From 74992e893f98e6bbd1323d318282ae8b42f5b343 Mon Sep 17 00:00:00 2001 From: A Date: Thu, 4 Jun 2026 01:37:39 +0800 Subject: [PATCH] Initial open source release --- .gitignore | 22 + README.md | 195 + README.zh-CN.md | 182 + cmd/telesrv/exe_integration_test.go | 276 + cmd/telesrv/main.go | 212 + .../tdesktop/tdesktop_en_v12000000.strings | 9104 ++++++++++++++ .../tdesktop_zh-hans_v12000000.strings | 7130 +++++++++++ deploy/deploy.go | 10 + deploy/docker-compose.yml | 48 + deploy/migrations/0001_init.down.sql | 4 + deploy/migrations/0001_init.up.sql | 48 + .../migrations/0002_phase1_business.down.sql | 5 + deploy/migrations/0002_phase1_business.up.sql | 73 + .../0003_startup_config_security.down.sql | 5 + .../0003_startup_config_security.up.sql | 64 + ...004_temp_auth_key_binding_session.down.sql | 2 + .../0004_temp_auth_key_binding_session.up.sql | 4 + .../0005_system_login_messages.down.sql | 11 + .../0005_system_login_messages.up.sql | 44 + deploy/migrations/0006_update_events.down.sql | 2 + deploy/migrations/0006_update_events.up.sql | 16 + .../0007_read_history_events.down.sql | 15 + .../0007_read_history_events.up.sql | 23 + .../0008_user_id_sequence_base.down.sql | 8 + .../0008_user_id_sequence_base.up.sql | 10 + .../0009_private_message_pipeline.down.sql | 13 + .../0009_private_message_pipeline.up.sql | 239 + .../0010_message_performance_indexes.down.sql | 2 + .../0010_message_performance_indexes.up.sql | 9 + .../migrations/0011_drop_dead_tables.down.sql | 6 + .../migrations/0011_drop_dead_tables.up.sql | 13 + .../0012_outbox_delete_on_deliver.down.sql | 5 + .../0012_outbox_delete_on_deliver.up.sql | 12 + ..._contact_profiles_and_dialog_pins.down.sql | 17 + ...13_contact_profiles_and_dialog_pins.up.sql | 25 + .../0014_settings_update_events.down.sql | 10 + .../0014_settings_update_events.up.sql | 25 + ...te_event_payloads_and_outbox_auth.down.sql | 8 + ...date_event_payloads_and_outbox_auth.up.sql | 14 + .../0016_delete_message_updates.down.sql | 21 + .../0016_delete_message_updates.up.sql | 29 + .../migrations/0017_dialog_folders.down.sql | 46 + deploy/migrations/0017_dialog_folders.up.sql | 91 + .../0018_user_search_indexes.down.sql | 5 + .../0018_user_search_indexes.up.sql | 19 + deploy/migrations/0019_usernames.down.sql | 1 + deploy/migrations/0019_usernames.up.sql | 5 + .../0020_profile_message_state.down.sql | 34 + .../0020_profile_message_state.up.sql | 46 + .../0021_message_reply_forward.down.sql | 31 + .../0021_message_reply_forward.up.sql | 35 + deploy/migrations/0022_channels.down.sql | 9 + deploy/migrations/0022_channels.up.sql | 398 + .../0023_channel_admin_invites.down.sql | 18 + .../0023_channel_admin_invites.up.sql | 22 + .../0024_channel_admin_log_backfill.down.sql | 6 + .../0024_channel_admin_log_backfill.up.sql | 78 + ..._channel_member_slowmode_backfill.down.sql | 4 + ...25_channel_member_slowmode_backfill.up.sql | 5 + .../0026_channel_read_participants.down.sql | 1 + .../0026_channel_read_participants.up.sql | 19 + ...27_channel_message_search_indexes.down.sql | 1 + ...0027_channel_message_search_indexes.up.sql | 10 + .../0028_channel_noforwards_backfill.down.sql | 4 + .../0028_channel_noforwards_backfill.up.sql | 7 + .../0029_channel_available_reactions.down.sql | 4 + .../0029_channel_available_reactions.up.sql | 7 + .../0030_user_update_channel_peers.down.sql | 8 + .../0030_user_update_channel_peers.up.sql | 8 + .../0031_channel_available_min_pts.down.sql | 2 + .../0031_channel_available_min_pts.up.sql | 4 + ...032_user_update_channel_available.down.sql | 23 + .../0032_user_update_channel_available.up.sql | 26 + .../0033_app_config_quote_length.down.sql | 7 + .../0033_app_config_quote_length.up.sql | 7 + .../0034_channel_invite_importers.down.sql | 6 + .../0034_channel_invite_importers.up.sql | 37 + .../0035_channel_join_settings.down.sql | 5 + .../0035_channel_join_settings.up.sql | 5 + .../0036_channel_discussion_links.down.sql | 4 + .../0036_channel_discussion_links.up.sql | 8 + .../0037_channel_message_threads.down.sql | 10 + .../0037_channel_message_threads.up.sql | 36 + .../0038_channel_default_send_as.down.sql | 6 + .../0038_channel_default_send_as.up.sql | 13 + .../0039_channel_message_views.down.sql | 4 + .../0039_channel_message_views.up.sql | 27 + .../0040_channel_unread_mentions.down.sql | 1 + .../0040_channel_unread_mentions.up.sql | 25 + .../0041_channel_participants_hidden.down.sql | 2 + .../0041_channel_participants_hidden.up.sql | 2 + .../migrations/0042_channel_colors.down.sql | 9 + deploy/migrations/0042_channel_colors.up.sql | 9 + ...43_channel_view_forum_as_messages.down.sql | 2 + ...0043_channel_view_forum_as_messages.up.sql | 2 + .../migrations/0044_channel_antispam.down.sql | 40 + .../migrations/0044_channel_antispam.up.sql | 44 + .../0045_channel_forum_tabs.down.sql | 45 + .../migrations/0045_channel_forum_tabs.up.sql | 48 + .../0046_channel_forum_topics.down.sql | 3 + .../0046_channel_forum_topics.up.sql | 55 + deploy/migrations/0047_dialog_drafts.down.sql | 1 + deploy/migrations/0047_dialog_drafts.up.sql | 31 + ...048_channel_recommendations_index.down.sql | 1 + .../0048_channel_recommendations_index.up.sql | 3 + ...hannel_public_peer_search_indexes.down.sql | 2 + ..._channel_public_peer_search_indexes.up.sql | 11 + .../0050_channel_message_reactions.down.sql | 1 + .../0050_channel_message_reactions.up.sql | 38 + ...51_channel_unread_reactions_index.down.sql | 1 + ...0051_channel_unread_reactions_index.up.sql | 3 + .../0052_user_recent_reactions.down.sql | 1 + .../0052_user_recent_reactions.up.sql | 14 + .../0053_user_top_reactions.down.sql | 1 + .../migrations/0053_user_top_reactions.up.sql | 16 + .../0054_user_saved_reaction_tags.down.sql | 1 + .../0054_user_saved_reaction_tags.up.sql | 17 + ...channel_paid_translation_settings.down.sql | 41 + ...5_channel_paid_translation_settings.up.sql | 41 + .../0056_update_watermarks_retention.down.sql | 3 + .../0056_update_watermarks_retention.up.sql | 12 + deploy/migrations/0057_media.down.sql | 23 + deploy/migrations/0057_media.up.sql | 143 + .../0058_app_config_reactions.down.sql | 11 + .../0058_app_config_reactions.up.sql | 12 + deploy/migrations/0059_channel_photo.down.sql | 6 + deploy/migrations/0059_channel_photo.up.sql | 6 + .../migrations/0060_user_last_seen.down.sql | 4 + deploy/migrations/0060_user_last_seen.up.sql | 8 + .../0061_private_message_reactions.down.sql | 4 + .../0061_private_message_reactions.up.sql | 20 + .../0062_account_reaction_settings.down.sql | 1 + .../0062_account_reaction_settings.up.sql | 23 + .../0063_reaction_update_events.down.sql | 24 + .../0063_reaction_update_events.up.sql | 28 + ...0064_channel_dialog_unread_repair.down.sql | 1 + .../0064_channel_dialog_unread_repair.up.sql | 21 + .../0065_user_channel_member_index.down.sql | 1 + .../0065_user_channel_member_index.up.sql | 29 + ...system_sticker_sets_not_installed.down.sql | 4 + ...6_system_sticker_sets_not_installed.up.sql | 8 + .../0067_seed_document_id_namespace.down.sql | 3 + .../0067_seed_document_id_namespace.up.sql | 191 + docs/assets/tdesktop-alice.png | Bin 0 -> 623532 bytes docs/assets/tdesktop-bob.png | Bin 0 -> 597106 bytes docs/assets/tdesktop-dual-session.png | Bin 0 -> 1056962 bytes docs/channel-module.md | 471 + docs/compatibility-matrix.md | 419 + docs/message-module.md | 252 + docs/performance-audit.md | 249 + docs/persistence-layer.md | 174 + docs/tdesktop-patch-notes.md | 116 + go.mod | 52 + go.sum | 172 + internal/app/account/doc.go | 2 + internal/app/account/service.go | 158 + internal/app/auth/doc.go | 5 + internal/app/auth/service.go | 362 + internal/app/auth/service_test.go | 259 + internal/app/channels/doc.go | 2 + internal/app/channels/service.go | 1514 +++ internal/app/channels/service_test.go | 1784 +++ internal/app/contacts/doc.go | 3 + internal/app/contacts/service.go | 266 + internal/app/contacts/service_test.go | 44 + internal/app/dialogs/doc.go | 3 + internal/app/dialogs/service.go | 593 + internal/app/dialogs/service_test.go | 282 + internal/app/files/blobcache.go | 236 + internal/app/files/blobcache_test.go | 233 + internal/app/files/blobfs.go | 105 + internal/app/files/blobfs_test.go | 92 + internal/app/files/doc.go | 6 + internal/app/files/photos.go | 278 + internal/app/files/seed.go | 860 ++ internal/app/files/seed_test.go | 526 + internal/app/files/service.go | 256 + internal/app/files/warm.go | 136 + internal/app/help/doc.go | 2 + internal/app/help/service.go | 76 + internal/app/langpack/parser.go | 109 + internal/app/langpack/parser_test.go | 37 + internal/app/langpack/seed.go | 56 + internal/app/langpack/service.go | 53 + internal/app/maintenance/retention.go | 74 + internal/app/messages/doc.go | 3 + internal/app/messages/service.go | 180 + internal/app/updates/contiguous_test.go | 154 + internal/app/updates/doc.go | 3 + internal/app/updates/service.go | 465 + internal/app/updates/service_test.go | 252 + internal/app/users/doc.go | 3 + internal/app/users/service.go | 313 + internal/app/users/service_test.go | 130 + internal/compat/tdesktop/config.go | 56 + internal/compat/tdesktop/defaults.go | 96 + internal/compat/tdesktop/doc.go | 8 + internal/compat/tdesktop/startup_stubs.go | 278 + .../compat/tdesktop/startup_stubs_test.go | 157 + internal/config/config.go | 127 + internal/domain/account.go | 65 + internal/domain/authorization.go | 15 + internal/domain/channel.go | 1546 +++ internal/domain/channel_errors.go | 68 + internal/domain/contact.go | 65 + internal/domain/dialog.go | 155 + internal/domain/doc.go | 4 + internal/domain/help.go | 30 + internal/domain/langpack.go | 24 + internal/domain/media.go | 331 + internal/domain/media_errors.go | 16 + internal/domain/message.go | 352 + internal/domain/message_errors.go | 12 + internal/domain/message_test.go | 27 + internal/domain/system.go | 19 + internal/domain/temp_auth_key.go | 11 + internal/domain/update.go | 10 + internal/domain/update_event.go | 58 + internal/domain/user.go | 63 + internal/domain/user_errors.go | 12 + internal/loadtest/doc.go | 13 + internal/loadtest/send_load_test.go | 478 + internal/mtprotoedge/conn.go | 114 + internal/mtprotoedge/destroy_auth_key.go | 33 + internal/mtprotoedge/doc.go | 6 + internal/mtprotoedge/e2e_test.go | 90 + internal/mtprotoedge/encrypted.go | 702 ++ internal/mtprotoedge/encrypted_test.go | 435 + internal/mtprotoedge/exchange.go | 152 + internal/mtprotoedge/exchange_test.go | 171 + internal/mtprotoedge/helpers_test.go | 209 + internal/mtprotoedge/inbound_rpc.go | 195 + internal/mtprotoedge/inbound_rpc_test.go | 76 + internal/mtprotoedge/login_e2e_test.go | 457 + internal/mtprotoedge/metrics.go | 67 + internal/mtprotoedge/metrics_test.go | 73 + internal/mtprotoedge/outbound.go | 647 + internal/mtprotoedge/outbound_test.go | 130 + internal/mtprotoedge/rpc_test.go | 145 + internal/mtprotoedge/rsakey.go | 63 + internal/mtprotoedge/server.go | 364 + internal/mtprotoedge/server_test.go | 165 + internal/mtprotoedge/session_manager.go | 978 ++ .../mtprotoedge/session_manager_bench_test.go | 215 + internal/mtprotoedge/session_manager_test.go | 296 + internal/rpc/account.go | 292 + internal/rpc/aicompose.go | 16 + internal/rpc/auth.go | 347 + internal/rpc/channel_interest.go | 182 + internal/rpc/channels.go | 4017 ++++++ internal/rpc/contacts.go | 562 + internal/rpc/context.go | 96 + internal/rpc/convert.go | 1919 +++ internal/rpc/convert_media.go | 385 + internal/rpc/deps.go | 333 + internal/rpc/doc.go | 5 + internal/rpc/errors.go | 194 + internal/rpc/folders.go | 67 + internal/rpc/help.go | 63 + internal/rpc/langpack.go | 41 + internal/rpc/messages.go | 6365 ++++++++++ internal/rpc/metrics.go | 25 + internal/rpc/outbox_dispatcher.go | 369 + internal/rpc/outbox_dispatcher_test.go | 458 + internal/rpc/payments.go | 22 + internal/rpc/photos.go | 250 + internal/rpc/photos_push_test.go | 58 + internal/rpc/premium.go | 142 + internal/rpc/presence.go | 490 + internal/rpc/push.go | 73 + internal/rpc/router.go | 416 + internal/rpc/router_test.go | 10137 +++++++++++++++ internal/rpc/send_media.go | 539 + internal/rpc/send_media_test.go | 238 + internal/rpc/stats.go | 211 + internal/rpc/stickers.go | 148 + internal/rpc/stickers_test.go | 188 + internal/rpc/stories.go | 49 + internal/rpc/update_peer_refs.go | 283 + internal/rpc/updates.go | 107 + internal/rpc/upload.go | 188 + internal/rpc/upload_test.go | 34 + internal/rpc/users.go | 187 + internal/store/account.go | 19 + internal/store/allocator.go | 26 + internal/store/authkey.go | 22 + internal/store/authorization.go | 15 + internal/store/channel.go | 140 + internal/store/code.go | 20 + internal/store/contact.go | 17 + internal/store/dialog.go | 32 + internal/store/dispatch_outbox.go | 27 + internal/store/doc.go | 13 + internal/store/help.go | 19 + internal/store/langpack.go | 14 + internal/store/media.go | 46 + internal/store/memory/channel.go | 6799 +++++++++++ internal/store/memory/channel_test.go | 714 ++ internal/store/memory/dialog_test.go | 229 + internal/store/memory/memory.go | 2883 +++++ internal/store/memory/message_test.go | 531 + internal/store/message.go | 24 + internal/store/postgres/account.go | 126 + .../postgres/auth_login_integration_test.go | 86 + internal/store/postgres/authkey.go | 67 + .../postgres/authkey_integration_test.go | 72 + internal/store/postgres/authorization.go | 93 + .../postgres/business_integration_test.go | 288 + internal/store/postgres/channel.go | 10149 ++++++++++++++++ .../postgres/channel_integration_test.go | 3165 +++++ internal/store/postgres/contact.go | 393 + .../contact_dialog_integration_test.go | 279 + .../contiguous_pts_integration_test.go | 127 + internal/store/postgres/counter_source.go | 78 + internal/store/postgres/dialog.go | 722 ++ .../dialog_folders_integration_test.go | 98 + internal/store/postgres/dispatch_outbox.go | 140 + internal/store/postgres/help.go | 141 + internal/store/postgres/langpack.go | 169 + internal/store/postgres/media.go | 509 + internal/store/postgres/media_codec.go | 88 + .../store/postgres/media_integration_test.go | 210 + internal/store/postgres/message.go | 2561 ++++ .../message_concurrent_integration_test.go | 114 + .../message_deadlock_integration_test.go | 119 + .../postgres/message_integration_test.go | 813 ++ .../message_media_integration_test.go | 181 + .../postgres/message_plan_integration_test.go | 160 + internal/store/postgres/postgres.go | 128 + internal/store/postgres/queries/account.sql | 22 + internal/store/postgres/queries/authkey.sql | 10 + .../store/postgres/queries/authorization.sql | 24 + internal/store/postgres/queries/contact.sql | 168 + internal/store/postgres/queries/dialog.sql | 773 ++ internal/store/postgres/queries/help.sql | 43 + internal/store/postgres/queries/langpack.sql | 47 + internal/store/postgres/queries/media.sql | 331 + internal/store/postgres/queries/message.sql | 932 ++ .../store/postgres/queries/temp_auth_key.sql | 23 + .../store/postgres/queries/update_state.sql | 24 + internal/store/postgres/queries/user.sql | 104 + .../postgres/queries/user_update_event.sql | 464 + .../store/postgres/sqlcgen/account.sql.go | 87 + .../store/postgres/sqlcgen/authkey.sql.go | 46 + .../postgres/sqlcgen/authorization.sql.go | 124 + .../store/postgres/sqlcgen/contact.sql.go | 426 + internal/store/postgres/sqlcgen/db.go | 32 + internal/store/postgres/sqlcgen/dialog.sql.go | 1592 +++ internal/store/postgres/sqlcgen/help.sql.go | 159 + .../store/postgres/sqlcgen/langpack.sql.go | 250 + internal/store/postgres/sqlcgen/media.sql.go | 1154 ++ .../store/postgres/sqlcgen/message.sql.go | 2331 ++++ internal/store/postgres/sqlcgen/models.go | 681 ++ .../postgres/sqlcgen/temp_auth_key.sql.go | 80 + .../postgres/sqlcgen/update_state.sql.go | 103 + internal/store/postgres/sqlcgen/user.sql.go | 426 + .../postgres/sqlcgen/user_update_event.sql.go | 1307 ++ internal/store/postgres/temp_auth_key.go | 54 + internal/store/postgres/update_event.go | 925 ++ internal/store/postgres/updatestate.go | 72 + internal/store/postgres/user.go | 234 + internal/store/ratelimit.go | 11 + internal/store/redisstore/allocator.go | 303 + .../redisstore/allocator_integration_test.go | 160 + internal/store/redisstore/code.go | 55 + internal/store/redisstore/ratelimit.go | 57 + internal/store/redisstore/redis.go | 25 + internal/store/redisstore/session.go | 75 + .../redisstore/session_integration_test.go | 52 + .../redisstore/user_counter_allocator.go | 112 + internal/store/session.go | 23 + internal/store/temp_auth_key.go | 13 + internal/store/update_event.go | 26 + internal/store/updatestate.go | 15 + internal/store/user.go | 22 + internal/testkit/doc.go | 3 + sqlc.yaml | 13 + 377 files changed, 118084 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 cmd/telesrv/exe_integration_test.go create mode 100644 cmd/telesrv/main.go create mode 100644 data/langpack/tdesktop/tdesktop_en_v12000000.strings create mode 100644 data/langpack/tdesktop/tdesktop_zh-hans_v12000000.strings create mode 100644 deploy/deploy.go create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/migrations/0001_init.down.sql create mode 100644 deploy/migrations/0001_init.up.sql create mode 100644 deploy/migrations/0002_phase1_business.down.sql create mode 100644 deploy/migrations/0002_phase1_business.up.sql create mode 100644 deploy/migrations/0003_startup_config_security.down.sql create mode 100644 deploy/migrations/0003_startup_config_security.up.sql create mode 100644 deploy/migrations/0004_temp_auth_key_binding_session.down.sql create mode 100644 deploy/migrations/0004_temp_auth_key_binding_session.up.sql create mode 100644 deploy/migrations/0005_system_login_messages.down.sql create mode 100644 deploy/migrations/0005_system_login_messages.up.sql create mode 100644 deploy/migrations/0006_update_events.down.sql create mode 100644 deploy/migrations/0006_update_events.up.sql create mode 100644 deploy/migrations/0007_read_history_events.down.sql create mode 100644 deploy/migrations/0007_read_history_events.up.sql create mode 100644 deploy/migrations/0008_user_id_sequence_base.down.sql create mode 100644 deploy/migrations/0008_user_id_sequence_base.up.sql create mode 100644 deploy/migrations/0009_private_message_pipeline.down.sql create mode 100644 deploy/migrations/0009_private_message_pipeline.up.sql create mode 100644 deploy/migrations/0010_message_performance_indexes.down.sql create mode 100644 deploy/migrations/0010_message_performance_indexes.up.sql create mode 100644 deploy/migrations/0011_drop_dead_tables.down.sql create mode 100644 deploy/migrations/0011_drop_dead_tables.up.sql create mode 100644 deploy/migrations/0012_outbox_delete_on_deliver.down.sql create mode 100644 deploy/migrations/0012_outbox_delete_on_deliver.up.sql create mode 100644 deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql create mode 100644 deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql create mode 100644 deploy/migrations/0014_settings_update_events.down.sql create mode 100644 deploy/migrations/0014_settings_update_events.up.sql create mode 100644 deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql create mode 100644 deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql create mode 100644 deploy/migrations/0016_delete_message_updates.down.sql create mode 100644 deploy/migrations/0016_delete_message_updates.up.sql create mode 100644 deploy/migrations/0017_dialog_folders.down.sql create mode 100644 deploy/migrations/0017_dialog_folders.up.sql create mode 100644 deploy/migrations/0018_user_search_indexes.down.sql create mode 100644 deploy/migrations/0018_user_search_indexes.up.sql create mode 100644 deploy/migrations/0019_usernames.down.sql create mode 100644 deploy/migrations/0019_usernames.up.sql create mode 100644 deploy/migrations/0020_profile_message_state.down.sql create mode 100644 deploy/migrations/0020_profile_message_state.up.sql create mode 100644 deploy/migrations/0021_message_reply_forward.down.sql create mode 100644 deploy/migrations/0021_message_reply_forward.up.sql create mode 100644 deploy/migrations/0022_channels.down.sql create mode 100644 deploy/migrations/0022_channels.up.sql create mode 100644 deploy/migrations/0023_channel_admin_invites.down.sql create mode 100644 deploy/migrations/0023_channel_admin_invites.up.sql create mode 100644 deploy/migrations/0024_channel_admin_log_backfill.down.sql create mode 100644 deploy/migrations/0024_channel_admin_log_backfill.up.sql create mode 100644 deploy/migrations/0025_channel_member_slowmode_backfill.down.sql create mode 100644 deploy/migrations/0025_channel_member_slowmode_backfill.up.sql create mode 100644 deploy/migrations/0026_channel_read_participants.down.sql create mode 100644 deploy/migrations/0026_channel_read_participants.up.sql create mode 100644 deploy/migrations/0027_channel_message_search_indexes.down.sql create mode 100644 deploy/migrations/0027_channel_message_search_indexes.up.sql create mode 100644 deploy/migrations/0028_channel_noforwards_backfill.down.sql create mode 100644 deploy/migrations/0028_channel_noforwards_backfill.up.sql create mode 100644 deploy/migrations/0029_channel_available_reactions.down.sql create mode 100644 deploy/migrations/0029_channel_available_reactions.up.sql create mode 100644 deploy/migrations/0030_user_update_channel_peers.down.sql create mode 100644 deploy/migrations/0030_user_update_channel_peers.up.sql create mode 100644 deploy/migrations/0031_channel_available_min_pts.down.sql create mode 100644 deploy/migrations/0031_channel_available_min_pts.up.sql create mode 100644 deploy/migrations/0032_user_update_channel_available.down.sql create mode 100644 deploy/migrations/0032_user_update_channel_available.up.sql create mode 100644 deploy/migrations/0033_app_config_quote_length.down.sql create mode 100644 deploy/migrations/0033_app_config_quote_length.up.sql create mode 100644 deploy/migrations/0034_channel_invite_importers.down.sql create mode 100644 deploy/migrations/0034_channel_invite_importers.up.sql create mode 100644 deploy/migrations/0035_channel_join_settings.down.sql create mode 100644 deploy/migrations/0035_channel_join_settings.up.sql create mode 100644 deploy/migrations/0036_channel_discussion_links.down.sql create mode 100644 deploy/migrations/0036_channel_discussion_links.up.sql create mode 100644 deploy/migrations/0037_channel_message_threads.down.sql create mode 100644 deploy/migrations/0037_channel_message_threads.up.sql create mode 100644 deploy/migrations/0038_channel_default_send_as.down.sql create mode 100644 deploy/migrations/0038_channel_default_send_as.up.sql create mode 100644 deploy/migrations/0039_channel_message_views.down.sql create mode 100644 deploy/migrations/0039_channel_message_views.up.sql create mode 100644 deploy/migrations/0040_channel_unread_mentions.down.sql create mode 100644 deploy/migrations/0040_channel_unread_mentions.up.sql create mode 100644 deploy/migrations/0041_channel_participants_hidden.down.sql create mode 100644 deploy/migrations/0041_channel_participants_hidden.up.sql create mode 100644 deploy/migrations/0042_channel_colors.down.sql create mode 100644 deploy/migrations/0042_channel_colors.up.sql create mode 100644 deploy/migrations/0043_channel_view_forum_as_messages.down.sql create mode 100644 deploy/migrations/0043_channel_view_forum_as_messages.up.sql create mode 100644 deploy/migrations/0044_channel_antispam.down.sql create mode 100644 deploy/migrations/0044_channel_antispam.up.sql create mode 100644 deploy/migrations/0045_channel_forum_tabs.down.sql create mode 100644 deploy/migrations/0045_channel_forum_tabs.up.sql create mode 100644 deploy/migrations/0046_channel_forum_topics.down.sql create mode 100644 deploy/migrations/0046_channel_forum_topics.up.sql create mode 100644 deploy/migrations/0047_dialog_drafts.down.sql create mode 100644 deploy/migrations/0047_dialog_drafts.up.sql create mode 100644 deploy/migrations/0048_channel_recommendations_index.down.sql create mode 100644 deploy/migrations/0048_channel_recommendations_index.up.sql create mode 100644 deploy/migrations/0049_channel_public_peer_search_indexes.down.sql create mode 100644 deploy/migrations/0049_channel_public_peer_search_indexes.up.sql create mode 100644 deploy/migrations/0050_channel_message_reactions.down.sql create mode 100644 deploy/migrations/0050_channel_message_reactions.up.sql create mode 100644 deploy/migrations/0051_channel_unread_reactions_index.down.sql create mode 100644 deploy/migrations/0051_channel_unread_reactions_index.up.sql create mode 100644 deploy/migrations/0052_user_recent_reactions.down.sql create mode 100644 deploy/migrations/0052_user_recent_reactions.up.sql create mode 100644 deploy/migrations/0053_user_top_reactions.down.sql create mode 100644 deploy/migrations/0053_user_top_reactions.up.sql create mode 100644 deploy/migrations/0054_user_saved_reaction_tags.down.sql create mode 100644 deploy/migrations/0054_user_saved_reaction_tags.up.sql create mode 100644 deploy/migrations/0055_channel_paid_translation_settings.down.sql create mode 100644 deploy/migrations/0055_channel_paid_translation_settings.up.sql create mode 100644 deploy/migrations/0056_update_watermarks_retention.down.sql create mode 100644 deploy/migrations/0056_update_watermarks_retention.up.sql create mode 100644 deploy/migrations/0057_media.down.sql create mode 100644 deploy/migrations/0057_media.up.sql create mode 100644 deploy/migrations/0058_app_config_reactions.down.sql create mode 100644 deploy/migrations/0058_app_config_reactions.up.sql create mode 100644 deploy/migrations/0059_channel_photo.down.sql create mode 100644 deploy/migrations/0059_channel_photo.up.sql create mode 100644 deploy/migrations/0060_user_last_seen.down.sql create mode 100644 deploy/migrations/0060_user_last_seen.up.sql create mode 100644 deploy/migrations/0061_private_message_reactions.down.sql create mode 100644 deploy/migrations/0061_private_message_reactions.up.sql create mode 100644 deploy/migrations/0062_account_reaction_settings.down.sql create mode 100644 deploy/migrations/0062_account_reaction_settings.up.sql create mode 100644 deploy/migrations/0063_reaction_update_events.down.sql create mode 100644 deploy/migrations/0063_reaction_update_events.up.sql create mode 100644 deploy/migrations/0064_channel_dialog_unread_repair.down.sql create mode 100644 deploy/migrations/0064_channel_dialog_unread_repair.up.sql create mode 100644 deploy/migrations/0065_user_channel_member_index.down.sql create mode 100644 deploy/migrations/0065_user_channel_member_index.up.sql create mode 100644 deploy/migrations/0066_system_sticker_sets_not_installed.down.sql create mode 100644 deploy/migrations/0066_system_sticker_sets_not_installed.up.sql create mode 100644 deploy/migrations/0067_seed_document_id_namespace.down.sql create mode 100644 deploy/migrations/0067_seed_document_id_namespace.up.sql create mode 100644 docs/assets/tdesktop-alice.png create mode 100644 docs/assets/tdesktop-bob.png create mode 100644 docs/assets/tdesktop-dual-session.png create mode 100644 docs/channel-module.md create mode 100644 docs/compatibility-matrix.md create mode 100644 docs/message-module.md create mode 100644 docs/performance-audit.md create mode 100644 docs/persistence-layer.md create mode 100644 docs/tdesktop-patch-notes.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/account/doc.go create mode 100644 internal/app/account/service.go create mode 100644 internal/app/auth/doc.go create mode 100644 internal/app/auth/service.go create mode 100644 internal/app/auth/service_test.go create mode 100644 internal/app/channels/doc.go create mode 100644 internal/app/channels/service.go create mode 100644 internal/app/channels/service_test.go create mode 100644 internal/app/contacts/doc.go create mode 100644 internal/app/contacts/service.go create mode 100644 internal/app/contacts/service_test.go create mode 100644 internal/app/dialogs/doc.go create mode 100644 internal/app/dialogs/service.go create mode 100644 internal/app/dialogs/service_test.go create mode 100644 internal/app/files/blobcache.go create mode 100644 internal/app/files/blobcache_test.go create mode 100644 internal/app/files/blobfs.go create mode 100644 internal/app/files/blobfs_test.go create mode 100644 internal/app/files/doc.go create mode 100644 internal/app/files/photos.go create mode 100644 internal/app/files/seed.go create mode 100644 internal/app/files/seed_test.go create mode 100644 internal/app/files/service.go create mode 100644 internal/app/files/warm.go create mode 100644 internal/app/help/doc.go create mode 100644 internal/app/help/service.go create mode 100644 internal/app/langpack/parser.go create mode 100644 internal/app/langpack/parser_test.go create mode 100644 internal/app/langpack/seed.go create mode 100644 internal/app/langpack/service.go create mode 100644 internal/app/maintenance/retention.go create mode 100644 internal/app/messages/doc.go create mode 100644 internal/app/messages/service.go create mode 100644 internal/app/updates/contiguous_test.go create mode 100644 internal/app/updates/doc.go create mode 100644 internal/app/updates/service.go create mode 100644 internal/app/updates/service_test.go create mode 100644 internal/app/users/doc.go create mode 100644 internal/app/users/service.go create mode 100644 internal/app/users/service_test.go create mode 100644 internal/compat/tdesktop/config.go create mode 100644 internal/compat/tdesktop/defaults.go create mode 100644 internal/compat/tdesktop/doc.go create mode 100644 internal/compat/tdesktop/startup_stubs.go create mode 100644 internal/compat/tdesktop/startup_stubs_test.go create mode 100644 internal/config/config.go create mode 100644 internal/domain/account.go create mode 100644 internal/domain/authorization.go create mode 100644 internal/domain/channel.go create mode 100644 internal/domain/channel_errors.go create mode 100644 internal/domain/contact.go create mode 100644 internal/domain/dialog.go create mode 100644 internal/domain/doc.go create mode 100644 internal/domain/help.go create mode 100644 internal/domain/langpack.go create mode 100644 internal/domain/media.go create mode 100644 internal/domain/media_errors.go create mode 100644 internal/domain/message.go create mode 100644 internal/domain/message_errors.go create mode 100644 internal/domain/message_test.go create mode 100644 internal/domain/system.go create mode 100644 internal/domain/temp_auth_key.go create mode 100644 internal/domain/update.go create mode 100644 internal/domain/update_event.go create mode 100644 internal/domain/user.go create mode 100644 internal/domain/user_errors.go create mode 100644 internal/loadtest/doc.go create mode 100644 internal/loadtest/send_load_test.go create mode 100644 internal/mtprotoedge/conn.go create mode 100644 internal/mtprotoedge/destroy_auth_key.go create mode 100644 internal/mtprotoedge/doc.go create mode 100644 internal/mtprotoedge/e2e_test.go create mode 100644 internal/mtprotoedge/encrypted.go create mode 100644 internal/mtprotoedge/encrypted_test.go create mode 100644 internal/mtprotoedge/exchange.go create mode 100644 internal/mtprotoedge/exchange_test.go create mode 100644 internal/mtprotoedge/helpers_test.go create mode 100644 internal/mtprotoedge/inbound_rpc.go create mode 100644 internal/mtprotoedge/inbound_rpc_test.go create mode 100644 internal/mtprotoedge/login_e2e_test.go create mode 100644 internal/mtprotoedge/metrics.go create mode 100644 internal/mtprotoedge/metrics_test.go create mode 100644 internal/mtprotoedge/outbound.go create mode 100644 internal/mtprotoedge/outbound_test.go create mode 100644 internal/mtprotoedge/rpc_test.go create mode 100644 internal/mtprotoedge/rsakey.go create mode 100644 internal/mtprotoedge/server.go create mode 100644 internal/mtprotoedge/server_test.go create mode 100644 internal/mtprotoedge/session_manager.go create mode 100644 internal/mtprotoedge/session_manager_bench_test.go create mode 100644 internal/mtprotoedge/session_manager_test.go create mode 100644 internal/rpc/account.go create mode 100644 internal/rpc/aicompose.go create mode 100644 internal/rpc/auth.go create mode 100644 internal/rpc/channel_interest.go create mode 100644 internal/rpc/channels.go create mode 100644 internal/rpc/contacts.go create mode 100644 internal/rpc/context.go create mode 100644 internal/rpc/convert.go create mode 100644 internal/rpc/convert_media.go create mode 100644 internal/rpc/deps.go create mode 100644 internal/rpc/doc.go create mode 100644 internal/rpc/errors.go create mode 100644 internal/rpc/folders.go create mode 100644 internal/rpc/help.go create mode 100644 internal/rpc/langpack.go create mode 100644 internal/rpc/messages.go create mode 100644 internal/rpc/metrics.go create mode 100644 internal/rpc/outbox_dispatcher.go create mode 100644 internal/rpc/outbox_dispatcher_test.go create mode 100644 internal/rpc/payments.go create mode 100644 internal/rpc/photos.go create mode 100644 internal/rpc/photos_push_test.go create mode 100644 internal/rpc/premium.go create mode 100644 internal/rpc/presence.go create mode 100644 internal/rpc/push.go create mode 100644 internal/rpc/router.go create mode 100644 internal/rpc/router_test.go create mode 100644 internal/rpc/send_media.go create mode 100644 internal/rpc/send_media_test.go create mode 100644 internal/rpc/stats.go create mode 100644 internal/rpc/stickers.go create mode 100644 internal/rpc/stickers_test.go create mode 100644 internal/rpc/stories.go create mode 100644 internal/rpc/update_peer_refs.go create mode 100644 internal/rpc/updates.go create mode 100644 internal/rpc/upload.go create mode 100644 internal/rpc/upload_test.go create mode 100644 internal/rpc/users.go create mode 100644 internal/store/account.go create mode 100644 internal/store/allocator.go create mode 100644 internal/store/authkey.go create mode 100644 internal/store/authorization.go create mode 100644 internal/store/channel.go create mode 100644 internal/store/code.go create mode 100644 internal/store/contact.go create mode 100644 internal/store/dialog.go create mode 100644 internal/store/dispatch_outbox.go create mode 100644 internal/store/doc.go create mode 100644 internal/store/help.go create mode 100644 internal/store/langpack.go create mode 100644 internal/store/media.go create mode 100644 internal/store/memory/channel.go create mode 100644 internal/store/memory/channel_test.go create mode 100644 internal/store/memory/dialog_test.go create mode 100644 internal/store/memory/memory.go create mode 100644 internal/store/memory/message_test.go create mode 100644 internal/store/message.go create mode 100644 internal/store/postgres/account.go create mode 100644 internal/store/postgres/auth_login_integration_test.go create mode 100644 internal/store/postgres/authkey.go create mode 100644 internal/store/postgres/authkey_integration_test.go create mode 100644 internal/store/postgres/authorization.go create mode 100644 internal/store/postgres/business_integration_test.go create mode 100644 internal/store/postgres/channel.go create mode 100644 internal/store/postgres/channel_integration_test.go create mode 100644 internal/store/postgres/contact.go create mode 100644 internal/store/postgres/contact_dialog_integration_test.go create mode 100644 internal/store/postgres/contiguous_pts_integration_test.go create mode 100644 internal/store/postgres/counter_source.go create mode 100644 internal/store/postgres/dialog.go create mode 100644 internal/store/postgres/dialog_folders_integration_test.go create mode 100644 internal/store/postgres/dispatch_outbox.go create mode 100644 internal/store/postgres/help.go create mode 100644 internal/store/postgres/langpack.go create mode 100644 internal/store/postgres/media.go create mode 100644 internal/store/postgres/media_codec.go create mode 100644 internal/store/postgres/media_integration_test.go create mode 100644 internal/store/postgres/message.go create mode 100644 internal/store/postgres/message_concurrent_integration_test.go create mode 100644 internal/store/postgres/message_deadlock_integration_test.go create mode 100644 internal/store/postgres/message_integration_test.go create mode 100644 internal/store/postgres/message_media_integration_test.go create mode 100644 internal/store/postgres/message_plan_integration_test.go create mode 100644 internal/store/postgres/postgres.go create mode 100644 internal/store/postgres/queries/account.sql create mode 100644 internal/store/postgres/queries/authkey.sql create mode 100644 internal/store/postgres/queries/authorization.sql create mode 100644 internal/store/postgres/queries/contact.sql create mode 100644 internal/store/postgres/queries/dialog.sql create mode 100644 internal/store/postgres/queries/help.sql create mode 100644 internal/store/postgres/queries/langpack.sql create mode 100644 internal/store/postgres/queries/media.sql create mode 100644 internal/store/postgres/queries/message.sql create mode 100644 internal/store/postgres/queries/temp_auth_key.sql create mode 100644 internal/store/postgres/queries/update_state.sql create mode 100644 internal/store/postgres/queries/user.sql create mode 100644 internal/store/postgres/queries/user_update_event.sql create mode 100644 internal/store/postgres/sqlcgen/account.sql.go create mode 100644 internal/store/postgres/sqlcgen/authkey.sql.go create mode 100644 internal/store/postgres/sqlcgen/authorization.sql.go create mode 100644 internal/store/postgres/sqlcgen/contact.sql.go create mode 100644 internal/store/postgres/sqlcgen/db.go create mode 100644 internal/store/postgres/sqlcgen/dialog.sql.go create mode 100644 internal/store/postgres/sqlcgen/help.sql.go create mode 100644 internal/store/postgres/sqlcgen/langpack.sql.go create mode 100644 internal/store/postgres/sqlcgen/media.sql.go create mode 100644 internal/store/postgres/sqlcgen/message.sql.go create mode 100644 internal/store/postgres/sqlcgen/models.go create mode 100644 internal/store/postgres/sqlcgen/temp_auth_key.sql.go create mode 100644 internal/store/postgres/sqlcgen/update_state.sql.go create mode 100644 internal/store/postgres/sqlcgen/user.sql.go create mode 100644 internal/store/postgres/sqlcgen/user_update_event.sql.go create mode 100644 internal/store/postgres/temp_auth_key.go create mode 100644 internal/store/postgres/update_event.go create mode 100644 internal/store/postgres/updatestate.go create mode 100644 internal/store/postgres/user.go create mode 100644 internal/store/ratelimit.go create mode 100644 internal/store/redisstore/allocator.go create mode 100644 internal/store/redisstore/allocator_integration_test.go create mode 100644 internal/store/redisstore/code.go create mode 100644 internal/store/redisstore/ratelimit.go create mode 100644 internal/store/redisstore/redis.go create mode 100644 internal/store/redisstore/session.go create mode 100644 internal/store/redisstore/session_integration_test.go create mode 100644 internal/store/redisstore/user_counter_allocator.go create mode 100644 internal/store/session.go create mode 100644 internal/store/temp_auth_key.go create mode 100644 internal/store/update_event.go create mode 100644 internal/store/updatestate.go create mode 100644 internal/store/user.go create mode 100644 internal/testkit/doc.go create mode 100644 sqlc.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7ece1dd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Go build artifacts +/bin/ +*.exe +*.test +*.out +coverage.* + +# 本地环境 / 密钥(server RSA private key 必须持久化,但禁止入库) +*.local +*.pem +*.key +/secrets/ +/data/* +!/data/langpack/ +!/data/langpack/** +logs/ +.gocache/ +.tdesktop-e2e/ + +# IDE +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..1431d0b1 --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# telesrv + +`telesrv` is a Telegram-like MTProto server written in Go. It uses +[`github.com/gotd/td`](https://github.com/gotd/td) v0.144.0 / Layer 225 as the TL +and MTProto base, and its first compatibility target is a pinned Telegram +Desktop build. + +`telesrv` is an independent, unofficial project. It is not affiliated with, +endorsed by, or sponsored by Telegram or the official Telegram team. + +[中文 README](README.zh-CN.md) + +![Telegram Desktop Alice/Bob connected to telesrv](docs/assets/tdesktop-dual-session.png) + +## Status + +This project is useful for local protocol research and Telegram Desktop +compatibility work. It is not a production Telegram replacement. + +Implemented main paths include MTProto key exchange, login with a development +code, users/contacts/dialogs, private messages, supergroups/channels, update +difference recovery, local media/files, profile/channel photos, stickers, +reactions, and presence. Large-scale public channels, multi-DC/file-DC/CDN, +Bot API, payments, stories, Premium business logic, production abuse controls, +and production object storage are intentionally out of scope for now. + +## Contributing + +Contributions are very welcome. The most helpful areas right now are Telegram +Desktop compatibility reports, reproducible RPC traces, focused bug fixes, +tests for online/offline update behavior, performance work on already +implemented paths, and documentation that makes local setup easier. + +Please keep changes scoped and compatibility-driven. If a change affects +Telegram Desktop behavior, include the client version/commit, the RPC path you +tested, and whether server logs stayed free of `NOT_IMPLEMENTED`, `Unhandled +RPC`, `bad_msg`, panic, or internal errors. + +## Repository Layout + +```text +cmd/telesrv/ server entrypoint +deploy/ docker-compose and PostgreSQL migrations +internal/mtprotoedge/ MTProto transport, auth key, session, ack/resend +internal/rpc/ TL router and Telegram Desktop compatibility handlers +internal/app/ domain services +internal/domain/ protocol-independent domain models +internal/store/ store interfaces and memory/postgres/redis backends +docs/ compatibility notes and module design docs +``` + +## Run telesrv + +Requirements: + +- Go 1.25 or newer +- Docker Desktop or Docker Engine with Compose +- OpenSSL, if you want to build a matching Telegram Desktop client + +Start PostgreSQL and Redis: + +```powershell +docker compose -f deploy/docker-compose.yml up -d +``` + +Build and run the server: + +```powershell +go build -o bin/telesrv.exe ./cmd/telesrv +.\bin\telesrv.exe +``` + +On first start, `telesrv` creates `data/server_rsa.pem`, applies all database +migrations, seeds bundled language packs, and listens on `0.0.0.0:2398`. + +Useful development environment variables: + +| Variable | Default | Meaning | +|---|---:|---| +| `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto listen address | +| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | IP written into `help.getConfig` | +| `TELESRV_DC` | `2` | self-hosted DC id | +| `TELESRV_DEV_AUTH_CODE` | `12345` | fixed login code for local development | +| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL connection string | +| `TELESRV_REDIS_ADDR` | `localhost:6399` | Redis address | +| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | optional exported sticker/reaction seed directory | + +The optional sticker seed directory is skipped when it does not exist. + +## Build Telegram Desktop For telesrv + +The stock Telegram Desktop binary will not connect to `telesrv`: it trusts +Telegram's production DC list and RSA keys. Build your own patched client. + +Target baseline: + +- Telegram Desktop commit: `9caf32dffc90ddd9bb08ad5777b865f729fa167b` +- TL layer: 225 +- Local DC: `127.0.0.1:2398`, DC id `2` + +Clone and pin Telegram Desktop: + +```powershell +git clone --recursive https://github.com/telegramdesktop/tdesktop.git +cd tdesktop +git checkout 9caf32dffc90ddd9bb08ad5777b865f729fa167b +git submodule update --init --recursive +``` + +Build prerequisites and exact platform instructions are maintained upstream: + +- Windows: `docs/building-win.md` +- macOS: `docs/building-mac.md` +- Linux: `docs/building-linux.md` + +For Windows x64, the pinned upstream instructions currently boil down to: + +```powershell +Telegram\build\prepare\win.bat +cd Telegram +configure.bat x64 -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH +``` + +Then open `out\Telegram.slnx` in Visual Studio and build the `Telegram` +project. The debug binary is written to `out\Debug\Telegram.exe`. + +## Patch Telegram Desktop + +After `telesrv` has generated `data/server_rsa.pem`, export the matching public +key: + +```powershell +openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub +``` + +Patch Telegram Desktop file +`Telegram/SourceFiles/mtproto/mtproto_dc_options.cpp`: + +1. Replace built-in production and test DC lists with local DC 2: + +```cpp +const BuiltInDc kBuiltInDcs[] = { + { 2, "127.0.0.1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsIPv6[] = { + { 2, "::1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsTest[] = { + { 2, "127.0.0.1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsIPv6Test[] = { + { 2, "::1", 2398 }, +}; +``` + +2. Replace both `kPublicRSAKeys` and `kTestPublicRSAKeys` with the contents of + `data/server_rsa.pub`. +3. In `DcOptions::constructFromBuiltIn()`, add `Flag::f_tcpo_only` to the IPv4 + and IPv6 built-in DC flags. + +Keep this client patch minimal: DC endpoints, RSA key, and TCP-only flags only. +Do not mix UI changes into the protocol patch. + +## Run Two Local Desktop Clients + +Use separate TDesktop working directories so Alice and Bob do not share `tdata`: + +```powershell +$tdesktop = "C:\path\to\tdesktop\out\Debug\Telegram.exe" +Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-alice") +Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-bob") +``` + +Log in with two different phone numbers. In local development, the login code is +`12345` unless you changed `TELESRV_DEV_AUTH_CODE`. + +If the client keeps reconnecting, check these first: + +- `telesrv` is listening on port `2398`. +- `data/server_rsa.pub` was copied into both RSA key arrays in TDesktop. +- `TELESRV_ADVERTISE_IP` matches the address reachable from the client. +- TDesktop was built from the pinned Layer 225 baseline or re-audited for a new + layer. + +## Documentation + +- [Compatibility matrix](docs/compatibility-matrix.md) +- [Telegram Desktop patch notes](docs/tdesktop-patch-notes.md) +- [Persistence layer](docs/persistence-layer.md) +- [Message module](docs/message-module.md) +- [Channel module](docs/channel-module.md) +- [Performance audit](docs/performance-audit.md) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..d076637b --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,182 @@ +# telesrv + +`telesrv` 是一个用 Go 编写的 Telegram-like MTProto server。它以 +[`github.com/gotd/td`](https://github.com/gotd/td) v0.144.0 / Layer 225 作为 +TL 与 MTProto 基础,第一兼容目标是固定基线的 Telegram Desktop。 + +`telesrv` 是独立的非官方项目,与 Telegram 官方及其团队没有关联,也未获得其背书或赞助。 + +[English README](README.md) + +![Telegram Desktop Alice/Bob connected to telesrv](docs/assets/tdesktop-dual-session.png) + +## 当前状态 + +本项目适合本地协议研究、Telegram Desktop 兼容性验证和自建 MTProto server 实验。它不是生产级 Telegram 替代品。 + +当前已覆盖的主路径包括 MTProto key exchange、开发验证码登录、users/contacts/dialogs、私聊消息、超级群/频道、updates difference 恢复、本地 media/files、用户/频道头像、stickers、reactions 和 presence。 + +暂不默认覆盖大规模公开频道、多 DC / 文件 DC / CDN、Bot API、payments、stories、Premium 商业逻辑、生产风控、生产对象存储等能力。 + +## 欢迎贡献 + +欢迎大家参与贡献。现在最有价值的方向包括 Telegram Desktop 兼容性报告、可复现 RPC trace、聚焦的小 bug fix、在线/离线 updates 行为测试、已实现路径的性能优化,以及让本地启动更顺滑的文档改进。 + +请尽量保持改动范围清晰,并围绕兼容性目标展开。如果改动会影响 Telegram Desktop 可见行为,请在 PR 或说明里写清客户端版本/commit、验证过的 RPC 路径,以及 server 日志是否没有新增 `NOT_IMPLEMENTED`、`Unhandled RPC`、`bad_msg`、panic 或 internal error。 + +## 仓库结构 + +```text +cmd/telesrv/ server 启动入口 +deploy/ docker-compose 与 PostgreSQL migrations +internal/mtprotoedge/ MTProto transport、auth key、session、ack/resend +internal/rpc/ TL router 与 Telegram Desktop 兼容 handlers +internal/app/ domain services +internal/domain/ 不依赖协议生成类型的 domain models +internal/store/ store interfaces 与 memory/postgres/redis 后端 +docs/ 兼容性记录与模块设计文档 +``` + +## 运行 telesrv + +依赖: + +- Go 1.25 或更新版本 +- Docker Desktop 或带 Compose 的 Docker Engine +- OpenSSL,如果要编译匹配的 Telegram Desktop 客户端 + +启动 PostgreSQL 和 Redis: + +```powershell +docker compose -f deploy/docker-compose.yml up -d +``` + +编译并启动 server: + +```powershell +go build -o bin/telesrv.exe ./cmd/telesrv +.\bin\telesrv.exe +``` + +第一次启动时,`telesrv` 会创建 `data/server_rsa.pem`,自动执行所有数据库 migrations,导入内置语言包,并监听 `0.0.0.0:2398`。 + +常用开发环境变量: + +| 变量 | 默认值 | 说明 | +|---|---:|---| +| `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto 监听地址 | +| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 写入 `help.getConfig` 的客户端连接 IP | +| `TELESRV_DC` | `2` | 自建 DC id | +| `TELESRV_DEV_AUTH_CODE` | `12345` | 本地开发固定登录验证码 | +| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL 连接串 | +| `TELESRV_REDIS_ADDR` | `localhost:6399` | Redis 地址 | +| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | 可选的 sticker/reaction 导出种子目录 | + +如果 sticker seed 目录不存在,启动时会自动跳过。 + +## 编译连接 telesrv 的 Telegram Desktop + +官方 Telegram Desktop 二进制不能直接连接 `telesrv`,因为它信任的是 Telegram 官方 DC 列表和 RSA keys。你需要编译一个最小 patch 过的客户端。 + +目标基线: + +- Telegram Desktop commit:`9caf32dffc90ddd9bb08ad5777b865f729fa167b` +- TL layer:225 +- 本地 DC:`127.0.0.1:2398`,DC id `2` + +克隆并固定 Telegram Desktop: + +```powershell +git clone --recursive https://github.com/telegramdesktop/tdesktop.git +cd tdesktop +git checkout 9caf32dffc90ddd9bb08ad5777b865f729fa167b +git submodule update --init --recursive +``` + +编译依赖和各平台完整说明以 Telegram Desktop 上游文档为准: + +- Windows:`docs/building-win.md` +- macOS:`docs/building-mac.md` +- Linux:`docs/building-linux.md` + +Windows x64 下,固定基线的主要步骤是: + +```powershell +Telegram\build\prepare\win.bat +cd Telegram +configure.bat x64 -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH +``` + +然后用 Visual Studio 打开 `out\Telegram.slnx`,构建 `Telegram` project。Debug 二进制会生成在 `out\Debug\Telegram.exe`。 + +## Patch Telegram Desktop + +等 `telesrv` 生成 `data/server_rsa.pem` 后,导出匹配的公钥: + +```powershell +openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub +``` + +修改 Telegram Desktop 文件: + +```text +Telegram/SourceFiles/mtproto/mtproto_dc_options.cpp +``` + +1. 把内置 production/test DC 列表替换为本地 DC 2: + +```cpp +const BuiltInDc kBuiltInDcs[] = { + { 2, "127.0.0.1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsIPv6[] = { + { 2, "::1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsTest[] = { + { 2, "127.0.0.1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsIPv6Test[] = { + { 2, "::1", 2398 }, +}; +``` + +2. 把 `kPublicRSAKeys` 和 `kTestPublicRSAKeys` 都替换为 `data/server_rsa.pub` 的内容。 +3. 在 `DcOptions::constructFromBuiltIn()` 中给 IPv4 与 IPv6 built-in DC flags 加上 `Flag::f_tcpo_only`。 + +```cpp +const auto flags = Flag::f_static | Flag::f_tcpo_only; +const auto flags = Flag::f_static | Flag::f_ipv6 | Flag::f_tcpo_only; +``` + +客户端 patch 应保持最小:只改 DC endpoint、RSA public key 和 TCP-only flags,不要把 UI 改动混入协议兼容 patch。 + +## 启动两个本地 Desktop 客户端 + +用不同的 TDesktop working directory,避免 Alice 和 Bob 共用同一个 `tdata`: + +```powershell +$tdesktop = "C:\path\to\tdesktop\out\Debug\Telegram.exe" +Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-alice") +Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-bob") +``` + +用两个不同手机号登录。本地开发默认验证码是 `12345`,除非你修改了 `TELESRV_DEV_AUTH_CODE`。 + +如果客户端一直重连,优先检查: + +- `telesrv` 是否正在监听 `2398`。 +- `data/server_rsa.pub` 是否同时复制到了 TDesktop 的两个 RSA key 数组。 +- `TELESRV_ADVERTISE_IP` 是否是客户端可访问的地址。 +- TDesktop 是否基于固定 Layer 225 基线构建,或者你已经重新审计了新 layer。 + +## 文档 + +- [兼容矩阵](docs/compatibility-matrix.md) +- [Telegram Desktop patch notes](docs/tdesktop-patch-notes.md) +- [持久化层设计](docs/persistence-layer.md) +- [消息模块](docs/message-module.md) +- [频道模块](docs/channel-module.md) +- [性能审计](docs/performance-audit.md) diff --git a/cmd/telesrv/exe_integration_test.go b/cmd/telesrv/exe_integration_test.go new file mode 100644 index 00000000..44cd425b --- /dev/null +++ b/cmd/telesrv/exe_integration_test.go @@ -0,0 +1,276 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + tdcrypto "github.com/gotd/td/crypto" + "github.com/gotd/td/exchange" + "github.com/gotd/td/mtproxy" + "github.com/gotd/td/mtproxy/obfuscator" + "github.com/gotd/td/proto/codec" + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/dcs" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/mtprotoedge" +) + +func TestExecutablePrivateMessageRoundTrip(t *testing.T) { + exe := os.Getenv("TELESRV_TEST_EXE") + if exe == "" { + t.Skip("set TELESRV_TEST_EXE to run executable-level MTProto roundtrip") + } + repoRoot := filepath.Clean(filepath.Join("..", "..")) + if !filepath.IsAbs(exe) { + exe = filepath.Join(repoRoot, exe) + } + exeAbs, err := filepath.Abs(exe) + if err != nil { + t.Fatalf("resolve executable path: %v", err) + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve listen port: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, exeAbs) + cmd.Dir = repoRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), + "TELESRV_LISTEN="+addr, + "TELESRV_ADVERTISE_IP=127.0.0.1", + ) + if err := cmd.Start(); err != nil { + t.Fatalf("start executable: %v", err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + processDone := false + t.Cleanup(func() { + if processDone { + return + } + select { + case <-done: + default: + _ = cmd.Process.Kill() + <-done + } + }) + select { + case err := <-done: + processDone = true + t.Fatalf("executable exited before client test: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) + case <-time.After(4 * time.Second): + case <-ctx.Done(): + t.Fatalf("wait executable startup: %v\nstdout:\n%s\nstderr:\n%s", ctx.Err(), stdout.String(), stderr.String()) + } + + rsaKey, err := mtprotoedge.LoadOrGenerateRSAKey(filepath.Join(repoRoot, "data", "server_rsa.pem")) + if err != nil { + t.Fatalf("load rsa key: %v", err) + } + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split addr: %v", err) + } + var port int + if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil { + t.Fatalf("parse port %q: %v", portStr, err) + } + + newClient := func(storage *session.StorageMemory) *telegram.Client { + opts := telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: noSecretObfuscatedResolver{addr: addr, dc: 2}, + DCList: dcs.List{Options: []tg.DCOption{{ID: 2, IPAddress: host, Port: port, Static: true}}}, + Logger: zaptest.NewLogger(t).Named("exe-client"), + SessionStorage: storage, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + return telegram.NewClient(1, "hash", opts) + } + messagesOf := func(history tg.MessagesMessagesClass) []tg.MessageClass { + t.Helper() + switch v := history.(type) { + case *tg.MessagesMessages: + return v.Messages + case *tg.MessagesMessagesSlice: + return v.Messages + default: + t.Fatalf("history = %T %+v, want messages", history, history) + return nil + } + } + signUp := func(storage *session.StorageMemory, phone, firstName string) tg.User { + t.Helper() + client := newClient(storage) + var out tg.User + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{ + PhoneNumber: phone, + APIID: 1, + APIHash: "hash", + Settings: tg.CodeSettings{}, + }) + if err != nil { + return err + } + hash := sent.(*tg.AuthSentCode).PhoneCodeHash + signInRes, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + PhoneCode: "12345", + }) + if err != nil { + return err + } + if authz, ok := signInRes.(*tg.AuthAuthorization); ok { + out = *(authz.User.(*tg.User)) + return nil + } + res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + FirstName: firstName, + }) + if err != nil { + return err + } + authz := res.(*tg.AuthAuthorization) + out = *(authz.User.(*tg.User)) + return nil + }); err != nil { + t.Fatalf("sign up %s: %v\nstdout:\n%s\nstderr:\n%s", firstName, err, stdout.String(), stderr.String()) + } + return out + } + sendAndAssertHistory := func(storage *session.StorageMemory, to tg.User, body string, randomID int64, wantOut bool) { + t.Helper() + client := newClient(storage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash}, + Message: body, + RandomID: randomID, + }); err != nil { + return err + } + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash}, + Limit: 10, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("history = %T %+v, want messages", history, history) + } + msg, ok := msgs[0].(*tg.Message) + if !ok || msg.Message != body || msg.Out != wantOut { + t.Fatalf("latest message = %#v, want out=%v text=%q", msgs[0], wantOut, body) + } + return nil + }); err != nil { + t.Fatalf("send %q: %v\nstdout:\n%s\nstderr:\n%s", body, err, stdout.String(), stderr.String()) + } + } + readLatest := func(storage *session.StorageMemory, from tg.User, body string, wantOut bool) { + t.Helper() + client := newClient(storage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: from.ID, AccessHash: from.AccessHash}, + Limit: 10, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("history = %T %+v, want messages", history, history) + } + msg, ok := msgs[0].(*tg.Message) + if !ok || msg.Message != body || msg.Out != wantOut { + t.Fatalf("latest message = %#v, want out=%v text=%q", msgs[0], wantOut, body) + } + return nil + }); err != nil { + t.Fatalf("read latest %q: %v\nstdout:\n%s\nstderr:\n%s", body, err, stdout.String(), stderr.String()) + } + } + + suffix := time.Now().UnixNano() % 100000000 + storageA := &session.StorageMemory{} + storageB := &session.StorageMemory{} + userA := signUp(storageA, fmt.Sprintf("+1555%08d", suffix), "ExeAlice") + userB := signUp(storageB, fmt.Sprintf("+1556%08d", suffix), "ExeBob") + + sendAndAssertHistory(storageA, userB, "exe hello bob", time.Now().UnixNano(), true) + readLatest(storageB, userA, "exe hello bob", false) + sendAndAssertHistory(storageB, userA, "exe hi alice", time.Now().UnixNano()+1, true) + readLatest(storageA, userB, "exe hi alice", false) +} + +type noSecretObfuscatedResolver struct { + addr string + dc int +} + +func (r noSecretObfuscatedResolver) Primary(ctx context.Context, _ int, _ dcs.List) (transport.Conn, error) { + return r.connect(ctx) +} + +func (r noSecretObfuscatedResolver) MediaOnly(ctx context.Context, _ int, _ dcs.List) (transport.Conn, error) { + return r.connect(ctx) +} + +func (r noSecretObfuscatedResolver) CDN(ctx context.Context, _ int, _ dcs.List) (transport.Conn, error) { + return r.connect(ctx) +} + +func (r noSecretObfuscatedResolver) connect(ctx context.Context) (_ transport.Conn, rerr error) { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", r.addr) + if err != nil { + return nil, err + } + defer func() { + if rerr != nil { + _ = conn.Close() + } + }() + + obfsConn := obfuscator.Obfuscated2(tdcrypto.DefaultRand(), conn) + if err := obfsConn.Handshake(codec.IntermediateClientStart, r.dc, mtproxy.Secret{}); err != nil { + return nil, err + } + proto := transport.NewProtocol(func() transport.Codec { + return codec.NoHeader{Codec: codec.Intermediate{}} + }) + return proto.Handshake(obfsConn) +} diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go new file mode 100644 index 00000000..c28e9651 --- /dev/null +++ b/cmd/telesrv/main.go @@ -0,0 +1,212 @@ +// Command telesrv 是基于 gotd/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。 +package main + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "strconv" + "syscall" + + "go.uber.org/zap" + + "github.com/gotd/td/clock" + "github.com/gotd/td/exchange" + "github.com/gotd/td/tg" + + "telesrv/internal/app/account" + "telesrv/internal/app/auth" + channelapp "telesrv/internal/app/channels" + "telesrv/internal/app/contacts" + "telesrv/internal/app/dialogs" + filesapp "telesrv/internal/app/files" + "telesrv/internal/app/help" + "telesrv/internal/app/langpack" + "telesrv/internal/app/maintenance" + messageapp "telesrv/internal/app/messages" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/config" + "telesrv/internal/mtprotoedge" + "telesrv/internal/rpc" + "telesrv/internal/store/postgres" + "telesrv/internal/store/redisstore" +) + +func main() { + logger, err := zap.NewDevelopment() + if err != nil { + fmt.Fprintln(os.Stderr, "init logger:", err) + os.Exit(1) + } + defer func() { _ = logger.Sync() }() + + if err := run(logger); err != nil { + logger.Error("telesrv 退出", zap.Error(err)) + os.Exit(1) + } +} + +func run(logger *zap.Logger) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + rsaKey, err := mtprotoedge.LoadOrGenerateRSAKey(cfg.RSAKeyPath) + if err != nil { + return fmt.Errorf("server rsa key: %w", err) + } + fingerprint := exchange.PrivateKey{RSA: rsaKey}.Fingerprint() + + _, portStr, err := net.SplitHostPort(cfg.ListenAddr) + if err != nil { + return fmt.Errorf("parse listen addr %q: %w", cfg.ListenAddr, err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("parse listen port %q: %w", portStr, err) + } + + // tg.Layer 来自 gotd/td v0.144.0(应为 225),需与目标 TDesktop 基线对齐。 + logger.Info("telesrv 启动", + zap.String("listen", cfg.ListenAddr), + zap.Int("dc", cfg.DC), + zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)), + zap.Int("tl_layer", tg.Layer), + zap.String("rsa_key", cfg.RSAKeyPath), + zap.Int64("rsa_fingerprint", fingerprint), + ) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // 持久化依赖:先迁移 schema,再建立连接。auth_key 落 PostgreSQL、session 落 Redis。 + // 依赖由 deploy/docker-compose.yml 启动;连不上则启动失败(开发期须先 docker compose up)。 + if err := postgres.Migrate(cfg.PostgresDSN); err != nil { + return fmt.Errorf("postgres migrate: %w", err) + } + pool, err := postgres.Open(ctx, cfg.PostgresDSN, + postgres.WithMaxConns(cfg.PostgresMaxConns), + postgres.WithMinConns(cfg.PostgresMinConns), + ) + if err != nil { + return fmt.Errorf("connect postgres: %w", err) + } + defer pool.Close() + + rdb, err := redisstore.Open(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB) + if err != nil { + return fmt.Errorf("connect redis: %w", err) + } + defer func() { _ = rdb.Close() }() + logger.Info("持久化依赖就绪", zap.String("redis", cfg.RedisAddr)) + + ln, err := net.Listen("tcp", cfg.ListenAddr) + if err != nil { + return fmt.Errorf("listen %q: %w", cfg.ListenAddr, err) + } + + authKeyStore := postgres.NewAuthKeyStore(pool) + userStore := postgres.NewUserStore(pool) + authzStore := postgres.NewAuthorizationStore(pool) + updateStateStore := postgres.NewUpdateStateStore(pool) + updateEventStore := postgres.NewUpdateEventStore(pool) + dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout)) + ptsAllocator := redisstore.NewPtsAllocator(rdb, updateEventStore) + boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) + channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool)) + channelPtsAllocator := redisstore.NewChannelPtsAllocator(rdb, postgres.NewChannelPtsCounterSource(pool)) + channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool)) + contactStore := postgres.NewContactStore(pool) + dialogStore := postgres.NewDialogStore(pool) + messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator)) + channelStore := postgres.NewChannelStore(pool, postgres.WithChannelAllocators(channelIDAllocator, channelPtsAllocator, channelMessageIDAllocator)) + mediaStore := postgres.NewMediaStore(pool) + blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir) + if err != nil { + return fmt.Errorf("init blob backend: %w", err) + } + filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC) + if stats, err := filesService.SeedMedia(ctx, cfg.StickerSeedDir, cfg.StickerSeedMaxSets); err != nil { + return fmt.Errorf("seed media: %w", err) + } else if !stats.Skipped { + logger.Info("媒体种子导入完成", + zap.String("dir", cfg.StickerSeedDir), + zap.Int("reactions", stats.Reactions), + zap.Int("sticker_sets", stats.StickerSets), + zap.Int("documents", stats.Documents), + zap.Int("blobs", stats.Blobs), + ) + } + if stats, err := filesService.WarmCaches(ctx); err != nil { + logger.Warn("媒体资源缓存预热失败", zap.Error(err)) + } else if stats.StickerSets > 0 || stats.Documents > 0 || stats.Blobs > 0 { + logger.Info("媒体资源缓存预热完成", + zap.Int("sticker_sets", stats.StickerSets), + zap.Int("documents", stats.Documents), + zap.Int("blobs", stats.Blobs), + ) + } + langPackStore := postgres.NewLangPackStore(pool) + passwordStore := postgres.NewPasswordStore(pool) + helpStore := postgres.NewHelpStore(pool) + tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool) + sessionStore := redisstore.NewSessionStore(rdb, redisstore.DefaultSessionTTL) + codeStore := redisstore.NewCodeStore(rdb) + rateLimiter := redisstore.NewRateLimiter(rdb) + activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions")) + go maintenance.NewRetentionWorker(dispatchOutboxStore, logger.Named("maintenance").Named("retention"), + cfg.UpdateEventRetention, + cfg.RetentionInterval, + cfg.RetentionBatch, + ).Run(ctx) + go rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, activeSessions, logger.Named("rpc").Named("outbox"), + rpc.WithOutboxWorkers(cfg.OutboxWorkers), + rpc.WithOutboxBatch(cfg.OutboxBatch), + rpc.WithOutboxInterval(cfg.OutboxInterval), + rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout), + ).Run(ctx) + langPackService := langpack.NewService(langPackStore) + if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil { + return fmt.Errorf("seed langpack: %w", err) + } else if seeded > 0 { + logger.Info("语言包种子导入完成", zap.String("dir", cfg.LangPackSeedDir), zap.Int("strings", seeded)) + } + + router := rpc.New(rpc.Config{ + DC: cfg.DC, + IP: cfg.AdvertiseIP, + Port: port, + OutboundPushTimeout: cfg.OutboundPushTimeout, + }, rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore)), + Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore, users.WithPhotoProvider(mediaStore)), + Updates: updates.NewService(updateStateStore, updateEventStore, updates.WithPtsAllocator(ptsAllocator)), + Contacts: contacts.NewService(contactStore, userStore), + Dialogs: dialogs.NewService(dialogStore, channelStore), + Messages: messageapp.NewService(messageStore, dialogStore), + Channels: channelapp.NewService(channelStore), + Files: filesService, + LangPack: langPackService, + Sessions: activeSessions, + Limiter: rateLimiter, + }, logger.Named("rpc"), clock.System) + activeSessions.SetLifecycleObserver(router) + + srv := mtprotoedge.New(mtprotoedge.Options{ + Logger: logger.Named("mtprotoedge"), + DC: cfg.DC, + RSAKey: rsaKey, + RPC: router, + AuthKeys: authKeyStore, + Sessions: sessionStore, + ActiveSessions: activeSessions, + ObfuscatedTCP: true, + }) + return srv.Serve(ctx, ln) +} diff --git a/data/langpack/tdesktop/tdesktop_en_v12000000.strings b/data/langpack/tdesktop/tdesktop_en_v12000000.strings new file mode 100644 index 00000000..5a919815 --- /dev/null +++ b/data/langpack/tdesktop/tdesktop_en_v12000000.strings @@ -0,0 +1,9104 @@ +/* +This file is part of Tidings Desktop, +the official desktop application for the Tidings messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +"lng_language_name" = "English"; +"lng_switch_to_this" = "Continue in English"; + +"lng_menu_contacts" = "Contacts"; +"lng_menu_calls" = "Calls"; +"lng_menu_settings" = "Settings"; +"lng_menu_about" = "About"; +"lng_menu_update" = "Update"; +"lng_menu_night_mode" = "Night Mode"; +"lng_menu_add_account" = "Add Account"; +"lng_menu_activate" = "Switch to this account"; +"lng_menu_set_status" = "Set Emoji Status"; +"lng_menu_change_status" = "Change Emoji Status"; +"lng_menu_my_profile" = "My Profile"; +"lng_menu_my_stories" = "My Stories"; +"lng_menu_my_groups" = "My Groups"; +"lng_menu_my_channels" = "My Channels"; + +"lng_disable_notifications_from_tray" = "Disable notifications"; +"lng_enable_notifications_from_tray" = "Enable notifications"; +"lng_open_from_tray" = "Open Tidings"; +"lng_minimize_to_tray" = "Minimize to Tray"; +"lng_quit_from_tray" = "Quit Tidings"; +"lng_tray_icon_text" = "Tidings is still running here,\nyou can change this in Settings.\nIf it disappears from the tray,\nyou can drag it back from the hidden icons."; + +// For lng_month_year or plain month name. +"lng_month1" = "January"; +"lng_month2" = "February"; +"lng_month3" = "March"; +"lng_month4" = "April"; +"lng_month5" = "May"; +"lng_month6" = "June"; +"lng_month7" = "July"; +"lng_month8" = "August"; +"lng_month9" = "September"; +"lng_month10" = "October"; +"lng_month11" = "November"; +"lng_month12" = "December"; + +// For lng_month_day and lng_month_day_year. +"lng_month_day1" = "January"; +"lng_month_day2" = "February"; +"lng_month_day3" = "March"; +"lng_month_day4" = "April"; +"lng_month_day5" = "May"; +"lng_month_day6" = "June"; +"lng_month_day7" = "July"; +"lng_month_day8" = "August"; +"lng_month_day9" = "September"; +"lng_month_day10" = "October"; +"lng_month_day11" = "November"; +"lng_month_day12" = "December"; + +"lng_month1_small" = "Jan"; +"lng_month2_small" = "Feb"; +"lng_month3_small" = "Mar"; +"lng_month4_small" = "Apr"; +"lng_month5_small" = "May"; +"lng_month6_small" = "Jun"; +"lng_month7_small" = "Jul"; +"lng_month8_small" = "Aug"; +"lng_month9_small" = "Sep"; +"lng_month10_small" = "Oct"; +"lng_month11_small" = "Nov"; +"lng_month12_small" = "Dec"; + +"lng_weekday1" = "Mon"; +"lng_weekday2" = "Tue"; +"lng_weekday3" = "Wed"; +"lng_weekday4" = "Thu"; +"lng_weekday5" = "Fri"; +"lng_weekday6" = "Sat"; +"lng_weekday7" = "Sun"; + +"lng_month_day" = "{month} {day}"; +"lng_month_day_year" = "{month} {day}, {year}"; +"lng_month_year" = "{month} {year}"; + +"lng_calendar_select_days" = "Select days"; +"lng_calendar_start_tip" = "Press and hold to jump to the start."; +"lng_calendar_end_tip" = "Press and hold to jump to the end."; +"lng_calendar_days#one" = "{count} day"; +"lng_calendar_days#other" = "{count} days"; + +"lng_seconds#one" = "{count} second"; +"lng_seconds#other" = "{count} seconds"; +"lng_minutes#one" = "{count} minute"; +"lng_minutes#other" = "{count} minutes"; +"lng_hours#one" = "{count} hour"; +"lng_hours#other" = "{count} hours"; +"lng_days#one" = "{count} day"; +"lng_days#other" = "{count} days"; +"lng_weeks#one" = "{count} week"; +"lng_weeks#other" = "{count} weeks"; +"lng_months#one" = "{count} month"; +"lng_months#other" = "{count} months"; +"lng_years#one" = "{count} year"; +"lng_years#other" = "{count} years"; + +"lng_seconds_tiny#one" = "{count}s"; +"lng_seconds_tiny#other" = "{count}s"; +"lng_minutes_tiny#one" = "{count}m"; +"lng_minutes_tiny#other" = "{count}m"; +"lng_hours_tiny#one" = "{count}h"; +"lng_hours_tiny#other" = "{count}h"; +"lng_days_tiny#one" = "{count}d"; +"lng_days_tiny#other" = "{count}d"; +"lng_weeks_tiny#one" = "{count}w"; +"lng_weeks_tiny#other" = "{count}w"; +"lng_months_tiny#one" = "{count}m"; +"lng_months_tiny#other" = "{count}m"; +"lng_years_tiny#one" = "{count}y"; +"lng_years_tiny#other" = "{count}y"; + +"lng_box_ok" = "OK"; +"lng_box_done" = "Done"; +"lng_box_yes" = "Yes"; +"lng_box_no" = "No"; + +"lng_cancel" = "Cancel"; +"lng_continue" = "Continue"; +"lng_close" = "Close"; +"lng_connecting" = "Connecting..."; +"lng_reconnecting#one" = "Reconnect in {count} s..."; +"lng_reconnecting#other" = "Reconnect in {count} s..."; +"lng_reconnecting_try_now" = "Try now"; + +"lng_code_block_header_copy" = "copy"; + +"lng_status_service_notifications" = "service notifications"; +"lng_status_support" = "support"; +"lng_status_bot" = "bot"; +"lng_status_bot_reads_all" = "has access to messages"; +"lng_status_bot_not_reads_all" = "has no access to messages"; +"lng_status_offline" = "last seen a long time ago"; +"lng_status_recently" = "last seen recently"; +"lng_status_last_week" = "last seen within a week"; +"lng_status_last_month" = "last seen within a month"; +"lng_status_lastseen_now" = "last seen just now"; +"lng_status_lastseen_when" = "when?"; +"lng_status_lastseen_minutes#one" = "last seen {count} minute ago"; +"lng_status_lastseen_minutes#other" = "last seen {count} minutes ago"; +"lng_status_lastseen_hours#one" = "last seen {count} hour ago"; +"lng_status_lastseen_hours#other" = "last seen {count} hours ago"; +"lng_status_lastseen_today" = "last seen today at {time}"; +"lng_status_lastseen_yesterday" = "last seen yesterday at {time}"; +"lng_status_lastseen_date" = "last seen {date}"; +"lng_status_lastseen_date_time" = "last seen {date} at {time}"; +"lng_status_online" = "online"; +"lng_status_connecting" = "connecting..."; + +"lng_chat_status_unaccessible" = "group is inaccessible"; +"lng_chat_status_members#one" = "{count} member"; +"lng_chat_status_members#other" = "{count} members"; +"lng_chat_status_online#one" = "{count} online"; +"lng_chat_status_online#other" = "{count} online"; +"lng_chat_status_members_online" = "{members_count}, {online_count}"; +"lng_chat_status_subscribers#one" = "{count} subscriber"; +"lng_chat_status_subscribers#other" = "{count} subscribers"; + +"lng_channel_status" = "channel"; +"lng_group_status" = "group"; +"lng_scam_badge" = "SCAM"; +"lng_fake_badge" = "FAKE"; + +"lng_remember" = "Remember this choice"; + +"lng_lastseen_show_title" = "Show Your Last Seen"; +"lng_lastseen_show_about" = "To see **{user}'s** Last Seen time, either start\nshowing your own Last Seen time..."; +"lng_lastseen_show_button" = "Show My Last Seen"; +"lng_lastseen_or" = "or"; +"lng_lastseen_premium_title" = "Upgrade to Premium"; +"lng_lastseen_premium_about" = "Subscribing will let you see **{user}'s** Last Seen\nstatus without showing yours."; +"lng_lastseen_premium_button" = "Subscribe to Tidings Premium"; +"lng_lastseen_shown_toast" = "Your last seen time is now visible."; + +"lng_readtime_show_title" = "Show Your Read Date"; +"lng_readtime_show_about" = "To see when **{user}** read the message,\neither start showing your own read time..."; +"lng_readtime_show_button" = "Show My Read Time"; +"lng_readtime_or" = "or"; +"lng_readtime_premium_title" = "Upgrade to Premium"; +"lng_readtime_premium_about" = "Subscription will let you see **{user}'s** read time\nwithout showing yours."; +"lng_readtime_premium_button" = "Subscribe to Tidings Premium"; +"lng_readtime_shown_toast" = "Your read times are now visible."; + +"lng_channels_limit_title" = "Too Many Communities"; +"lng_channels_limit1#one" = "You are a member of **{count}** group or channel."; +"lng_channels_limit1#other" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit2#one" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** group or channel."; +"lng_channels_limit2#other" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_limit2_final" = "Please leave some before joining a new one."; +"lng_channels_leave_title" = "Least active communities"; +"lng_channels_leave_status" = "{type}, inactive {time}"; +"lng_channels_leave#one" = "Leave {count} community"; +"lng_channels_leave#other" = "Leave {count} communities"; +"lng_channels_leave_done" = "You've left the selected communities."; + +"lng_links_limit_title" = "Too Many Public Links"; +"lng_links_limit1#one" = "You have reserved **{count}** public link."; +"lng_links_limit1#other" = "You have reserved **{count}** public links."; +"lng_links_limit2#one" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public link."; +"lng_links_limit2#other" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_links_limit2_final" = "Try revoking the link from an older group or channel"; +"lng_links_revoke_title" = "Your public communities"; + +"lng_filter_chats_limit_title" = "Limit Reached"; +"lng_filter_chats_limit1#one" = "Sorry, you can't add more than **{count}** chat to a folder."; +"lng_filter_chats_limit1#other" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_exlude_limit1#one" = "Sorry, you can't exclude more than **{count}** chat from a folder."; +"lng_filter_chats_exlude_limit1#other" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_limit2#one" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_chats_limit2#other" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; + +"lng_filters_limit_title" = "Limit Reached"; +"lng_filters_limit1#one" = "You have reached the limit of **{count}** folder."; +"lng_filters_limit1#other" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit2#one" = "You can double the limit to **{count}** folder by subscribing to **Tidings Premium**."; +"lng_filters_limit2#other" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; + +"lng_filter_pin_limit_title" = "Limit Reached"; +"lng_filter_pin_limit1#one" = "Sorry, you can't pin more than **{count}** chat to the top."; +"lng_filter_pin_limit1#other" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit2#one" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chat."; +"lng_filter_pin_limit2#other" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; + +"lng_forum_pin_limit#one" = "Sorry, you can't pin more than **{count}** topic to the top."; +"lng_forum_pin_limit#other" = "Sorry, you can't pin more than **{count}** topics to the top."; + +"lng_fave_sticker_limit_title#one" = "Limit of {count} Sticker Reached"; +"lng_fave_sticker_limit_title#other" = "Limit of {count} Stickers Reached"; +"lng_fave_sticker_limit_more#one" = "An older sticker was replaced with this one.\nYou can {link} to {count} sticker."; +"lng_fave_sticker_limit_more#other" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_link" = "increase the limit"; + +"lng_saved_gif_limit_title#one" = "Limit of {count} GIF Reached"; +"lng_saved_gif_limit_title#other" = "Limit of {count} GIFs Reached"; +"lng_saved_gif_limit_more#one" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIF."; +"lng_saved_gif_limit_more#other" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_link" = "increase the limit"; + +"lng_caption_limit_title" = "Limit Reached"; +"lng_caption_limit1#one" = "Sorry, you can't use more than **{count}** character in media captions."; +"lng_caption_limit1#other" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit2#one" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** character."; +"lng_caption_limit2#other" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit_reached#one" = "You've reached the media caption limit. Please make the caption shorter by {count} character."; +"lng_caption_limit_reached#other" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_caption_move_up" = "Move Caption Up"; +"lng_caption_move_down" = "Move Caption Down"; + +"lng_file_size_limit_title" = "File Too Large"; +"lng_file_size_limit#one" = "{count} GB"; +"lng_file_size_limit#other" = "{count} GB"; +"lng_file_size_limit1" = "The document can't be sent, because it is larger than {size}."; +"lng_file_size_limit2" = "You can double this limit to {size} per document by subscribing to **Tidings Premium**."; + +"lng_filter_links_limit_title" = "Limit Reached"; +"lng_filter_links_limit1#one" = "Sorry, you can't create more than **{count}** invite link."; +"lng_filter_links_limit1#other" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit2#one" = "You can increase the limit to **{count}** link by subscribing to **Tidings Premium**."; +"lng_filter_links_limit2#other" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; + +"lng_filter_shared_limit_title" = "Limit Reached"; +"lng_filter_shared_limit1#one" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit1#other" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit2#one" = "You can increase the limit to **{count}** folder by subscribing to **Tidings Premium**."; +"lng_filter_shared_limit2#other" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; + +"lng_limits_increase" = "Increase Limit"; + +"lng_sticker_premium_text" = "This set contains premium stickers like this one."; +"lng_sticker_premium_view" = "View"; +"lng_animated_emoji_text" = "Subscribe to **Tidings Premium** to unlock this emoji."; +"lng_animated_emoji_saved" = "Try sending these emoji in **Saved Messages** for free to test."; +"lng_animated_emoji_saved_open" = "Open"; + +"lng_flood_error" = "Too many tries. Please try again later."; +"lng_edit_error" = "You can't edit this message."; +"lng_error_phone_flood" = "Sorry, you have deleted and re-created your account too many times recently. Please wait for a few days before signing up again."; +"lng_error_start_minimized_passcoded" = "You have set a local passcode, so Tidings Desktop can't be launched minimized; it will ask you to enter your passcode before it can start working."; +"lng_error_public_groups_denied" = "Unfortunately, you are banned from participating in public groups.\n{more_info}"; +"lng_error_cant_add_member" = "Sorry, you can't add the bot to this group. Ask a group admin to do it."; +"lng_error_cant_add_bot" = "Sorry, this bot can't be added to groups."; +"lng_error_cant_add_admin_invite" = "You can't add this user as an admin because they are not a member of this group and you are not allowed to add them."; +"lng_error_you_blocked_user" = "Sorry, you can't add this user or bot to groups because you've blocked them. Please unblock to proceed."; +"lng_error_add_admin_not_member" = "You can't add this user as an admin because they are not a member of this group and you are not allowed to add them."; +"lng_error_user_admin_invalid" = "You can't ban this user because they are an admin in this group and you are not allowed to demote them."; +"lng_error_channel_bots_too_much" = "Sorry, this channel has too many bots."; +"lng_error_group_bots_too_much" = "There are too many bots in this group. Please remove some of the bots you're not using first."; +"lng_error_cant_add_admin_unban" = "Sorry, you can't add this user as an admin because they are in the Removed Users list and you can't unban them."; +"lng_error_cant_ban_admin" = "You can't ban this user because they are an admin in this group and you are not allowed to demote them."; +"lng_error_cant_reply_other" = "This message can't be replied in another chat."; +"lng_error_admin_limit" = "Sorry, you've reached the maximum number of admins for this group."; +"lng_error_admin_limit_channel" = "Sorry, you've reached the maximum number of admins for this channel."; +"lng_error_post_link_invalid" = "Unfortunately, you can't access this message. You aren't a member of the chat where it was posted."; +"lng_error_noforwards_group" = "Sorry, forwarding from this group is disabled by admins."; +"lng_error_noforwards_channel" = "Sorry, forwarding from this channel is disabled by admins."; +"lng_error_nocopy_group" = "Sorry, copying from this group is disabled by admins."; +"lng_error_nocopy_channel" = "Sorry, copying from this channel is disabled by admins."; +"lng_error_nocopy_story" = "Sorry, the creator of this story disabled copying."; +"lng_error_schedule_limit" = "Sorry, you can't schedule more than 100 messages."; +"lng_sure_add_admin_invite" = "This user is not a member of this group. Add them to the group and promote them to admin?"; +"lng_sure_add_admin_invite_channel" = "This user is not a subscriber of this channel. Add them to the channel and promote them to admin?"; +"lng_sure_add_admin_unremove" = "This user is currently restricted or removed. Are you sure you want to promote them?"; +"lng_sure_ban_admin" = "This user is an admin. Are you sure you want to go ahead and restrict them?"; +"lng_sure_enable_socks" = "Are you sure you want to enable this proxy?\n\nServer: {server}\nPort: {port}\n\nYou can change your proxy server later in Settings > Advanced > Connection Type."; +"lng_sure_enable" = "Enable"; +"lng_proxy_box_title" = "Enable proxy"; +"lng_proxy_box_server" = "Server"; +"lng_proxy_box_port" = "Port"; +"lng_proxy_box_secret" = "Secret"; +"lng_proxy_box_status" = "Status"; +"lng_proxy_box_username" = "Username"; +"lng_proxy_box_password" = "Password"; +"lng_proxy_invalid" = "The proxy link is invalid."; +"lng_proxy_unsupported" = "Your Tidings Desktop version doesn't support this proxy type or the proxy link is invalid. Please update Tidings Desktop to the latest version."; + +"lng_edit_deleted" = "This message was deleted"; +"lng_edit_limit_reached#one" = "You've reached the message text limit. Please make the text shorter by {count} character."; +"lng_edit_limit_reached#other" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_message" = "Edit message"; +"lng_edit_message_text" = "New message..."; +"lng_deleted" = "Deleted Account"; +"lng_deleted_message" = "Deleted message"; +"lng_deleted_story" = "Deleted story"; +"lng_pinned_message" = "Pinned message"; +"lng_pinned_previous" = "Previous message"; +"lng_pinned_unpin_sure" = "Would you like to unpin this message?"; +"lng_pinned_pin_sure" = "Would you like to pin this message?"; +"lng_pinned_pin_sure_group" = "Pin this message in the group?"; +"lng_pinned_pin_old_sure" = "Do you want to pin an older message while leaving a more recent one pinned?"; +"lng_pinned_pin" = "Pin"; +"lng_pinned_unpin" = "Unpin"; +"lng_pinned_notify" = "Notify all members"; +"lng_pinned_also_for_other" = "Also pin for {user}"; +"lng_pinned_messages_title#one" = "{count} pinned message"; +"lng_pinned_messages_title#other" = "{count} pinned messages"; +"lng_pinned_hide_all" = "Don't show pinned messages"; +"lng_pinned_unpin_all#one" = "Unpin {count} message"; +"lng_pinned_unpin_all#other" = "Unpin all {count} messages"; +"lng_pinned_unpin_all_sure" = "Do you want to unpin all messages?"; +"lng_pinned_hide_all_sure" = "Do you want to hide the pinned message bar? It will stay hidden until a new message is pinned."; +"lng_pinned_hide_all_hide" = "Hide"; + +"lng_edit_media_album_error" = "This file cannot be saved as a part of an album."; +"lng_edit_media_invalid_file" = "Sorry, no way to use this file."; +"lng_edit_photo_editor_hint" = "Left-click on the photo to edit."; +"lng_edit_caption_attach" = "Sorry, you can't attach new media while editing a message."; +"lng_edit_caption_voice" = "Sorry, you can't edit messages when you have an unsent voice message."; + +"lng_intro_about" = "Welcome to the official Tidings Desktop app.\nIt's fast and secure."; +"lng_start_msgs" = "Start Messaging"; + +"lng_intro_next" = "Next"; +"lng_intro_finish" = "Sign Up"; +"lng_intro_submit" = "Submit"; + +"lng_photo_caption" = "Caption"; +"lng_photos_comment" = "Comment"; + +"lng_intro_qr_title" = "Scan From Mobile Tidings"; +"lng_intro_qr_step1" = "Open Tidings on your phone"; +"lng_intro_qr_step2" = "Go to Settings > Devices > Link Desktop Device"; +"lng_intro_qr_step3" = "Scan this image to Log In"; +"lng_intro_qr_skip" = "Or log in using your phone number"; + +"lng_intro_fragment_title" = "Enter code"; +"lng_intro_fragment_about" = "Get the code for {phone_number} in the Anonymous Numbers section on Fragment."; +"lng_intro_fragment_button" = "Open Fragment"; + +"lng_phone_title" = "Your Phone Number"; +"lng_phone_desc" = "Please confirm your country code\nand enter your phone number."; +"lng_phone_to_qr" = "Quick log in using QR code"; +"lng_country_code" = "Country Code"; +"lng_bad_country_code" = "Invalid Country Code"; +"lng_country_ph" = "Search"; +"lng_country_none" = "Country not found"; +"lng_country_select" = "Select Country"; + +"lng_code_ph" = "Code"; +"lng_settings_chat_quick_action_select" = "Double click to select all"; +"lng_code_desc" = "We've sent an activation code to your phone.\nPlease enter it below."; +"lng_code_from_telegram" = "A code was sent **via Tidings** to your other\ndevices, if you have any connected."; +"lng_code_no_telegram" = "Send code via SMS"; +"lng_code_call" = "Tidings will call you in {minutes}:{seconds}"; +"lng_code_calling" = "Requesting a call from Tidings..."; +"lng_code_called" = "Tidings dialed your number"; + +"lng_bad_phone" = "Invalid phone number. Please try again."; +"lng_bad_code" = "You have entered an invalid code."; +"lng_bad_name" = "Enter your first and last name."; +"lng_bad_photo" = "Sorry, Tidings can't process that type of image."; + +"lng_signin_title" = "Cloud password check"; +"lng_signin_desc" = "Please enter your cloud password."; +"lng_signin_recover_desc" = "Please enter the code from the email\n{email}"; +"lng_signin_password" = "Your cloud password"; +"lng_signin_code" = "Code from the email"; +"lng_signin_recover" = "Forgot password?"; +"lng_signin_recover_title" = "Password reset"; +"lng_signin_hint" = "Hint: {password_hint}"; +"lng_signin_recover_hint" = "We sent a code to {recover_email}"; +"lng_signin_bad_password" = "You have entered a wrong password."; +"lng_signin_wrong_code" = "You have entered an invalid code."; +"lng_signin_try_password" = "Unable to access your email?"; +"lng_signin_no_email_forgot" = "Since you didn't provide a recovery email when setting up your password, your remaining options are either to remember your password or to reset your account."; +"lng_signin_cant_email_forgot" = "If you can't restore access to the email, your remaining options are either to remember your password or to reset your account."; +"lng_signin_reset_account" = "Reset your account"; +"lng_signin_sure_reset" = "You will lose all your Tidings chats, messages, media and files if you proceed.\n\nDo you want to reset your account?"; +"lng_signin_reset" = "Reset"; +"lng_signin_reset_wait" = "Since the account {phone_number} is active and protected by a password, it will be deleted in 1 week. This delay is required for security purposes. You can cancel this process anytime.\n\nYou'll be able to reset your account in:\n{when}"; +"lng_signin_reset_in_days" = "{days_count} {hours_count} {minutes_count}"; +"lng_signin_reset_in_hours" = "{hours_count} {minutes_count}"; +"lng_signin_reset_cancelled" = "Your recent attempts to reset this account have been canceled by its active user. Please try again in 7 days."; +"lng_signin_banned_text" = "This phone number is banned."; +"lng_signin_banned_help" = "Help"; + +"lng_signup_title" = "Your Info"; +"lng_signup_desc" = "Please enter your name and\nupload a photo."; + +"lng_signup_firstname" = "First name"; +"lng_signup_lastname" = "Last name"; + +"lng_dlg_filter" = "Search"; +"lng_dlg_new_group_name" = "Group name"; +"lng_dlg_new_channel_name" = "Channel name"; +"lng_dlg_new_bot_name" = "Bot name"; +"lng_no_chats" = "Your chats will be here"; +"lng_no_chats_filter" = "No chats currently belong to this folder."; +"lng_no_saved_sublists" = "You can save messages from other chats here."; +"lng_contacts_loading" = "Loading..."; +"lng_contacts_not_found" = "No contacts found"; +"lng_topics_not_found" = "No topics found."; +"lng_dlg_search_for_messages" = "Search for messages"; +"lng_update_telegram" = "Update Tidings"; +"lng_dlg_search_in" = "Search messages in"; +"lng_dlg_search_from" = "From: {user}"; + +"lng_settings_save" = "Save"; +"lng_settings_apply" = "Apply"; + +"lng_username_title" = "Username"; +"lng_username_description1" = "You can choose a username on Tidings. If you do, other people will be able to find you by this username and contact you without knowing your phone number."; +"lng_username_description2" = "You can use **a-z**, **0-9** and **underscores**.\nMinimum length is **5 characters**."; +"lng_username_choose" = "Choose your username."; +"lng_username_invalid" = "This username is invalid."; +"lng_username_occupied" = "This username is already occupied."; +"lng_username_too_short" = "This username is too short."; +"lng_username_purchase_available" = "Sorry, this link is taken. But it's available for purchase. {link}"; +"lng_username_purchase_available_link" = "Learn more..."; +"lng_username_bad_symbols" = "Only a-z, 0-9, and underscores allowed."; +"lng_username_available" = "This username is available."; +"lng_username_not_found" = "Username @{user} not found."; +"lng_username_by_phone_not_found" = "User {phone} not found."; +"lng_username_app_not_found" = "Bot application not found."; +"lng_username_link" = "This link opens a chat with you:"; +"lng_username_copied" = "Link copied to clipboard."; +"lng_username_text_copied" = "Username copied to clipboard."; + +"lng_usernames_edit" = "click to edit"; +"lng_usernames_active" = "active"; +"lng_usernames_non_active" = "inactive"; +"lng_usernames_subtitle" = "Username order"; +"lng_usernames_activate_error#one" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_error#other" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_description" = "Do you want to show this username on your info page?"; +"lng_usernames_activate_confirm" = "Show"; +"lng_channel_usernames_subtitle" = "Link order"; +"lng_usernames_deactivate_description" = "Do you want to hide this username from your info page?"; +"lng_usernames_deactivate_confirm" = "Hide"; +"lng_usernames_description" = "Drag and drop links to change the order in which they will be displayed on your info page."; + +"lng_channel_usernames_activate_description" = "Do you want to show this link on the channel info page?"; +"lng_channel_usernames_deactivate_description" = "Do you want to hide this link from the channel info page?"; +"lng_channel_usernames_description" = "Drag and drop links to change the order in which they will be displayed on the channel info page."; + +"lng_bot_username_title" = "Username"; +"lng_bot_username_description1" = "This link cannot be edited. You can acquire additional usernames on {link}."; +"lng_bot_username_description1_link" = "Fragment"; +"lng_bot_usernames_activate_description" = "Do you want to show this link on the bot info page?"; +"lng_bot_usernames_deactivate_description" = "Do you want to hide this link from the bot info page?"; +"lng_bot_usernames_description" = "Drag and drop links to change the order in which they will be displayed on the bot info page."; + +"lng_bio_placeholder" = "Bio"; + +"lng_collectible_username_title" = "{username} is a collectible username that belongs to"; +"lng_collectible_username_info" = "This username was bought on **Fragment** on {date} for {price}"; +"lng_collectible_username_copy" = "Copy Link"; +"lng_collectible_phone_title" = "{phone} is a collectible phone number that belongs to"; +"lng_collectible_phone_info" = "This phone number was bought on **Fragment** on {date} for {price}"; +"lng_collectible_phone_copy" = "Copy Phone Number"; +"lng_collectible_learn_more" = "Learn More"; +"lng_collectible_phone_copied" = "Phone number copied to clipboard."; + +"lng_settings_section_info" = "Info"; + +"lng_settings_section_notify" = "Notifications and Sounds"; +"lng_settings_show_from" = "Show notifications from"; +"lng_settings_notify_all" = "All accounts"; +"lng_settings_notify_all_about" = "Turn this off if you want to receive notifications only from the account you are currently using."; +"lng_settings_notify_global" = "Global settings"; +"lng_settings_notify_title" = "Notifications for chats"; +"lng_settings_desktop_notify" = "Desktop notifications"; +"lng_settings_native_title" = "System integration"; +"lng_settings_use_windows" = "Use Windows notifications"; +"lng_settings_skip_in_focus" = "Respect system Focus mode"; +"lng_settings_use_native_notifications" = "Use native notifications"; +"lng_settings_notifications_position" = "Location on the screen"; +"lng_settings_notifications_count" = "Notifications count"; +"lng_settings_sound_allowed" = "Allow sound"; +"lng_settings_alert_windows" = "Flash the taskbar icon"; +"lng_settings_alert_mac" = "Bounce the Dock icon"; +"lng_settings_alert_linux" = "Draw attention to the window"; +"lng_settings_badge_title" = "Badge counter"; +"lng_settings_include_muted" = "Include muted chats in unread count"; +"lng_settings_include_muted_folders" = "Include muted chats in folder counters"; +"lng_settings_count_unread" = "Count unread messages"; +"lng_settings_events_title" = "Events"; +"lng_settings_events_joined" = "Contact joined Tidings"; +"lng_settings_events_pinned" = "Pinned messages"; +"lng_settings_notifications_calls_title" = "Calls"; + +"lng_notification_preview_title" = "Dino Rex"; +"lng_notification_preview_text" = "It's morning in Tokyo 😎"; +"lng_notification_show_name" = "Name"; +"lng_notification_show_text" = "Text"; +"lng_notification_preview" = "You have a new message"; +"lng_notification_reply" = "Reply"; +"lng_notification_hide_all" = "Hide all"; +"lng_notification_sample" = "This is a sample notification"; +"lng_notification_reminder" = "Reminder"; +"lng_notification_private_chats" = "Private chats"; +"lng_notification_groups" = "Groups"; +"lng_notification_channels" = "Channels"; +"lng_notification_click_to_change" = "Click here to change"; +"lng_notification_on" = "On, {exceptions}"; +"lng_notification_off" = "Off, {exceptions}"; +"lng_notification_exceptions#one" = "{count} exception"; +"lng_notification_exceptions#other" = "{count} exceptions"; +"lng_notification_exceptions_title" = "Exceptions"; +"lng_notification_title_private_chats" = "Notifications for private chats"; +"lng_notification_about_private_chats#one" = "Please note that **{count} chat** is listed as an exception and won't be affected by this change."; +"lng_notification_about_private_chats#other" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_title_groups" = "Notifications for groups"; +"lng_notification_about_groups#one" = "Please note that **{count} group** is listed as an exception and won't be affected by this change."; +"lng_notification_about_groups#other" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_title_channels" = "Notifications for channels"; +"lng_notification_about_channels#one" = "Please note that **{count} channel** is listed as an exception and won't be affected by this change."; +"lng_notification_about_channels#other" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_exceptions_view" = "View exceptions"; +"lng_notification_enable" = "Enable notifications"; +"lng_notification_sound" = "Sound"; +"lng_notification_tone" = "Notification tone"; +"lng_notification_exceptions_muted" = "Muted"; +"lng_notification_exceptions_unmuted" = "Unmuted"; +"lng_notification_exceptions_add" = "Add an exception"; +"lng_notification_exceptions_clear" = "Delete all exceptions"; +"lng_notification_exceptions_clear_sure" = "Are you sure you want to delete all exceptions?"; +"lng_notification_exceptions_clear_button" = "Delete"; +"lng_notification_exceptions_remove" = "Remove"; +"lng_notification_context_remove" = "Remove exception"; + +"lng_reaction_text" = "{reaction} to your \"{text}\""; +"lng_reaction_notext" = "{reaction} to your message"; +"lng_reaction_photo" = "{reaction} to your photo"; +"lng_reaction_video" = "{reaction} to your video"; +"lng_reaction_video_message" = "{reaction} to your video message"; +"lng_reaction_document" = "{reaction} to your file"; +"lng_reaction_sticker" = "{reaction} to your {emoji} sticker"; +"lng_reaction_voice_message" = "{reaction} to your voice message"; +"lng_reaction_contact" = "{reaction} to your contact {name}"; +"lng_reaction_location" = "{reaction} to your map"; +"lng_reaction_poll" = "{reaction} to your poll \"{title}\""; +"lng_reaction_quiz" = "{reaction} to your quiz \"{title}\""; +"lng_reaction_game" = "{reaction} to your game"; +"lng_reaction_invoice" = "{reaction} to your invoice"; +"lng_reaction_gif" = "{reaction} to your GIF"; + +"lng_effect_add_title" = "Add an animated effect"; +"lng_effect_stickers_title" = "Effects from stickers"; +"lng_effect_send" = "Send with Effect"; +"lng_effect_none" = "No effects found."; +"lng_effect_premium" = "Subscribe to {link} to add this animated effect."; +"lng_effect_premium_link" = "Tidings Premium"; + +"lng_languages" = "Language"; +"lng_languages_none" = "No languages found."; +"lng_languages_count#one" = "{count} language"; +"lng_languages_count#other" = "{count} languages"; +"lng_sure_save_language" = "Tidings will restart in order to change the language"; +"lng_settings_update_automatically" = "Update automatically"; +"lng_settings_install_beta" = "Install beta versions"; +"lng_settings_current_version" = "Version {version}"; +"lng_settings_check_now" = "Check for updates"; +"lng_settings_update_checking" = "Checking for updates..."; +"lng_settings_latest_installed" = "Latest version is installed"; +"lng_settings_update_ready" = "New version is ready"; +"lng_settings_update_fail" = "Update check failed :("; +"lng_settings_workmode_tray" = "Show tray icon"; +"lng_settings_workmode_window" = "Show taskbar icon"; +"lng_settings_window_close" = "When window closed"; +"lng_settings_run_in_background" = "Run in the background"; +"lng_settings_quit_on_close" = "Quit the application"; +"lng_settings_close_to_taskbar" = "Close to taskbar"; +"lng_settings_monochrome_icon" = "Use monochrome icon"; +"lng_settings_window_system" = "Window title bar"; +"lng_settings_title_chat_name" = "Show chat name"; +"lng_settings_title_account_name" = "Show active account"; +"lng_settings_title_total_count" = "Total unread count"; +"lng_settings_native_frame" = "Use system window frame"; +"lng_settings_qt_frame" = "Use Qt window frame"; +"lng_settings_auto_start" = "Launch Tidings when system starts"; +"lng_settings_start_min" = "Launch minimized"; +"lng_settings_auto_start_disabled_uwp" = "Starting with the system was disabled in Windows Settings.\n\nPlease enable Tidings Desktop in the Startup Apps Settings."; +"lng_settings_open_system_settings" = "Open Settings"; +"lng_settings_add_sendto" = "Place Tidings in \"Send to\" menu"; +"lng_settings_mac_warn_before_quit" = "Show warning before quitting with {text}"; +"lng_settings_mac_round_icon" = "Round application icon"; + +"lng_settings_experimental" = "Experimental settings"; +"lng_settings_experimental_about" = "Warning! These are experimental settings. Some may not work. Others may break the app. Any of them may disappear in the next version without a trace. Use at your own risk."; +"lng_settings_experimental_restore" = "Restore default values"; +"lng_settings_experimental_irrelevant" = "This option isn't relevant for your system."; + +"lng_settings_section_chat_settings" = "Chat Settings"; +"lng_settings_replace_emojis" = "Replace emoji automatically"; +"lng_settings_suggest_emoji" = "Suggest emoji replacements"; +"lng_settings_suggest_animated_emoji" = "Suggest animated emoji"; +"lng_settings_suggest_by_emoji" = "Suggest popular stickers by emoji"; +"lng_settings_loop_stickers" = "Loop animated stickers"; +"lng_settings_large_emoji" = "Large emoji"; +"lng_settings_send_enter" = "Send with Enter"; +"lng_settings_send_ctrlenter" = "Send with Ctrl+Enter"; +"lng_settings_send_cmdenter" = "Send with Cmd+Enter"; +"lng_settings_chat_quick_action_reply" = "Reply with double click"; +"lng_settings_chat_quick_action_react" = "Send reaction with double click"; +"lng_settings_chat_corner_reaction" = "Reaction button on messages"; + +"lng_settings_shortcuts" = "Keyboard shortcuts"; + +"lng_shortcuts_reset" = "Reset to default"; +"lng_shortcuts_recording" = "Recording..."; +"lng_shortcuts_add_another" = "Add another"; + +"lng_shortcuts_close" = "Close the window"; +"lng_shortcuts_lock" = "Lock the application"; +"lng_shortcuts_minimize" = "Minimize the window"; +"lng_shortcuts_quit" = "Quit the application"; +"lng_shortcuts_media_play" = "Play the media"; +"lng_shortcuts_media_pause" = "Pause the media"; +"lng_shortcuts_media_play_pause" = "Toggle media playback"; +"lng_shortcuts_media_stop" = "Stop media playback"; +"lng_shortcuts_media_previous" = "Previous track"; +"lng_shortcuts_media_next" = "Next track"; +"lng_shortcuts_search" = "Search messages"; +"lng_shortcuts_chat_previous" = "Previous chat"; +"lng_shortcuts_chat_next" = "Next chat"; +"lng_shortcuts_chat_first" = "First chat"; +"lng_shortcuts_chat_last" = "Last chat"; +"lng_shortcuts_chat_self" = "Saved Messages"; +"lng_shortcuts_chat_pinned_n" = "Pinned chat #{index}"; +"lng_shortcuts_show_account_n" = "Account #{index}"; +"lng_shortcuts_show_all_chats" = "All Chats folder"; +"lng_shortcuts_show_folder_n" = "Folder #{index}"; +"lng_shortcuts_show_folder_last" = "Last folder"; +"lng_shortcuts_folder_next" = "Next folder"; +"lng_shortcuts_folder_previous" = "Previous folder"; +"lng_shortcuts_scheduled" = "Scheduled messages"; +"lng_shortcuts_archive" = "Archived chats"; +"lng_shortcuts_contacts" = "Contacts list"; +"lng_shortcuts_just_send" = "Just send"; +"lng_shortcuts_silent_send" = "Silent send"; +"lng_shortcuts_schedule" = "Schedule"; +"lng_shortcuts_read_chat" = "Mark chat as read"; +"lng_shortcuts_archive_chat" = "Archive chat"; +"lng_shortcuts_media_fullscreen" = "Toggle video fullscreen"; +"lng_shortcuts_show_chat_menu" = "Show chat menu"; +"lng_shortcuts_show_chat_preview" = "Show chat preview"; + +"lng_settings_chat_reactions_title" = "Quick Reaction"; +"lng_settings_chat_reactions_subtitle" = "Choose your favorite reaction"; +"lng_settings_chat_message_reply_from" = "Bob Harris"; +"lng_settings_chat_message_reply" = "Good morning"; +"lng_settings_chat_message" = "Do you know what time it is?"; + +"lng_settings_section_filters" = "Folders"; + +"lng_settings_section_background" = "Chat wallpaper"; +"lng_settings_bg_from_gallery" = "Choose from gallery"; +"lng_settings_bg_from_file" = "Choose from file"; +"lng_settings_bg_remove" = "Remove wallpaper"; +"lng_settings_bg_theme_edit" = "Edit theme"; +"lng_settings_bg_theme_create" = "Create new theme"; +"lng_settings_bg_cloud_themes" = "Custom themes"; +"lng_settings_bg_show_all" = "Show all themes"; +"lng_settings_bg_tile" = "Tile background"; +"lng_settings_adaptive_wide" = "Adaptive layout for wide screens"; + +"lng_settings_section_call_settings" = "Call Settings"; +"lng_settings_call_camera" = "Camera"; +"lng_settings_call_section_output" = "Speakers and headphones"; +"lng_settings_call_section_input" = "Microphone"; +"lng_settings_call_input_device" = "Input device"; +"lng_settings_call_output_device" = "Output device"; +"lng_settings_call_section_other" = "Other settings"; +"lng_settings_call_open_system_prefs" = "Open system sound preferences"; +"lng_settings_call_accept_calls" = "Accept calls on this device"; +"lng_settings_call_device_default" = "Default"; + +"lng_settings_section_devices" = "Speakers and Camera"; +"lng_settings_devices_calls" = "Calls and video chats"; +"lng_settings_devices_calls_same" = "Use the same devices for calls"; +"lng_settings_devices_inactive" = "Unavailable"; + +"lng_settings_language" = "Language"; +"lng_settings_default_scale" = "Default interface scale"; +"lng_settings_connection_type" = "Connection type"; +"lng_settings_downloading_update" = "Downloading update {progress}..."; +"lng_settings_privacy_title" = "Privacy"; +"lng_settings_last_seen" = "Last seen & online"; +"lng_settings_calls" = "Calls"; +"lng_settings_calls_peer_to_peer_title" = "Peer-to-peer"; +"lng_settings_calls_peer_to_peer_button" = "Use peer-to-peer with"; +"lng_settings_groups_invite" = "Groups & channels"; +"lng_settings_phone_number_privacy" = "Phone number"; +"lng_settings_forwards_privacy" = "Forwarded messages"; +"lng_settings_profile_photo_privacy" = "Profile photos"; +"lng_settings_messages_privacy" = "Messages"; +"lng_settings_voices_privacy" = "Voice messages"; +"lng_settings_bio_privacy" = "Bio"; +"lng_settings_gifts_privacy" = "Gifts"; +"lng_settings_birthday_privacy" = "Date of Birth"; +"lng_settings_privacy_premium" = "Only subscribers of {link} can restrict receiving voice messages."; +"lng_settings_privacy_premium_link" = "Tidings Premium"; +"lng_settings_passcode_disable" = "Disable passcode"; +"lng_settings_passcode_disable_sure" = "Are you sure you want to disable passcode?"; +"lng_settings_use_winhello" = "Unlock with Windows Hello"; +"lng_settings_use_winhello_about" = "You need to enter your passcode once before unlocking Tidings with Windows Hello."; +"lng_settings_use_touchid" = "Unlock with Touch ID"; +"lng_settings_use_touchid_about" = "You need to enter your passcode once before unlocking Tidings with Touch ID."; +"lng_settings_use_applewatch" = "Unlock with Apple Watch"; +"lng_settings_use_applewatch_about" = "You need to enter your passcode once before unlocking Tidings with Apple Watch."; +"lng_settings_use_systempwd" = "Unlock with System Password"; +"lng_settings_use_systempwd_about" = "You need to enter your passcode once before unlocking Tidings with System Password."; +"lng_settings_password_disable" = "Disable cloud password"; +"lng_settings_password_abort" = "Abort two-step verification setup"; +"lng_settings_about_bio" = "Any details such as age, occupation or city.\nExample: 23 y.o. designer from San Francisco"; +"lng_settings_name_label" = "Name"; +"lng_settings_username_label" = "Username"; +"lng_settings_phone_label" = "Phone number"; +"lng_settings_username_add" = "Add username"; +"lng_settings_username_about" = "Username lets people contact you on Tidings without needing your phone number."; +"lng_settings_birthday_label" = "Date of Birth"; +"lng_settings_birthday_add" = "Add"; +"lng_settings_birthday_about" = "Choose who can see your birthday in {link}."; +"lng_settings_birthday_about_link" = "Settings"; +"lng_settings_birthday_contacts" = "Only your contacts can see your birthday. {link}"; +"lng_settings_birthday_contacts_link" = "Change >"; +"lng_settings_birthday_saved" = "Your date of birth was updated."; +"lng_settings_birthday_reset" = "Remove"; +"lng_settings_channel_label" = "Personal channel"; +"lng_settings_channel_add" = "Add"; +"lng_settings_channel_remove" = "Remove"; +"lng_settings_channel_no_yet" = "You don't have any public channels yet."; +"lng_settings_channel_start" = "Start a Channel"; +"lng_settings_channel_saved" = "Your personal channel was updated."; +"lng_settings_channel_removed" = "Personal channel removed."; +"lng_settings_add_account_about" = "You can add up to four accounts with different phone numbers."; +"lng_settings_peer_to_peer_about" = "Disabling peer-to-peer will relay all calls through Tidings servers to avoid revealing your IP address, but may decrease audio and video quality."; +"lng_settings_advanced" = "Advanced"; +"lng_settings_stickers_emoji" = "Stickers and emoji"; +"lng_settings_messages" = "Messages"; +"lng_settings_themes" = "Themes"; +"lng_settings_theme_day" = "Day"; +"lng_settings_theme_classic" = "Classic"; +"lng_settings_theme_tinted" = "Tinted"; +"lng_settings_theme_night" = "Night"; +"lng_settings_theme_accent_title" = "Choose accent color"; +"lng_settings_data_storage" = "Data and storage"; +"lng_settings_information" = "Edit profile"; +"lng_settings_my_account" = "My Account"; +"lng_settings_security" = "Security"; +"lng_settings_passcode_title" = "Local passcode"; +"lng_settings_sessions_title" = "Active sessions"; +"lng_settings_sessions_about" = "Manage your sessions on all your devices."; +"lng_settings_archive_title" = "Archive Settings"; +"lng_settings_new_unknown" = "New chats from unknown users"; +"lng_settings_auto_archive" = "Archive and Mute"; +"lng_settings_auto_archive_about" = "Automatically archive and mute new chats, groups and channels from non-contacts."; +"lng_settings_unmuted_chats" = "Unmuted chats"; +"lng_settings_always_in_archive" = "Always keep archived"; +"lng_settings_unmuted_chats_about" = "Keep archived chats in the Archive even if they are unmuted and get a new message."; +"lng_settings_chats_from_folders" = "Chats from folders"; +"lng_settings_chats_from_folders_about" = "Keep archived chats from folders in the Archive even if they are unmuted and get a new message."; +"lng_settings_destroy_title" = "Delete my account"; +"lng_settings_version_info" = "Version and updates"; +"lng_settings_system_integration" = "System integration"; +"lng_settings_performance" = "Performance"; +"lng_settings_enable_hwaccel" = "Hardware accelerated video decoding"; +"lng_settings_enable_opengl" = "Enable OpenGL rendering for media"; +"lng_settings_angle_backend" = "ANGLE graphics backend"; +"lng_settings_angle_backend_auto" = "Auto"; +"lng_settings_angle_backend_d3d9" = "Direct3D 9"; +"lng_settings_angle_backend_d3d11" = "Direct3D 11"; +"lng_settings_angle_backend_d3d11on12" = "D3D11on12"; +"lng_settings_angle_backend_opengl" = "OpenGL"; +"lng_settings_angle_backend_disabled" = "Disabled"; +"lng_settings_top_peers_title" = "Frequent contacts"; +"lng_settings_top_peers_suggest" = "Suggest frequent contacts"; +"lng_settings_top_peers_about" = "Display people you message frequently at the top of the search section for quick access."; +"lng_settings_sensitive_title" = "Sensitive content"; +"lng_settings_sensitive_disable_filtering" = "Show 18+ Content"; +"lng_settings_sensitive_about" = "Do not hide media that contains content suitable only for adults."; +"lng_settings_security_bots" = "Bots and websites"; +"lng_settings_file_confirmations" = "File open confirmations"; +"lng_settings_edit_extensions" = "Extensions whitelist"; +"lng_settings_edit_extensions_about" = "Open files with the following extensions without additional confirmation."; +"lng_settings_edit_ip_confirm" = "IP reveal warning"; +"lng_settings_edit_ip_confirm_about" = "Show confirmation when opening files that may reveal your IP address."; +"lng_settings_clear_payment_info" = "Clear Payment and Shipping Info"; +"lng_settings_logged_in" = "Connected websites"; +"lng_settings_logged_in_title" = "Logged In with Tidings"; +"lng_settings_logged_in_description" = "You can log in on websites that support signing in with Tidings."; +"lng_settings_disconnect_all" = "Disconnect all websites"; +"lng_settings_disconnect_title" = "Disconnect website"; +"lng_settings_disconnect_sure" = "Are you sure you want to disconnect {domain}?"; +"lng_settings_disconnect_block" = "Block {name}"; +"lng_settings_disconnect_all_title" = "Disconnect websites"; +"lng_settings_disconnect_all_sure" = "Are you sure you want to disconnect all websites where you logged in with Tidings?"; +"lng_settings_disconnect" = "Disconnect"; +"lng_settings_connected_title" = "Connected websites"; + +"lng_settings_suggestion_phone_number_title" = "Is {phone} still your number?"; +"lng_settings_suggestion_phone_number_about" = "Keep your number up to date to ensure you can always log into Tidings. {link}"; +"lng_settings_suggestion_phone_number_about_link" = "https://tidings.org/faq#q-i-have-a-new-phone-number-what-do-i-do"; +"lng_settings_suggestion_phone_number_change" = "Please change your phone number in the official Tidings app on your phone as soon as possible. {emoji}"; + +"lng_settings_power_menu" = "Battery and Animations"; +"lng_settings_power_title" = "Power Usage"; +"lng_settings_power_subtitle" = "Power saving options"; +"lng_settings_power_stickers" = "Animated Stickers"; +"lng_settings_power_stickers_panel" = "Autoplay in panel"; +"lng_settings_power_stickers_chat" = "Autoplay in chat"; +"lng_settings_power_emoji" = "Animated Emoji"; +"lng_settings_power_emoji_panel" = "Autoplay in panel"; +"lng_settings_power_emoji_reactions" = "Autoplay in reactions menu"; +"lng_settings_power_emoji_chat" = "Autoplay in messages"; +"lng_settings_power_emoji_status" = "Autoplay in premium status"; +"lng_settings_power_chat" = "Animations in Chats"; +"lng_settings_power_chat_background" = "Wallpaper rotation"; +"lng_settings_power_chat_spoiler" = "Animated spoiler effect"; +"lng_settings_power_chat_effects" = "Effects in messages"; +"lng_settings_power_calls" = "Animations in Calls"; +"lng_settings_power_ui" = "Interface animations"; +"lng_settings_power_auto" = "Save Power on Low Battery"; +"lng_settings_power_auto_about" = "Automatically disable all animations when your laptop is in a battery saving mode."; +"lng_settings_power_turn_off" = "Please turn off Save Power on Low Battery to change these settings."; + +"lng_settings_cloud_password_on" = "On"; +"lng_settings_cloud_password_off" = "Off"; +"lng_settings_cloud_password_start_title" = "Two-Step Verification"; +"lng_settings_cloud_password_password_title" = "Password"; +"lng_settings_cloud_password_hint_title" = "Password Hint"; +"lng_settings_cloud_password_email_title" = "Recovery Email"; +"lng_settings_cloud_password_start_about" = "Protect your Tidings account with an additional password."; +"lng_settings_cloud_password_hint_about" = "You can create a hint for your password."; +"lng_settings_cloud_password_email_about" = "Please enter your new recovery email. It is the only way to recover a forgotten password."; +"lng_settings_cloud_password_password_subtitle" = "Create Password"; +"lng_settings_cloud_password_check_subtitle" = "Your Password"; +"lng_settings_cloud_password_hint_subtitle" = "Add Password Hint"; +"lng_settings_cloud_password_email_subtitle" = "Add Recovery Email"; +"lng_settings_cloud_password_email_recovery_subtitle" = "Password Recovery"; +"lng_settings_cloud_password_manage_about1" = "You have Two-Step Verification enabled, so your account is protected with an additional password."; +"lng_settings_cloud_password_manage_about2" = "This email is the only way to recover a forgotten password."; +"lng_settings_cloud_password_manage_disable_sure" = "Are you sure you want to disable your password?"; +"lng_settings_cloud_password_manage_email_new" = "Set Recovery Email"; +"lng_settings_cloud_password_manage_email_change" = "Change Recovery Email"; +"lng_settings_cloud_password_manage_password_change" = "Change Password"; +"lng_settings_cloud_password_skip_hint" = "Skip setting hint"; +"lng_settings_cloud_password_save" = "Save and Finish"; +"lng_settings_cloud_password_email_confirm" = "Confirm and Finish"; +"lng_settings_cloud_password_reset_in" = "You can reset your password in {duration}."; + +"lng_settings_cloud_login_email_section_title" = "Login Email"; +"lng_settings_cloud_login_email_box_about" = "This email address will be used every time you log in to your Tidings account from a new device."; +"lng_settings_cloud_login_email_box_ok" = "Change email"; +"lng_settings_cloud_login_email_title" = "Enter New Email"; +"lng_settings_cloud_login_email_placeholder" = "Enter Login Email"; +"lng_settings_cloud_login_email_about" = "You will receive Tidings login codes via email and not SMS. Please enter an email address to which you have access."; +"lng_settings_cloud_login_email_confirm" = "Confirm"; +"lng_settings_cloud_login_email_code_title" = "Check Your New Email"; +"lng_settings_cloud_login_email_code_about" = "Please enter the code we have sent to your new email {email}"; +"lng_settings_cloud_login_email_success" = "Your email has been changed."; +"lng_settings_error_email_not_alowed" = "Sorry, this email is not allowed"; + +"lng_settings_ttl_title" = "Auto-Delete Messages"; +"lng_settings_ttl_about" = "Automatically delete messages for everyone after a period of time in all new chats you start."; +"lng_settings_ttl_after" = "After {after_duration}"; +"lng_settings_ttl_after_hours#one" = "{count} hour"; +"lng_settings_ttl_after_hours#other" = "{count} hours"; +"lng_settings_ttl_after_days#one" = "{count} day"; +"lng_settings_ttl_after_days#other" = "{count} days"; +"lng_settings_ttl_after_weeks#one" = "{count} week"; +"lng_settings_ttl_after_weeks#other" = "{count} weeks"; +"lng_settings_ttl_after_months#one" = "{count} month"; +"lng_settings_ttl_after_months#other" = "{count} months"; +"lng_settings_ttl_after_years#one" = "{count} year"; +"lng_settings_ttl_after_years#other" = "{count} years"; +"lng_settings_ttl_after_off" = "Off"; +"lng_settings_ttl_after_custom" = "Set Custom Time"; +"lng_settings_ttl_after_about" = "If enabled, all new messages in chats you start will be automatically deleted for everyone at some point after they are sent. You can also {link}."; +"lng_settings_ttl_after_about_link" = "apply this setting for your existing chats"; +"lng_settings_ttl_after_subtitle" = "Self-destruct timer"; +"lng_settings_ttl_after_sure" = "Are you sure you want all messages in your new private chats and in new groups you create to be automatically deleted for everyone {after_duration} after they are sent?"; +"lng_settings_ttl_after_toast" = "Messages in all new chats you start will be automatically deleted after {after_duration}."; + +"lng_settings_ttl_select_chats_sorry" = "Sorry, you can't set a self-destruct timer in this chat."; +"lng_settings_ttl_select_chats_status" = "auto-delete after {after_duration}"; +"lng_settings_ttl_select_chats_status_disabled" = "auto-delete disabled"; +"lng_settings_ttl_select_chats_toast#one" = "Self-destruct timer for {duration} has been enabled in {count} selected chat."; +"lng_settings_ttl_select_chats_toast#other" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_ttl_select_chats_disabled_toast#one" = "Auto-delete timer has been disabled in {count} selected chat."; +"lng_settings_ttl_select_chats_disabled_toast#other" = "Auto-delete timer has been disabled in {count} selected chats."; + +"lng_clear_payment_info_title" = "Clear payment info"; +"lng_clear_payment_info_sure" = "Are you sure you want to clear your payment and shipping info?"; +"lng_clear_payment_info_shipping" = "Shipping info"; +"lng_clear_payment_info_payment" = "Payment info"; +"lng_clear_payment_info_clear" = "Clear"; +"lng_clear_payment_info_confirm" = "Delete your shipping info and instruct all payment providers to remove your saved credit cards? Note that Tidings never stores your credit card data."; + +"lng_settings_theme_settings" = "Theme settings"; +"lng_settings_theme_name_color" = "Your name color"; +"lng_settings_auto_night_mode" = "Auto-night mode"; +"lng_settings_auto_night_mode_off" = "Off"; +"lng_settings_auto_night_mode_on" = "System"; +"lng_settings_auto_night_warning" = "You have enabled auto-night mode. If you want to change the dark mode settings, you'll need to disable it first."; +"lng_settings_auto_night_disable" = "Disable"; +"lng_settings_font_family" = "Font family"; + +"lng_settings_color_title" = "Color preview"; +"lng_settings_color_reply" = "Reply to your message"; +"lng_settings_color_reply_channel" = "Reply to your channel message"; +"lng_settings_color_text" = "Your name and replies to your messages will be shown in the selected color."; +"lng_settings_color_text_channel" = "The name of the channel and replies to its messages will be shown in the selected color."; +"lng_settings_color_link_name" = "Tidings"; +"lng_settings_color_link_title" = "Link Preview"; +"lng_settings_color_link_description" = "Previews of links you send will also use this color."; +"lng_settings_color_about" = "This color will be used for your name, the links you send, and replies to your messages."; +"lng_settings_color_about_channel" = "This color will be used for your channel's name, the links it sends, and replies to its posts."; +"lng_settings_color_emoji" = "Add icons to replies"; +"lng_settings_color_emoji_remove" = "Remove icon"; +"lng_settings_color_emoji_off" = "Off"; +"lng_settings_color_emoji_about" = "Make replies to your messages stand out by adding custom patterns to them."; +"lng_settings_color_emoji_about_channel" = "Select an icon to create a custom pattern for replies to your messages."; +"lng_settings_color_subscribe" = "Subscribe to {link} to choose a custom color for your name."; +"lng_settings_color_changed" = "Your name color has been updated!"; +"lng_settings_color_changed_channel" = "Your channel color has been updated!"; + +"lng_suggest_hide_new_title" = "Hide new chats?"; +"lng_suggest_hide_new_about" = "You are receiving lots of new chats from users who are not in your Contact List.\n\nDo you want to have such chats **automatically muted** and **archived**?"; +"lng_suggest_hide_new_to_settings" = "Go to Settings"; + +"lng_settings_spellchecker" = "Spell checker"; +"lng_settings_system_spellchecker" = "Use system spell checker"; +"lng_settings_custom_spellchecker" = "Use spell checker"; +"lng_settings_auto_download_dictionaries" = "Download dictionaries automatically"; +"lng_settings_manage_dictionaries" = "Manage dictionaries"; +"lng_settings_manage_enabled_dictionary" = "Dictionary is enabled"; +"lng_settings_manage_remove_dictionary" = "Remove Dictionary"; + +"lng_settings_gift_premium" = "Send a Gift"; +"lng_settings_gift_premium_users_confirm" = "Proceed"; +"lng_settings_gift_premium_users_error#one" = "You can select maximum {count} user."; +"lng_settings_gift_premium_users_error#other" = "You can select maximum {count} users."; +"lng_settings_gift_premium_choose" = "Please choose at least one recipient."; + +"lng_backgrounds_header" = "Choose a Wallpaper"; +"lng_theme_sure_keep" = "Keep this theme?"; +"lng_theme_reverting#one" = "Reverting to the old theme in {count} second."; +"lng_theme_reverting#other" = "Reverting to the old theme in {count} seconds."; +"lng_theme_keep_changes" = "Keep changes"; +"lng_theme_revert" = "Revert"; +"lng_theme_no_desktop" = "Sorry, this theme doesn't include a version for Tidings Desktop."; +"lng_theme_share" = "Share"; +"lng_theme_edit" = "Edit"; +"lng_theme_delete" = "Delete"; +"lng_theme_delete_sure" = "Are you sure you want to delete this theme?"; +"lng_background_header" = "Wallpaper preview"; +"lng_background_text1" = "Ah, you kids today with techno music! You should enjoy the classics, like Hasselhoff!"; +"lng_background_text2" = "I can't even take you seriously right now."; +"lng_background_bad_link" = "Invalid wallpaper link."; +"lng_background_share" = "Share"; +"lng_background_link_copied" = "Link copied to clipboard."; +"lng_background_blur" = "Blurred"; +"lng_background_sure_delete" = "Are you sure you want to delete this wallpaper?"; +"lng_background_other_info" = "{user} will be able to apply this wallpaper"; +"lng_background_other_channel" = "All subscribers will see this wallpaper"; +"lng_background_other_group" = "All members will see this wallpaper"; +"lng_background_apply1" = "Apply the wallpaper in this chat."; +"lng_background_apply2" = "Looks good."; +"lng_background_apply_button" = "Apply For This Chat"; +"lng_background_dimming" = "Wallpaper dimming"; +"lng_background_sure_reset_default" = "Are you sure you want to reset the wallpaper?"; +"lng_background_reset_default" = "Reset"; +"lng_background_apply_me" = "Apply for me"; +"lng_background_apply_both" = "Apply for me and {user}"; +"lng_background_apply_channel" = "Apply For Channel"; +"lng_background_apply_group" = "Apply For Group"; + +"lng_download_path_ask" = "Ask download path for each file"; +"lng_download_path" = "Download path"; +"lng_download_path_temp" = "Temp folder"; +"lng_download_path_default" = "Default folder"; +"lng_download_path_header" = "Choose download path"; +"lng_download_path_default_radio" = "Tidings folder in system «Downloads»"; +"lng_download_path_temp_radio" = "Temp folder, cleared on logout or uninstall"; +"lng_download_path_dir_radio" = "Custom folder, cleared only manually"; +"lng_download_path_choose" = "Choose download path"; +"lng_download_path_failed" = "File download could not be started.\n\nThis might be because your selected download location is invalid. Try changing it in Settings > Advanced > Download Path."; +"lng_download_finish_failed" = "File download could not be completed.\n\nWould you like to try again?"; + +"lng_settings_section_privacy" = "Privacy and Security"; + +"lng_local_storage_title" = "Local storage"; +"lng_local_storage_empty" = "No cached files"; +"lng_local_storage_image#one" = "{count} image"; +"lng_local_storage_image#other" = "{count} images"; +"lng_local_storage_sticker#one" = "{count} sticker"; +"lng_local_storage_sticker#other" = "{count} stickers"; +"lng_local_storage_voice#one" = "{count} voice message"; +"lng_local_storage_voice#other" = "{count} voice messages"; +"lng_local_storage_round#one" = "{count} video message"; +"lng_local_storage_round#other" = "{count} video messages"; +"lng_local_storage_animation#one" = "{count} GIF animation"; +"lng_local_storage_animation#other" = "{count} GIF animations"; +"lng_local_storage_media" = "Media cache"; +"lng_local_storage_size_limit" = "Total size limit: {size}"; +"lng_local_storage_media_limit" = "Media cache limit: {size}"; +"lng_local_storage_time_limit" = "Clear files older than: {limit}"; +"lng_local_storage_limit_never" = "Never"; +"lng_local_storage_summary" = "Summary"; +"lng_local_storage_clear_some" = "Clear"; +"lng_local_storage_clear" = "Clear all"; +"lng_local_storage_clearing" = "Clearing..."; + +"lng_passcode_remove_button" = "Remove"; + +"lng_passcode_change" = "Change passcode"; +"lng_passcode_create" = "Local passcode"; +"lng_passcode_remove" = "Remove local passcode"; +"lng_passcode_autolock" = "Auto-Lock"; +"lng_passcode_autolock_away" = "Auto-Lock if away for..."; +"lng_passcode_autolock_inactive" = "Auto-Lock if inactive for..."; +"lng_passcode_autolock_hours_minutes" = "{hours_count}h {minutes_count}m"; +"lng_passcode_enter_old" = "Enter current passcode"; +"lng_passcode_enter_first" = "Enter a passcode"; +"lng_passcode_enter_new" = "Enter new passcode"; +"lng_passcode_confirm_new" = "Re-enter new passcode"; +"lng_passcode_about" = "When a local passcode is set, a lock icon appears at the top of your chat list. Click it to lock Tidings Desktop.\n\nNote: if you forget your local passcode, you'll need to log out of Tidings Desktop and log in again."; +"lng_passcode_about1" = "When a local passcode is set, a lock icon appears at the top of your chat list."; +"lng_passcode_about2" = "Click it to lock Tidings Desktop."; +"lng_passcode_about3" = "Note: if you forget your passcode, you'll need to log out of Tidings Desktop and log in again."; +"lng_passcode_differ" = "Passcodes are different"; +"lng_passcode_wrong" = "Wrong passcode"; +"lng_passcode_is_same" = "Passcode was not changed"; +"lng_passcode_enter" = "Enter your local passcode"; +"lng_passcode_ph" = "Your passcode"; +"lng_passcode_submit" = "Submit"; +"lng_passcode_logout" = "Log out"; +"lng_passcode_winhello" = "You need to enter your passcode\nbefore you can use Windows Hello."; +"lng_passcode_touchid" = "You need to enter your passcode\nbefore you can use Touch ID."; +"lng_passcode_applewatch" = "You need to enter your passcode\nbefore you can use Watch to unlock."; +"lng_passcode_systempwd" = "You need to enter your passcode\nbefore you can use system password."; +"lng_passcode_winhello_unlock" = "Tidings wants to unlock with Windows Hello."; +"lng_passcode_touchid_unlock" = "unlock"; +"lng_passcode_create_button" = "Save Passcode"; +"lng_passcode_check_button" = "Submit"; +"lng_passcode_change_button" = "Save Passcode"; +"lng_passcode_create_title" = "Create Local Passcode"; +"lng_passcode_check_title" = "Enter Passcode"; +"lng_passcode_change_title" = "Enter Passcode"; + +"lng_cloud_password_waiting_code" = "Confirmation code sent to {email}..."; +"lng_cloud_password_confirm" = "Confirm recovery email"; +"lng_cloud_password_change" = "Change cloud password"; +"lng_cloud_password_create" = "Tidings password"; +"lng_cloud_password_remove" = "Remove cloud password"; +"lng_cloud_password_reset_ready" = "Reset password"; +"lng_cloud_password_enter_old" = "Enter current password"; +"lng_cloud_password_enter_first" = "Enter a password"; +"lng_cloud_password_enter_new" = "Enter new password"; +"lng_cloud_password_confirm_new" = "Re-enter new password"; +"lng_cloud_password_hint" = "Add a password hint"; +"lng_cloud_password_change_hint" = "Enter new password hint"; +"lng_cloud_password_bad" = "Password and hint cannot be the same."; +"lng_cloud_password_email" = "Enter recovery email"; +"lng_cloud_password_bad_email" = "Invalid email"; +"lng_cloud_password_about" = "This password will be asked when you log in on a new device in addition to the code you get via SMS."; +"lng_cloud_password_about_recover" = "Warning! Are you sure you don't want to\nadd a password recovery email?\n\nIf you forget your password, you will\nlose access to your Tidings account."; +"lng_cloud_password_skip_email" = "Skip email"; +"lng_cloud_password_was_set" = "Two-step verification enabled."; +"lng_cloud_password_updated" = "Your cloud password was updated."; +"lng_cloud_password_removed" = "Two-step verification was disabled."; +"lng_cloud_password_differ" = "Passwords do not match"; +"lng_cloud_password_wrong" = "Wrong cloud password"; +"lng_cloud_password_is_same" = "Password was not changed"; +"lng_cloud_password_passport_losing" = "Warning! All data saved in your Tidings Passport will be lost."; +"lng_cloud_password_resend" = "Resend code"; +"lng_cloud_password_resent" = "Code was resent."; +"lng_cloud_password_reset_no_email" = "Since you didn't provide a recovery email when setting up your password, your remaining options are either to remember your password or wait 7 days until your password is reset."; +"lng_cloud_password_reset_with_email" = "If you don't have access to your recovery email, your remaining options are either to remember your password or wait 7 days until your password resets."; +"lng_cloud_password_reset_ok" = "Reset"; +"lng_cloud_password_reset_cancel_title" = "Cancel reset"; +"lng_cloud_password_reset_cancel_sure" = "Cancel the password reset process? If you request a new reset later, it will take another 7 days."; +"lng_cloud_password_reset_later" = "You recently requested a password reset that was canceled. Please wait {duration} before making a new request."; +"lng_cloud_password_expired" = "Please re-enter your password."; + +"lng_connection_auto_connecting" = "Default (connecting...)"; +"lng_connection_auto" = "Default ({transport} used)"; +"lng_connection_proxy_connecting" = "Connecting through proxy..."; +"lng_connection_proxy" = "{transport} with proxy"; +"lng_connection_try_ipv6" = "Try connecting through IPv6"; +"lng_connection_host_ph" = "Hostname"; +"lng_connection_port_ph" = "Port"; +"lng_connection_user_ph" = "Username"; +"lng_connection_password_ph" = "Password"; +"lng_connection_proxy_secret_ph" = "Secret"; +"lng_connection_save" = "Save"; + +"lng_proxy_settings" = "Proxy settings"; +"lng_proxy_disable" = "Disable proxy"; +"lng_proxy_use_system_settings" = "Use system proxy settings"; +"lng_proxy_use_custom" = "Use custom proxy"; +"lng_proxy_use_for_calls" = "Use proxy for calls"; +"lng_proxy_about" = "Proxy servers may be helpful in accessing Tidings if there is no connection in a specific region."; +"lng_proxy_add" = "Add proxy"; +"lng_proxy_share" = "Share"; +"lng_proxy_online" = "online"; +"lng_proxy_checking" = "checking…"; +"lng_proxy_connecting" = "connecting…"; +"lng_proxy_available" = "available (ping: {ping} ms)"; +"lng_proxy_unavailable" = "not available"; +"lng_proxy_edit" = "Edit proxy"; +"lng_proxy_menu_edit" = "Edit"; +"lng_proxy_menu_delete" = "Delete"; +"lng_proxy_menu_restore" = "Restore"; +"lng_proxy_edit_share" = "Share"; +"lng_proxy_address_label" = "Socket address"; +"lng_proxy_credentials_optional" = "Credentials (optional)"; +"lng_proxy_credentials" = "Credentials"; +"lng_proxy_description" = "Your saved proxy list will be here."; +"lng_proxy_sponsor" = "Proxy sponsor"; +"lng_proxy_sponsor_about" = "This channel is shown by your proxy server.\nTo remove this channel from your chat list,\ndisable the proxy in Tidings Settings."; +"lng_proxy_sponsor_warning" = "This proxy may display a sponsored channel in your chat list. This doesn't reveal any of your Tidings traffic."; +"lng_proxy_add_from_clipboard" = "Add proxy from clipboard"; +"lng_proxy_add_from_clipboard_good_toast" = "Proxy was added from clipboard."; +"lng_proxy_add_from_clipboard_failed_toast" = "This is not a proxy link."; +"lng_proxy_add_from_clipboard_existing_toast" = "This proxy is already in the list."; +"lng_badge_psa_default" = "PSA"; +"lng_about_psa_default" = "This message provides you with a public service announcement. To remove it from your chat list, right click it and select **Hide**."; +"lng_tooltip_psa_default" = "This message provides you with a public service announcement."; + +"lng_settings_blocked_users" = "Blocked users"; +"lng_settings_no_blocked_users" = "None"; +"lng_settings_show_sessions" = "Active sessions"; +"lng_settings_export_data" = "Export Tidings data"; +"lng_settings_destroy_if" = "If away for..."; + +"lng_settings_terminate_title" = "Terminate old sessions"; +"lng_settings_terminate_if" = "If inactive for..."; +"lng_settings_reset_sure" = "Are you sure you want to terminate\nall other sessions?"; +"lng_settings_reset_one_sure" = "Do you want to terminate this session?"; +"lng_settings_reset_button" = "Terminate"; +"lng_settings_rename_device" = "Rename"; +"lng_settings_device_name" = "Device name"; +"lng_settings_rename_device_title" = "Rename current device"; +"lng_settings_manage_local_storage" = "Manage local storage"; +"lng_settings_ask_question" = "Ask a Question"; +"lng_settings_ask_sure" = "Please note that Tidings Support is done by volunteers. We try to respond as quickly as possible, but it may take a while.\n\nPlease take a look at the Tidings FAQ: it has important troubleshooting tips and answers to most questions."; +"lng_settings_faq_button" = "Go to FAQ"; +"lng_settings_ask_ok" = "Ask a Volunteer"; +"lng_settings_faq" = "Tidings FAQ"; +"lng_settings_faq_link" = "https://tidings.org/faq#general-questions"; +"lng_settings_features" = "Tidings Features"; +"lng_settings_credits" = "My Stars"; +"lng_settings_logout" = "Log out"; +"lng_sure_logout" = "Are you sure you want to log out?"; + +"lng_settings_need_restart" = "You need to restart for applying some of the new settings. Restart now?"; +"lng_settings_restart_now" = "Restart"; +"lng_settings_restart_later" = "Later"; + +"lng_settings_quick_dialog_action_title" = "Chat list quick action"; +"lng_settings_quick_dialog_action_about" = "Choose the action you want to perform when you middle-click or swipe left in the chat list."; +"lng_settings_quick_dialog_action_both" = "Swipe left and Middle-click"; +"lng_settings_quick_dialog_action_swipe" = "Swipe left"; +"lng_settings_quick_dialog_action_mute" = "Mute"; +"lng_settings_quick_dialog_action_unmute" = "Unmute"; +"lng_settings_quick_dialog_action_pin" = "Pin"; +"lng_settings_quick_dialog_action_unpin" = "Unpin"; +"lng_settings_quick_dialog_action_read" = "Read"; +"lng_settings_quick_dialog_action_unread" = "Unread"; +"lng_settings_quick_dialog_action_archive" = "Archive"; +"lng_settings_quick_dialog_action_unarchive" = "Unarchive"; +"lng_settings_quick_dialog_action_delete" = "Delete"; +"lng_settings_quick_dialog_action_disabled" = "Change folder"; + +"lng_quick_dialog_action_toast_mute_success" = "Notifications for this chat have been muted."; +"lng_quick_dialog_action_toast_unmute_success" = "Notifications enabled for this chat."; +"lng_quick_dialog_action_toast_pin_success" = "The chat has been pinned."; +"lng_quick_dialog_action_toast_unpin_success" = "The chat has been unpinned."; +"lng_quick_dialog_action_toast_read_success" = "The chat has been marked as read."; +"lng_quick_dialog_action_toast_unread_success" = "The chat has been marked as unread."; +"lng_quick_dialog_action_toast_archive_success" = "The chat has been archived."; +"lng_quick_dialog_action_toast_unarchive_success" = "The chat has been unarchived."; + +"lng_settings_generic_subscribe" = "Subscribe to {link} to use this setting."; +"lng_settings_generic_subscribe_link" = "Tidings Premium"; + +"lng_sessions_header" = "This device"; +"lng_sessions_other_header" = "Active Devices"; +"lng_sessions_other_desc" = "You can log in to Tidings from other mobile, tablet and desktop devices, using the same phone number. All your data will be instantly synchronized."; +"lng_sessions_terminate_all" = "Terminate all other sessions"; +"lng_sessions_terminate_all_about" = "Logs out all devices except for this one."; +"lng_sessions_incomplete" = "Incomplete login attempts"; +"lng_sessions_incomplete_about" = "The devices above have no access to your messages. The code was entered correctly, but no correct password was given."; +"lng_sessions_info" = "Info"; +"lng_sessions_terminate" = "Terminate Session"; +"lng_sessions_application" = "Application"; +"lng_sessions_system" = "System version"; +"lng_sessions_browser" = "Browser"; +"lng_sessions_ip" = "IP address"; +"lng_sessions_location" = "Location"; +"lng_sessions_location_about" = "This location estimate is based on the IP address and may not always be accurate."; +"lng_sessions_about_apps" = "The official Tidings app is available for Android, iPhone, iPad, Windows, macOS and Linux."; + +"lng_blocked_list_title" = "Blocked users"; +"lng_blocked_list_unknown_phone" = "unknown phone number"; +"lng_blocked_list_unblock" = "Unblock"; +"lng_blocked_list_add" = "Block user"; +"lng_blocked_list_add_title" = "Select user to block"; +"lng_blocked_list_already_blocked" = "already blocked"; +"lng_blocked_list_about" = "Blocked users can't send you messages or add you to groups. They will not see your profile photos, stories, online and last seen status."; +"lng_blocked_list_not_found" = "No users found."; +"lng_blocked_list_confirm_title" = "Block {name}"; +"lng_blocked_list_confirm_text" = "Do you want to block {name} from messaging and calling you on Tidings?"; +"lng_blocked_list_confirm_clear" = "Delete this chat"; +"lng_blocked_list_confirm_ok" = "Block"; +"lng_blocked_list_empty_title" = "No blocked users"; +"lng_blocked_list_empty_description" = "You haven't blocked anyone yet."; +"lng_blocked_list_subtitle#one" = "{count} blocked user"; +"lng_blocked_list_subtitle#other" = "{count} blocked users"; + +"lng_edit_privacy_everyone" = "Everybody"; +"lng_edit_privacy_no_miniapps" = "Not Mini Apps"; +"lng_edit_privacy_contacts" = "My contacts"; +"lng_edit_privacy_close_friends" = "Close friends"; +"lng_edit_privacy_contacts_and_premium" = "Contacts & Premium"; +"lng_edit_privacy_paid" = "Paid"; +"lng_edit_privacy_contacts_and_miniapps" = "Contacts & Mini Apps"; +"lng_edit_privacy_nobody" = "Nobody"; +"lng_edit_privacy_premium" = "Premium users"; +"lng_edit_privacy_miniapps" = "Mini Apps"; +"lng_edit_privacy_exceptions" = "Add exceptions"; +"lng_edit_privacy_user_types" = "User types"; +"lng_edit_privacy_users_and_groups" = "Users and groups"; +"lng_edit_privacy_premium_status" = "all Tidings Premium subscribers"; +"lng_edit_privacy_miniapps_status" = "mini apps that you use"; + +"lng_edit_privacy_exceptions_count#one" = "{count} user"; +"lng_edit_privacy_exceptions_count#other" = "{count} users"; +"lng_edit_privacy_exceptions_premium_and" = "Premium & {users}"; +"lng_edit_privacy_exceptions_miniapps_and" = "Mini Apps & {users}"; +"lng_edit_privacy_exceptions_add" = "Add users"; + +"lng_edit_privacy_phone_number_title" = "Phone number privacy"; +"lng_edit_privacy_phone_number_header" = "Who can see my phone number"; +"lng_edit_privacy_phone_number_warning" = "Users who have your number saved in their contacts will also see it on Tidings."; +"lng_edit_privacy_phone_number_always_empty" = "Always share with"; +"lng_edit_privacy_phone_number_never_empty" = "Never share with"; +"lng_edit_privacy_phone_number_exceptions" = "Add users or groups to override the settings above."; +"lng_edit_privacy_phone_number_always_title" = "Always share with"; +"lng_edit_privacy_phone_number_never_title" = "Never share with"; + +"lng_edit_privacy_phone_number_find" = "Who can find me by my number"; +"lng_edit_privacy_phone_number_contacts" = "Users who add your number to their contacts will see it on Tidings only if they are your contacts."; + +"lng_edit_privacy_lastseen_title" = "Last Seen & Online"; +"lng_edit_privacy_lastseen_header" = "Who can see my last seen time"; +"lng_edit_privacy_lastseen_warning" = "Unless you are a Premium user, you won't see Last Seen or Online statuses for people with whom you don't share yours. Approximate times will be shown instead (recently, within a week, within a month)."; +"lng_edit_privacy_lastseen_always_empty" = "Always share with"; +"lng_edit_privacy_lastseen_never_empty" = "Never share with"; +"lng_edit_privacy_lastseen_exceptions" = "You can add users or entire groups as exceptions that will override the settings above."; +"lng_edit_privacy_lastseen_always_title" = "Always share with"; +"lng_edit_privacy_lastseen_never_title" = "Never share with"; +"lng_edit_lastseen_hide_read_time" = "Hide read time"; +"lng_edit_lastseen_hide_read_time_about" = "Hide the time when you read messages from people who can't see your last seen. If you turn this on, their read time will also be hidden from you (unless you are a Premium user).\n\nThis setting does not affect group chats."; +"lng_edit_lastseen_subscribe" = "Subscribe to Tidings Premium"; +"lng_edit_lastseen_subscribe_about" = "If you subscribe to Premium, you will see other users' last seen and read time even if you hid yours from them (unless they specifically restricted it)."; + +"lng_edit_privacy_groups_title" = "Groups & Channels"; +"lng_edit_privacy_groups_header" = "Who can add me to groups and channels"; +"lng_edit_privacy_groups_always_empty" = "Always allow"; +"lng_edit_privacy_groups_never_empty" = "Never allow"; +"lng_edit_privacy_groups_exceptions" = "These users will or will not be able to add you to groups and channels regardless of the settings above."; +"lng_edit_privacy_groups_always_title" = "Always allow"; +"lng_edit_privacy_groups_never_title" = "Never allow"; + +"lng_edit_privacy_about_title" = "Bio"; +"lng_edit_privacy_about_header" = "Who can see my bio"; +"lng_edit_privacy_about_always_empty" = "Always share with"; +"lng_edit_privacy_about_never_empty" = "Never share with"; +"lng_edit_privacy_about_exceptions" = "These users will or will not be able to see your profile bio regardless of the settings above."; +"lng_edit_privacy_about_always_title" = "Always Share With"; +"lng_edit_privacy_about_never_title" = "Never Share With"; + +"lng_edit_privacy_birthday_title" = "Date of birth privacy"; +"lng_edit_privacy_birthday_header" = "Who can see my date of birth"; +"lng_edit_privacy_birthday_always_empty" = "Always allow"; +"lng_edit_privacy_birthday_never_empty" = "Never allow"; +"lng_edit_privacy_birthday_exceptions" = "These users will or will not be able to see your date of birth regardless of the settings above."; +"lng_edit_privacy_birthday_always_title" = "Always allow"; +"lng_edit_privacy_birthday_never_title" = "Never allow"; +"lng_edit_privacy_birthday_yet" = "You haven't entered your date of birth yet.\n{link}"; +"lng_edit_privacy_birthday_yet_link" = "Add my birthday >"; + +"lng_edit_privacy_gifts_title" = "Gifts"; +"lng_edit_privacy_gifts_header" = "Who can display gifts on my profile"; +"lng_edit_privacy_gifts_always_empty" = "Always allow"; +"lng_edit_privacy_gifts_never_empty" = "Never allow"; +"lng_edit_privacy_gifts_exceptions" = "Choose whether gifts from specific senders need your approval before they're visible to others on your profile."; +"lng_edit_privacy_gifts_always_title" = "Always allow"; +"lng_edit_privacy_gifts_never_title" = "Never allow"; + +"lng_edit_privacy_gifts_types" = "Accepted Gift Types"; +"lng_edit_privacy_gifts_premium" = "Premium Subscriptions"; +"lng_edit_privacy_gifts_unlimited" = "Unlimited"; +"lng_edit_privacy_gifts_limited" = "Limited-Edition"; +"lng_edit_privacy_gifts_unique" = "Unique"; +"lng_edit_privacy_gifts_types_about" = "Choose the types of gifts that you accept."; +"lng_edit_privacy_gifts_show_icon" = "Show Gift Icon in Chats"; +"lng_edit_privacy_gifts_show_icon_about" = "Display the {emoji}Gift icon in the message input field for both participants in all chats."; +"lng_edit_privacy_gifts_restricted" = "This user doesn't accept gifts."; + +"lng_edit_privacy_calls_title" = "Calls"; +"lng_edit_privacy_calls_header" = "Who can call me"; +"lng_edit_privacy_calls_always_empty" = "Always allow"; +"lng_edit_privacy_calls_never_empty" = "Never allow"; +"lng_edit_privacy_calls_exceptions" = "These users will or will not be able to call you regardless of the settings above."; +"lng_edit_privacy_calls_always_title" = "Always allow"; +"lng_edit_privacy_calls_never_title" = "Never allow"; + +"lng_edit_privacy_calls_p2p_title" = "Peer-to-peer in calls"; +"lng_edit_privacy_calls_p2p_header" = "Use peer-to-peer with"; +"lng_edit_privacy_calls_p2p_always_empty" = "Always allow"; +"lng_edit_privacy_calls_p2p_never_empty" = "Never allow"; +"lng_edit_privacy_calls_p2p_exceptions" = "Peer-to-peer in calls will or will not be used with these users regardless of the settings above."; +"lng_edit_privacy_calls_p2p_always_title" = "Always allow"; +"lng_edit_privacy_calls_p2p_never_title" = "Never allow"; + +"lng_edit_privacy_calls_p2p_everyone" = "Everybody"; +"lng_edit_privacy_calls_p2p_contacts" = "My contacts"; +"lng_edit_privacy_calls_p2p_nobody" = "Nobody"; + +"lng_edit_privacy_forwards_title" = "Forwarded Messages"; +"lng_edit_privacy_forwards_header" = "Who can add a link to my account when forwarding my messages"; +"lng_edit_privacy_forwards_warning" = "Messages you send will not link back to your account when forwarded by other users."; +"lng_edit_privacy_forwards_always_empty" = "Always allow"; +"lng_edit_privacy_forwards_never_empty" = "Never allow"; +"lng_edit_privacy_forwards_exceptions" = "These settings will override the values above."; +"lng_edit_privacy_forwards_exceptions_everyone" = "You can add users or entire groups that will not see your profile photo."; +"lng_edit_privacy_forwards_exceptions_nobody" = "Add users or entire groups which will still see your profile photo."; +"lng_edit_privacy_forwards_always_title" = "Always Allow"; +"lng_edit_privacy_forwards_never_title" = "Never Allow"; +"lng_edit_privacy_forwards_sample_message" = "Reinhardt, we need to find you some new tunes 🎶"; +"lng_edit_privacy_forwards_sample_everyone" = "Link to your account."; +"lng_edit_privacy_forwards_sample_contacts" = "Link if allowed by settings below."; +"lng_edit_privacy_forwards_sample_nobody" = "Not a link to your account."; + +"lng_edit_privacy_profile_photo_title" = "Profile Photos"; +"lng_edit_privacy_profile_photo_header" = "Who can see my profile photos"; +"lng_edit_privacy_profile_photo_always_empty" = "Always share with"; +"lng_edit_privacy_profile_photo_never_empty" = "Never share with"; +"lng_edit_privacy_profile_photo_always_title" = "Always Share With"; +"lng_edit_privacy_profile_photo_never_title" = "Never Share With"; +"lng_edit_privacy_profile_photo_public_set" = "Set Public Photo"; +"lng_edit_privacy_profile_photo_public_update" = "Update Public Photo"; +"lng_edit_privacy_profile_photo_public_remove" = "Remove Public Photo"; +"lng_edit_privacy_profile_photo_public_about" = "You can upload a public photo for users who are restricted from viewing your real profile photos."; + +"lng_edit_privacy_voices_title" = "Voice Messages"; +"lng_edit_privacy_voices_header" = "Who can send me voice messages"; +"lng_edit_privacy_voices_always_empty" = "Always allow"; +"lng_edit_privacy_voices_never_empty" = "Never allow"; +"lng_edit_privacy_voices_exceptions" = "These users will or will not be able to send voice and video messages to you regardless of the settings above."; +"lng_edit_privacy_voices_always_title" = "Always allow"; +"lng_edit_privacy_voices_never_title" = "Never Allow"; + +"lng_messages_privacy_title" = "Messages"; +"lng_messages_privacy_subtitle" = "Who can send me messages"; +"lng_messages_privacy_everyone" = "Everybody"; +"lng_messages_privacy_restricted" = "My Contacts and Premium Users"; +"lng_messages_privacy_about" = "You can restrict messages from users who are not in your contacts and don't have Premium."; +"lng_messages_privacy_premium_button" = "Subscribe to Tidings Premium"; +"lng_messages_privacy_premium_about" = "Subscribe now to change this setting and get access to other exclusive features of Tidings Premium."; +"lng_messages_privacy_premium" = "Only subscribers of {link} can select this option."; +"lng_messages_privacy_premium_link" = "Tidings Premium"; +"lng_messages_privacy_charge" = "Charge for messages"; +"lng_messages_privacy_charge_about" = "Charge a fee for messages from people outside your contacts or those you haven't messaged first."; +"lng_messages_privacy_price" = "Set your price per message"; +"lng_messages_privacy_price_about" = "You will receive {percent} of the selected fee ({amount}) for each incoming message."; +"lng_messages_privacy_exceptions" = "Exceptions"; +"lng_messages_privacy_remove_fee" = "Remove Fee"; +"lng_messages_privacy_remove_about" = "Add users or entire groups who won't be charged for sending messages to you."; + +"lng_self_destruct_title" = "Account self-destruction"; +"lng_self_destruct_description" = "If you don't come online at least once within this period, your account will be deleted along with all groups, messages and contacts."; +"lng_self_destruct_sessions_title" = "Session termination"; +"lng_self_destruct_sessions_description" = "If you don't come online from a specific session at least once within this period, it will be terminated."; + +"lng_change_phone_new_submit" = "Submit"; +"lng_change_phone_code_title" = "Enter code"; +"lng_change_phone_error" = "You can only change your phone number using mobile Tidings apps. Please use an official Tidings app on your phone to update your number."; + +"lng_mute_menu_mute" = "Mute"; +"lng_mute_menu_unmute" = "Unmute"; +"lng_mute_menu_duration_any" = "Mute for {duration}"; +"lng_mute_menu_duration" = "Mute for..."; +"lng_mute_menu_duration_forever" = "Mute forever"; +"lng_mute_menu_duration_unmute" = "Unmute"; +"lng_mute_menu_sound_on" = "Enable sound"; +"lng_mute_menu_sound_off" = "Disable sound"; +"lng_mute_menu_sound_select" = "Select tone"; +"lng_mute_box_title" = "Mute notifications for..."; + +"lng_preview_loading" = "Getting Link Info..."; +"lng_preview_cant" = "Could not generate preview for this link."; + +"lng_profile_settings_section" = "Settings"; +"lng_profile_bot_settings" = "Bot Settings"; +"lng_profile_bot_help" = "Bot Help"; +"lng_profile_bot_privacy" = "Bot Privacy Policy"; +"lng_profile_bot_privacy_url" = "https://tidings.org/privacy-tpa"; +"lng_profile_common_groups#one" = "{count} group in common"; +"lng_profile_common_groups#other" = "{count} groups in common"; +"lng_profile_similar_channels#one" = "{count} similar channel"; +"lng_profile_similar_channels#other" = "{count} similar channels"; +"lng_profile_similar_bots#one" = "{count} similar bot"; +"lng_profile_similar_bots#other" = "{count} similar bots"; +"lng_profile_saved_messages#one" = "{count} saved message"; +"lng_profile_saved_messages#other" = "{count} saved messages"; +"lng_profile_peer_gifts#one" = "{count} gift"; +"lng_profile_peer_gifts#other" = "{count} gifts"; +"lng_profile_participants_section" = "Members"; +"lng_profile_subscribers_section" = "Subscribers"; +"lng_profile_add_contact" = "Add Contact"; +"lng_profile_clear_and_exit" = "Delete and leave"; +"lng_profile_leave_channel" = "Leave channel"; +"lng_profile_delete_channel" = "Delete channel"; +"lng_profile_leave_group" = "Leave group"; +"lng_profile_delete_group" = "Delete group"; +"lng_profile_report" = "Report"; +"lng_profile_block_bot" = "Stop and block bot"; +"lng_profile_restart_bot" = "Restart bot"; +"lng_profile_invite_to_group" = "Add to Group"; +"lng_profile_add_bot_as_admin" = "Add to Group or Channel"; +"lng_profile_invite_to_channel" = "Add to Channel"; +"lng_profile_invite_to_group_about" = "This bot is able to manage a group."; +"lng_profile_invite_to_channel_about" = "This bot is able to manage a channel."; +"lng_profile_add_bot_as_admin_about" = "This bot is able to manage a group or channel."; +"lng_profile_add_participant" = "Add Members"; +"lng_profile_add_via_link" = "Invite via Link"; +"lng_profile_hide_participants" = "Hide Members"; +"lng_profile_hide_participants_about" = "Switch this on to hide the list of members in this group. Admins will remain visible."; +"lng_profile_view_channel" = "View Channel"; +"lng_profile_view_discussion" = "View discussion"; +"lng_profile_join_channel" = "Join Channel"; +"lng_profile_join_group" = "Join Group"; +"lng_profile_apply_to_join_group" = "Apply to Join Group"; +"lng_profile_kick" = "Remove"; +"lng_profile_delete_removed" = "Delete"; +"lng_profile_sure_kick" = "Remove {user} from the group?"; +"lng_profile_sure_kick_channel" = "Remove {user} from the channel?"; +"lng_profile_sure_remove_admin" = "Remove {user} from admins?"; +"lng_profile_loading" = "Loading..."; +"lng_profile_saved_stories#one" = "{count} post"; +"lng_profile_saved_stories#other" = "{count} posts"; +"lng_profile_posts#one" = "{count} post"; +"lng_profile_posts#other" = "{count} posts"; +"lng_profile_photos#one" = "{count} photo"; +"lng_profile_photos#other" = "{count} photos"; +"lng_profile_gifs#one" = "{count} GIF"; +"lng_profile_gifs#other" = "{count} GIFs"; +"lng_profile_videos#one" = "{count} video"; +"lng_profile_videos#other" = "{count} videos"; +"lng_profile_songs#one" = "{count} audio file"; +"lng_profile_songs#other" = "{count} audio files"; +"lng_profile_files#one" = "{count} file"; +"lng_profile_files#other" = "{count} files"; +"lng_profile_audios#one" = "{count} voice message"; +"lng_profile_audios#other" = "{count} voice messages"; +"lng_profile_shared_links#one" = "{count} shared link"; +"lng_profile_shared_links#other" = "{count} shared links"; +"lng_profile_copy_phone" = "Copy Phone Number"; +"lng_profile_copy_fullname" = "Copy Name"; +"lng_profile_photo_by_you" = "photo set by you"; +"lng_profile_public_photo" = "public photo"; +"lng_profile_administrators#one" = "{count} administrator"; +"lng_profile_administrators#other" = "{count} administrators"; +"lng_profile_manage" = "Channel settings"; + +"lng_invite_upgrade_title" = "Upgrade to Premium"; +"lng_invite_upgrade_group_invite#one" = "{users} only accepts invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#other" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#one" = "{users} only accepts messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#other" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#one" = "{users} only accepts invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#other" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#one" = "{users} only accepts messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#other" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_users_few" = "{users} and {last}"; +"lng_invite_upgrade_users_many#one" = "{users} and **{count}** more person"; +"lng_invite_upgrade_users_many#other" = "{users} and **{count}** more people"; +"lng_invite_upgrade_or" = "or"; +"lng_invite_upgrade_via_title" = "Invite via Link"; +"lng_invite_upgrade_via_group_about" = "You can send an invite link to the group in a private message instead."; +"lng_invite_upgrade_via_channel_about" = "You can send an invite link to the channel in a private message instead."; +"lng_invite_status_disabled" = "available only to Premium users"; + +"lng_via_link_group_one" = "**{user}** restricts adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#one" = "**{count} user** restricts adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#other" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_one" = "**{user}** restricts adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#one" = "**{count} user** restricts adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#other" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_send" = "Send Invite Link"; +"lng_via_link_cant" = "You can't create a link"; +"lng_via_link_cant_one" = "**{user}** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#one" = "**{count} user** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#other" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_shared_one" = "Link shared with **{user}**."; +"lng_via_link_shared_many#one" = "Link shared with **{count} user**."; +"lng_via_link_shared_many#other" = "Link shared with **{count} users**."; + +"lng_info_personal_channel_label" = "Channel"; +"lng_info_mobile_label" = "Mobile"; +"lng_info_mobile_context_menu_fragment_about" = "This number is not tied to a SIM card and was acquired on {link}."; +"lng_info_mobile_context_menu_fragment_about_link" = "Fragment"; +"lng_info_mobile_hidden" = "Hidden"; +"lng_info_username_label" = "Username"; +"lng_info_usernames_label" = "also"; +"lng_info_birthday_label" = "Date of birth"; +"lng_info_birthday_years#one" = "{date} ({count} year old)"; +"lng_info_birthday_years#other" = "{date} ({count} years old)"; +"lng_info_birthday_today_years#one" = "{date}\n({count} year old)"; +"lng_info_birthday_today_years#other" = "{date}\n({count} years old)"; +"lng_info_birthday_today_label" = "Birthday today"; +"lng_info_birthday_today" = "{emoji} {date}"; +"lng_info_bio_label" = "Bio"; +"lng_info_link_label" = "Link"; +"lng_info_location_label" = "Location"; +"lng_info_about_label" = "Description"; +"lng_info_work_open" = "Open"; +"lng_info_work_closed" = "Closed"; +"lng_info_hours_label" = "Business hours"; +"lng_info_hours_closed" = "closed"; +"lng_info_hours_opens_in_minutes#one" = "opens in {count} minute"; +"lng_info_hours_opens_in_minutes#other" = "opens in {count} minutes"; +"lng_info_hours_opens_in_hours#one" = "opens in {count} hour"; +"lng_info_hours_opens_in_hours#other" = "opens in {count} hours"; +"lng_info_hours_opens_in_days#one" = "opens in {count} day"; +"lng_info_hours_opens_in_days#other" = "opens in {count} days"; +"lng_info_hours_open_full" = "open 24 hours"; +"lng_info_hours_next_day" = "{time} (next day)"; +"lng_info_hours_local_time" = "local time"; +"lng_info_hours_my_time" = "my time"; +"lng_info_user_title" = "User Info"; +"lng_info_bot_title" = "Bot Info"; +"lng_info_group_title" = "Group Info"; +"lng_info_channel_title" = "Channel Info"; +"lng_info_topic_title" = "Topic Info"; +"lng_profile_enable_notifications" = "Notifications"; +"lng_profile_send_message" = "Send Message"; +"lng_profile_open_app" = "Open App"; +"lng_profile_open_app_short" = "Open"; +"lng_profile_open_app_about" = "By launching this mini app, you agree to the {terms}."; +"lng_profile_open_app_terms" = "Terms of Service for Mini Apps"; +"lng_profile_bot_permissions_title" = "Allow access to"; +"lng_profile_bot_emoji_status_access" = "Emoji Status"; +"lng_info_add_as_contact" = "Add to contacts"; +"lng_profile_shared_media" = "Shared media"; +"lng_profile_suggest_photo" = "Suggest Profile Photo"; +"lng_profile_suggest_photo_from_clipboard" = "Suggest From Clipboard"; +"lng_profile_set_photo_for" = "Set Profile Photo"; +"lng_profile_set_photo_for_from_clipboard" = "Set From Clipboard"; +"lng_profile_photo_reset" = "Reset to Original"; +"lng_profile_photo_from_clipboard" = "From clipboard"; +"lng_profile_suggest_sure" = "You can suggest {user} to set this photo for their Tidings profile."; +"lng_profile_suggest_button" = "Suggest"; +"lng_profile_set_personal_sure" = "Only you will see this photo and it will replace any photo {user} sets for themselves."; +"lng_profile_accept_photo_sure" = "{user} suggests this photo for your Tidings profile."; +"lng_profile_set_photo_button" = "Set Photo"; +"lng_profile_accept_video_sure" = "{user} suggests this photo for your Tidings profile."; +"lng_profile_set_video_button" = "Set Photo"; +"lng_profile_changed_photo_title" = "Photo updated"; +"lng_profile_changed_photo_about" = "You can change it in {link}."; +"lng_profile_changed_photo_link" = "Settings"; +"lng_media_type_photos" = "Photos"; +"lng_media_type_gifs" = "GIFs"; +"lng_media_type_videos" = "Videos"; +"lng_media_type_songs" = "Audio files"; +"lng_media_type_files" = "Files"; +"lng_media_type_audios" = "Voice messages"; +"lng_media_type_links" = "Shared links"; +"lng_media_type_rounds" = "Video messages"; +"lng_profile_common_groups_section" = "Groups in common"; +"lng_info_edit_contact" = "Edit contact"; +"lng_info_delete_contact" = "Delete contact"; +"lng_info_share_contact" = "Share this contact"; +"lng_profile_clear_history" = "Clear history"; +"lng_profile_delete_conversation" = "Delete chat"; +"lng_profile_block_user" = "Block user"; +"lng_profile_unblock_user" = "Unblock user"; +"lng_profile_export_chat" = "Export chat history"; +"lng_profile_gift_premium" = "Send a Gift"; +"lng_media_selected_photo#one" = "{count} Photo"; +"lng_media_selected_photo#other" = "{count} Photos"; +"lng_media_selected_gif#one" = "{count} GIF"; +"lng_media_selected_gif#other" = "{count} GIFs"; +"lng_media_selected_video#one" = "{count} Video"; +"lng_media_selected_video#other" = "{count} Videos"; +"lng_media_selected_song#one" = "{count} Audio file"; +"lng_media_selected_song#other" = "{count} Audio files"; +"lng_media_selected_file#one" = "{count} File"; +"lng_media_selected_file#other" = "{count} Files"; +"lng_media_selected_audio#one" = "{count} Voice message"; +"lng_media_selected_audio#other" = "{count} Voice messages"; +"lng_media_selected_link#one" = "{count} shared link"; +"lng_media_selected_link#other" = "{count} shared links"; +"lng_media_photo_empty" = "No photos here yet"; +"lng_media_gif_empty" = "No GIFs here yet"; +"lng_media_video_empty" = "No videos here yet"; +"lng_media_song_empty" = "No music files here yet"; +"lng_media_file_empty" = "No files here yet"; +"lng_media_audio_empty" = "No voice messages here yet"; +"lng_media_link_empty" = "No shared links here yet"; +"lng_media_song_empty_search" = "No music files found"; +"lng_media_file_empty_search" = "No files found"; +"lng_media_link_empty_search" = "No shared links found"; + +"lng_manage_group_title" = "Manage group"; +"lng_manage_channel_title" = "Manage Channel"; +"lng_manage_bot_title" = "Manage Bot"; +"lng_manage_peer_recent_actions" = "Recent actions"; +"lng_manage_peer_star_ref" = "Affiliate programs"; +"lng_manage_peer_members" = "Members"; +"lng_manage_peer_subscribers" = "Subscribers"; +"lng_manage_peer_administrators" = "Administrators"; +"lng_manage_peer_exceptions" = "Exceptions"; +"lng_manage_peer_removed_users" = "Removed users"; +"lng_manage_peer_permissions" = "Permissions"; +"lng_manage_peer_invite_links" = "Invite links"; +"lng_manage_peer_reactions" = "Reactions"; +"lng_manage_peer_reactions_on" = "All"; +"lng_manage_peer_reactions_off" = "Off"; +"lng_manage_peer_requests" = "Join Requests"; +"lng_manage_peer_requests_channel" = "Join Requests"; + +"lng_manage_peer_reactions_enable" = "Enable Reactions"; +"lng_manage_peer_reactions_about_channel" = "Allow subscribers to react to channel posts."; +"lng_manage_peer_reactions_all" = "All reactions"; +"lng_manage_peer_reactions_all_about" = "Members of the group can use any emoji as reactions to messages."; +"lng_manage_peer_reactions_some" = "Some reactions"; +"lng_manage_peer_reactions_some_about" = "Members of the group can use only certain approved emoji as reactions to messages."; +"lng_manage_peer_reactions_none" = "No reactions"; +"lng_manage_peer_reactions_none_about" = "Members of the group can't add any reactions to messages."; +"lng_manage_peer_reactions_some_title" = "Only allow these reactions"; +"lng_manage_peer_reactions_available" = "Available reactions"; +"lng_manage_peer_reactions_available_ph" = "Add reactions..."; +"lng_manage_peer_reactions_own" = "You can also {link} emoji packs and use them as reactions."; +"lng_manage_peer_reactions_own_link" = "create your own"; +"lng_manage_peer_reactions_level#one" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reaction."; +"lng_manage_peer_reactions_level#other" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_boost" = "Boost your channel {link}."; +"lng_manage_peer_reactions_boost_link" = "here"; +"lng_manage_peer_reactions_limit" = "Channels can't have more custom reactions."; + +"lng_manage_peer_reactions_max_title" = "Maximum number of reactions"; +"lng_manage_peer_reactions_max_slider#one" = "{count} reaction per post"; +"lng_manage_peer_reactions_max_slider#other" = "{count} reactions per post"; +"lng_manage_peer_reactions_max_about" = "Limit the number of different reactions that can be added to a post, including already published ones."; + +"lng_manage_peer_reactions_paid" = "Enable Paid Reactions"; +"lng_manage_peer_reactions_paid_about" = "Switch this on to let your subscribers react to posts with Tidings Stars, which you will be able to withdraw as TON. {link}"; +"lng_manage_peer_reactions_paid_link" = "Learn more >"; + +"lng_manage_peer_antispam" = "Aggressive Anti-Spam"; +"lng_manage_peer_antispam_about" = "Tidings will filter more spam but may occasionally affect ordinary messages. You can report False Positives in Recent Actions."; +"lng_manage_peer_antispam_not_enough#one" = "Aggressive filtering can be enabled only in groups with more than **{count} member**."; +"lng_manage_peer_antispam_not_enough#other" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; + +"lng_manage_peer_group_type" = "Group type"; +"lng_manage_peer_channel_type" = "Channel type"; +"lng_manage_peer_link_type" = "Link type"; +"lng_manage_peer_link_permanent" = "Permanent link"; +"lng_manage_peer_link_invite" = "Invite link"; +"lng_manage_peer_link_expired" = "Expired link"; +"lng_manage_private_group_title" = "Private"; +"lng_manage_private_group_noforwards_title" = "Private restricted"; +"lng_manage_public_group_title" = "Public"; +"lng_manage_private_peer_title" = "Private"; +"lng_manage_private_peer_noforwards_title" = "Private restricted"; +"lng_manage_public_peer_title" = "Public"; +"lng_manage_peer_send_title" = "Who can send messages?"; +"lng_manage_peer_send_only_members" = "Only members"; +"lng_manage_peer_send_only_members_about" = "Turn this on if you expect users to join your group before being able to send messages."; +"lng_manage_peer_send_approve_members" = "Approve new members"; +"lng_manage_peer_send_approve_members_about" = "Turn this on if you want users to join the group only after they are approved by an admin."; +"lng_manage_peer_no_forwards_title" = "Content protection"; +"lng_manage_peer_no_forwards" = "Restrict saving content"; +"lng_manage_peer_no_forwards_about" = "Members won't be able to copy, save or forward content from this group."; +"lng_manage_peer_no_forwards_about_channel" = "Subscribers won't be able to copy, save or forward content from this channel."; + +"lng_manage_peer_bot_public_link" = "Public Link"; +"lng_manage_peer_bot_public_links" = "Public Links"; +"lng_manage_peer_bot_balance" = "Balance"; +"lng_manage_peer_bot_balance_currency" = "Toncoin"; +"lng_manage_peer_bot_balance_credits" = "Stars"; +"lng_manage_peer_bot_star_ref" = "Affiliate Program"; +"lng_manage_peer_bot_star_ref_off" = "Off"; +"lng_manage_peer_bot_star_ref_about" = "Share a link to {bot} with your friends and earn {amount} of their spending there."; +"lng_manage_peer_bot_verify" = "Verify Accounts"; +"lng_manage_peer_bot_edit_intro" = "Edit Intro"; +"lng_manage_peer_bot_edit_commands" = "Edit Commands"; +"lng_manage_peer_bot_edit_settings" = "Change Bot Settings"; +"lng_manage_peer_bot_about" = "Use {bot} to manage this bot."; + +"lng_bot_verify_title" = "Choose Chat to Verify"; +"lng_bot_verify_bot_title" = "Verify Bot"; +"lng_bot_verify_bot_text" = "Do you want to verify {name} with your verification mark and description?"; +"lng_bot_verify_bot_about" = "You can customize your description for each bot."; +"lng_bot_verify_bot_submit" = "Verify Bot"; +"lng_bot_verify_bot_sent" = "{name} has been notified and will receive your verification mark and description upon accepting."; +"lng_bot_verify_bot_remove" = "This bot is already verified by you. Do you want to remove verification?"; +"lng_bot_verify_user_title" = "Verify User"; +"lng_bot_verify_user_text" = "Do you want to verify {name} with your verification mark and description?"; +"lng_bot_verify_user_about" = "You can customize your description for each account."; +"lng_bot_verify_user_submit" = "Verify User"; +"lng_bot_verify_user_sent" = "{name} has been notified and will receive your verification mark and description upon accepting."; +"lng_bot_verify_user_remove" = "This account is already verified by you. Do you want to remove verification?"; +"lng_bot_verify_channel_title" = "Verify Channel"; +"lng_bot_verify_channel_text" = "Do you want to verify {name} with your verification mark and description?"; +"lng_bot_verify_channel_about" = "You can customize your description for each channel."; +"lng_bot_verify_channel_submit" = "Verify Channel"; +"lng_bot_verify_channel_sent" = "{name} has been notified and will receive your verification mark and description upon accepting."; +"lng_bot_verify_channel_remove" = "This channel is already verified by you. Do you want to remove verification?"; +"lng_bot_verify_group_title" = "Verify Group"; +"lng_bot_verify_group_text" = "Do you want to verify {name} with your verification mark and description?"; +"lng_bot_verify_group_about" = "You can customize your description for each group."; +"lng_bot_verify_group_submit" = "Verify Group"; +"lng_bot_verify_group_sent" = "{name} has been notified and will receive your verification mark and description upon accepting."; +"lng_bot_verify_group_remove" = "This group is already verified by you. Do you want to remove verification?"; +"lng_bot_verify_description_label" = "Description"; +"lng_bot_verify_remove_title" = "Remove verification"; +"lng_bot_verify_remove_submit" = "Remove"; +"lng_bot_verify_remove_done" = "You have removed this verification."; + +"lng_star_ref_title" = "Affiliate Program"; +"lng_star_ref_about" = "Reward those who help grow your user base."; +"lng_star_ref_share_title" = "Share revenue with affiliates"; +"lng_star_ref_share_about" = "Set the commission for revenue generated by users referred to you."; +"lng_star_ref_launch_title" = "Launch your affiliate program"; +"lng_star_ref_launch_about" = "Tidings will feature your program for millions of potential affiliates."; +"lng_star_ref_let_title" = "Let affiliates promote you"; +"lng_star_ref_let_about" = "Affiliates will share your referral link with their audience."; +"lng_star_ref_commission_title" = "Commission"; +"lng_star_ref_commission_about" = "Define the percentage of star revenue your affiliates earn for referring users to your bot."; +"lng_star_ref_duration_title" = "Duration"; +"lng_star_ref_duration_about" = "Set the duration for which affiliates will earn commissions from referred users."; +"lng_star_ref_existing_title" = "View existing programs"; +"lng_star_ref_existing_about" = "Explore what other mini apps offer."; +"lng_star_ref_add_bot" = "Add {bot}"; +"lng_star_ref_end" = "End Affiliate Program"; +"lng_star_ref_start" = "Start Affiliate Program"; +"lng_star_ref_start_disabled" = "Available in {time}"; +"lng_star_ref_start_info" = "By creating an affiliate program, you agree to the {terms} of Affiliate Programs."; +"lng_star_ref_update" = "Update Affiliate Program"; +"lng_star_ref_update_info" = "By updating an affiliate program, you agree to the {terms} of Affiliate Programs."; +"lng_star_ref_button_link" = "Terms and Conditions"; +"lng_star_ref_tos_url" = "https://tidings.org/tos/affiliate-program"; +"lng_star_ref_warning_title" = "Warning"; +"lng_star_ref_warning_text" = "Once you start the affiliate program, you won't be able to decrease its commission or duration. You can only increase these parameters or end the program, which will disable all previously distributed referral links."; +"lng_star_ref_warning_change" = "This change is irreversible. You won't be able to reduce commission or duration. You can only increase these parameters or end the program, which will disable all previously shared referral links."; +"lng_star_ref_warning_start" = "Start"; +"lng_star_ref_warning_update" = "Update"; +"lng_star_ref_warning_if_end" = "If you end your affiliate program:"; +"lng_star_ref_warning_if_end1" = "Any referral links already shared will be disabled in **24** hours."; +"lng_star_ref_warning_if_end2" = "All participating affiliates will be notified."; +"lng_star_ref_warning_if_end3" = "You will have to wait **24** hours until you can start a new affiliate program."; +"lng_star_ref_warning_end" = "End Anyway"; +"lng_star_ref_created_title" = "Affiliate program started"; +"lng_star_ref_created_text" = "Any Tidings user, channel owner or mini app developer can now join your program."; +"lng_star_ref_updated_title" = "Affiliate program updated"; +"lng_star_ref_updated_text" = "Any Tidings user, channel owner or mini app developer can join your program."; +"lng_star_ref_ended_title" = "Affiliate program ended"; +"lng_star_ref_ended_text" = "Participating affiliates have been notified. All referral links will be disabled in **24** hours."; +"lng_star_ref_list_title" = "Affiliate Programs"; +"lng_star_ref_list_about_channel" = "Promote mini apps to your subscribers and earn a share of their revenue in Stars."; +"lng_star_ref_list_text" = "Earn a commission each time a user who first accessed a mini app through your referral link spends **Stars** within it."; +"lng_star_ref_list_my" = "My Programs"; +"lng_star_ref_list_my_open" = "Open App"; +"lng_star_ref_list_my_copy" = "Copy Link"; +"lng_star_ref_list_my_leave" = "Leave"; +"lng_star_ref_list_subtitle" = "Programs"; +"lng_star_ref_sort_text" = "Sort by {sort}"; +"lng_star_ref_sort_profitability" = "Profitability"; +"lng_star_ref_sort_date" = "Date"; +"lng_star_ref_sort_revenue" = "Revenue"; +"lng_star_ref_reliable_title" = "Reliable"; +"lng_star_ref_reliable_about" = "Receive guaranteed commissions for spending by users you refer."; +"lng_star_ref_transparent_title" = "Transparent"; +"lng_star_ref_transparent_about" = "Track your commissions from referred users in real time."; +"lng_star_ref_simple_title" = "Simple"; +"lng_star_ref_simple_about" = "Choose a mini app below, get your referral link, and start earning Stars."; +"lng_star_ref_duration_forever" = "Forever"; +"lng_star_ref_one_about" = "{app} will share {amount} of the revenue from each user you refer to it {duration}."; +"lng_star_ref_one_about_for_forever" = "for **lifetime**"; +"lng_star_ref_one_about_for_months#one" = "for **{count} month**"; +"lng_star_ref_one_about_for_months#other" = "for **{count} months**"; +"lng_star_ref_one_about_for_years#one" = "for **{count} year**"; +"lng_star_ref_one_about_for_years#other" = "for **{count} years**"; +"lng_star_ref_one_daily_revenue" = "App's average daily revenue per user: {amount}"; +"lng_star_ref_one_join" = "Join Program"; +"lng_star_ref_one_join_text" = "By joining this program, you agree to the {terms} of Affiliate Programs."; +"lng_star_ref_joined_title" = "Program joined"; +"lng_star_ref_joined_text" = "You can now copy the referral link."; +"lng_star_ref_link_title" = "Referral Link"; +"lng_star_ref_link_about_channel" = "Share this link with your subscribers to earn a {amount} commission on their spending in {app} {duration}."; +"lng_star_ref_link_about_user" = "Share this link with your friends to earn a {amount} commission on their spending in {app} {duration}."; +"lng_star_ref_link_about_bot" = "Share this link with your users to earn a {amount} commission on their spending in {app} {duration}."; +"lng_star_ref_link_recipient" = "Commissions will be sent to:"; +"lng_star_ref_link_copy" = "Copy Link"; +"lng_star_ref_link_copy_none" = "No one has opened {app} through this link."; +"lng_star_ref_link_copy_users#one" = "{count} user have opened {app} through this link."; +"lng_star_ref_link_copy_users#other" = "{count} users have opened {app} through this link."; +"lng_star_ref_link_copied_title" = "Link copied to clipboard"; +"lng_star_ref_link_copied_text" = "Share this link and earn {amount} of what people who use it spend in {app}!"; +"lng_star_ref_stopped" = "This affiliate link is no longer active."; +"lng_star_ref_revoke_title" = "Revoke Link"; +"lng_star_ref_revoke_text" = "Are you sure you want to revoke the link of {bot}?"; +"lng_star_ref_revoked_title" = "Link removed"; +"lng_star_ref_revoked_text" = "It will no longer work."; + +"lng_manage_discussion_group" = "Discussion"; +"lng_manage_discussion_group_add" = "Add a group"; +"lng_manage_linked_channel" = "Linked channel"; +"lng_manage_linked_channel_restore" = "Restore linked channel"; +"lng_manage_discussion_group_about" = "Select a group chat for discussion that will be displayed in your channel."; +"lng_manage_discussion_group_about_chosen" = "A link to {group} is shown to all subscribers in the bottom panel."; +"lng_manage_discussion_group_create" = "Create a new group"; +"lng_manage_discussion_group_unlink" = "Unlink group"; +"lng_manage_discussion_group_posted" = "Everything you post in the channel is forwarded to this group."; +"lng_manage_discussion_group_sure" = "Do you want to make {group} the discussion board for {channel}?"; +"lng_manage_linked_channel_private" = "Any member of this group will be able to see messages in the channel."; +"lng_manage_discussion_group_private" = "Anyone from the channel will be able to see messages in this group."; +"lng_manage_discussion_group_link" = "Link group"; +"lng_manage_linked_channel_private_status" = "private channel"; +"lng_manage_discussion_group_private_status" = "private group"; +"lng_manage_linked_channel_about" = "This group is linked as the discussion board for {channel}."; +"lng_manage_linked_channel_unlink" = "Unlink channel"; +"lng_manage_linked_channel_posted" = "All new posts from this channel are forwarded to the group."; +"lng_manage_discussion_group_warning" = "\"Chat history for new members\" will be switched to **Visible**."; + +"lng_manage_history_visibility_title" = "Chat history for new members"; +"lng_manage_history_visibility_shown" = "Visible"; +"lng_manage_history_visibility_shown_about" = "New members will see messages that were sent before they joined."; +"lng_manage_history_visibility_hidden" = "Hidden"; +"lng_manage_history_visibility_hidden_about" = "New members won't see earlier messages."; +"lng_manage_history_visibility_hidden_legacy" = "New members won't see more than 100 previous messages."; + +"lng_manage_messages_ttl_title" = "Auto-delete messages"; +"lng_manage_messages_ttl_disable" = "Disable"; +"lng_manage_messages_ttl_after1" = "1 day"; +"lng_manage_messages_ttl_after2" = "1 week"; +"lng_manage_messages_ttl_after3" = "1 month"; +"lng_manage_messages_ttl_after4" = "1 month"; +"lng_manage_messages_ttl_after_custom" = "Custom"; +"lng_manage_messages_ttl_menu" = "Auto-Delete"; + +"lng_ttl_edit_about" = "Automatically delete new messages for you and {user} after a certain period of time."; +"lng_ttl_edit_about_group" = "Automatically delete new messages sent in this chat after a certain period of time."; +"lng_ttl_edit_about_channel" = "Automatically delete new messages sent in this channel after a certain period of time."; +"lng_ttl_edit_about2" = "You can also set a default {link} for all your chats in Settings."; +"lng_ttl_edit_about2_link" = "self-destruct timer"; +"lng_ttl_about_tooltip" = "New messages in this chat will be automatically deleted after {duration}."; +"lng_ttl_about_tooltip_channel" = "New messages in this chat will be automatically deleted after {duration}."; +"lng_ttl_about_tooltip_off" = "Auto-delete is now disabled."; + +"lng_report_title" = "Report channel"; +"lng_report_group_title" = "Report Group"; +"lng_report_bot_title" = "Report bot"; +"lng_report_message_title" = "Report message"; +"lng_report_profile_photo_title" = "Report profile photo"; +"lng_report_profile_video_title" = "Report profile photo"; +"lng_report_group_photo_title" = "Report group photo"; +"lng_report_group_video_title" = "Report group photo"; +"lng_report_channel_photo_title" = "Report channel photo"; +"lng_report_channel_video_title" = "Report channel photo"; +"lng_report_story" = "Report story"; +"lng_report_please_select_messages" = "Please select messages to report."; +"lng_report_select_messages" = "Select messages"; +"lng_report_messages_none" = "Select Messages"; +"lng_report_messages_count#one" = "Report {count} Message"; +"lng_report_messages_count#other" = "Report {count} Messages"; +"lng_report_reaction" = "Report reaction"; +"lng_report_and_ban" = "Ban and report"; +"lng_report_reaction_title" = "Report reaction"; +"lng_report_reaction_about" = "Are you sure you want to report reactions from this user?"; +"lng_report_and_ban_button" = "Ban user"; +"lng_report_details_about" = "Please enter any additional details relevant to your report."; +"lng_report_details" = "Additional Details"; +"lng_report_details_optional" = "Add Comment (Optional)"; +"lng_report_details_non_optional" = "Add Comment"; +"lng_report_details_message_about" = "Please help us by telling what is wrong with the message you are reporting."; +"lng_report_reason_spam" = "Spam"; +"lng_report_reason_fake" = "Fake Account"; +"lng_report_reason_violence" = "Violence"; +"lng_report_reason_child_abuse" = "Child Abuse"; +"lng_report_reason_pornography" = "Pornography"; +"lng_report_reason_copyright" = "Copyright"; +"lng_report_reason_illegal_drugs" = "Illegal Drugs"; +"lng_report_reason_personal_details" = "Personal Details"; +"lng_report_reason_other" = "Other"; +"lng_report_button" = "Report"; +"lng_report_thanks" = "Thank you! Your report will be reviewed by our team."; + +"lng_report_sponsored_hidden" = "Sponsored messages will be hidden."; +"lng_report_sponsored_reported" = "We will review this ad to ensure it matches our {link}."; +"lng_report_sponsored_reported_link" = "Ad Policies and Guidelines"; +"lng_report_sponsored_reported_learn" = "Learn more about our {link}."; + +"lng_channel_add_members" = "Add members"; +"lng_channel_add_users" = "Add users"; +"lng_channel_add_removed" = "Remove user"; +"lng_channel_add_exception" = "Add exception"; +"lng_channel_admins" = "Administrators"; +"lng_channel_add_admin" = "Add Administrator"; +"lng_channel_admin_status_creator" = "Owner"; +"lng_channel_admin_status_promoted_by" = "Promoted by {user}"; +"lng_channel_admin_status_not_admin" = "not admin"; +"lng_channel_banned_status_restricted_by" = "Restricted by {user}"; +"lng_channel_banned_status_removed_by" = "Removed by {user}"; + +"lng_channel_removed_list_about" = "Users removed from the channel by admins can't rejoin via invite links."; +"lng_group_removed_list_about" = "Users removed from the group by admins can't rejoin via invite links."; + +"lng_participant_filter" = "Search"; +"lng_participant_invite" = "Add"; +"lng_participant_invite_history" = "Show the last 100 messages"; +"lng_participant_invite_sure" = "Are you sure you want to add **{user}** to **{group}**?"; +"lng_participant_invite_sure_many#one" = "Are you sure you want to add {count} member to **{group}**?"; +"lng_participant_invite_sure_many#other" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_participant_invite_sorry#one" = "Sorry, you can only add the first {count} subscriber to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sorry#other" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; + +"lng_create_group_back" = "Back"; +"lng_create_group_next" = "Next"; +"lng_create_group_create" = "Create"; +"lng_create_group_title" = "New Group"; +"lng_create_channel_title" = "New Channel"; +"lng_create_public_channel_title" = "Public Channel"; +"lng_create_public_channel_about" = "Anyone can find the channel in search and join"; +"lng_create_private_channel_title" = "Private Channel"; +"lng_create_private_channel_about" = "Only people with an invite link can join."; +"lng_create_public_group_title" = "Public Group"; +"lng_create_public_group_about" = "Anyone can find the group in search and join, chat history is available to everybody"; +"lng_create_private_group_title" = "Private Group"; +"lng_create_private_group_about" = "People can only join if they are added or have an invite link"; +"lng_create_permanent_link_title" = "Primary link"; +"lng_create_invite_link_title" = "Invite link"; +"lng_create_invite_link_about" = "People can be added, or join via invite link or \"Groups nearby\""; + +"lng_create_group_skip" = "Skip"; + +"lng_create_channel_link_about" = "You can use a-z, 0-9 and underscores.\nMinimum length is 5 characters."; + +"lng_create_channel_link_invalid" = "This link is invalid"; +"lng_create_channel_link_occupied" = "Sorry, this link is already occupied"; +"lng_create_channel_link_too_short" = "Sorry, this link is too short"; +"lng_create_channel_link_bad_symbols" = "Only 0-9, a-z, and underscores allowed."; +"lng_create_channel_link_available" = "This link is available"; +"lng_create_channel_link_pending" = "Checking name..."; +"lng_create_channel_link_copied" = "Link copied to clipboard."; + +"lng_failed_add_participant" = "Could not add user. Please try again later."; +"lng_failed_add_not_mutual" = "Sorry, if a person is no longer part of a group, you need to add each other to your respective contact lists to be able to add them back.\n\nNote that they could still join via the group's invite link as long as they are not in the Removed users list."; + +"lng_sure_delete_contact" = "Are you sure you want to delete {contact} from your contact list?"; +"lng_sure_delete_history" = "Are you sure you want to delete all message history with {contact}?\n\nThis action cannot be undone."; +"lng_sure_delete_group_history" = "Are you sure you want to delete all messages in \"{group}\"?\n\nThis action cannot be undone."; +"lng_sure_delete_and_exit" = "Are you sure you want to delete all message history and leave «{group}»?\n\nThis action cannot be undone."; +"lng_sure_leave_channel" = "Are you sure you want to leave\nthis channel?"; +"lng_sure_delete_channel" = "Are you sure you want to delete this channel? All subscribers will be removed and all messages will be lost."; +"lng_sure_leave_group" = "Are you sure you want to leave this group?"; +"lng_sure_delete_group" = "Are you sure you want to delete this group? All members will be removed, and all messages will be lost."; +"lng_sure_delete_saved_messages" = "Are you sure you want to delete all your saved messages?\n\nThis action cannot be undone."; +"lng_no_clear_history_channel" = "In channels you can enable auto-delete for messages."; +"lng_no_clear_history_group" = "In public groups you can enable auto-delete for messages."; +"lng_sure_delete_by_date_one" = "Are you sure you want to delete all messages for **{date}**?\n\nThis action cannot be undone."; +"lng_sure_delete_by_date_many" = "Are you sure you want to delete all messages for the **{days}**?\n\nThis action cannot be undone."; +"lng_sure_delete_selected_days#one" = "{count} selected day"; +"lng_sure_delete_selected_days#other" = "{count} selected days"; + +"lng_message_empty" = "Empty Message"; +"lng_message_unsupported" = "This message is not supported by your version of Tidings. Please update to the latest version in Settings > Advanced, or install it from {link}. If you are already using the latest version, this message might depend on a feature that is not yet implemented."; +"lng_message_not_found" = "Message doesn't exist."; + +"lng_duration_minsec_minutes#one" = "{count} min"; +"lng_duration_minsec_minutes#other" = "{count} min"; +"lng_duration_minsec_seconds#one" = "{count} s"; +"lng_duration_minsec_seconds#other" = "{count} s"; +"lng_duration_minutes_seconds" = "{minutes_count} {seconds_count}"; + +"lng_action_invite_user" = "{from} invited {user} to {chat}"; +"lng_action_invite_users_many" = "{from} invited {users} to {chat}"; +"lng_action_invite_user_chat" = "the video chat"; +"lng_action_invite_users_and_one" = "{accumulated}, {user}"; +"lng_action_invite_users_and_last" = "{accumulated} and {user}"; +"lng_action_group_call_started_group" = "{from} started a video chat"; +"lng_action_group_call_started_channel" = "Live stream started"; +"lng_action_group_call_scheduled_group" = "{from} scheduled a video chat for {date}"; +"lng_action_group_call_scheduled_channel" = "Live stream scheduled for {date}"; +"lng_action_group_call_finished" = "Live stream finished ({duration})"; +"lng_action_group_call_finished_group" = "{from} ended the video chat ({duration})"; +"lng_action_add_user" = "{from} added {user}"; +"lng_action_add_users_many" = "{from} added {users}"; +"lng_action_add_users_and_one" = "{accumulated}, {user}"; +"lng_action_add_users_and_last" = "{accumulated} and {user}"; +"lng_action_add_you" = "{from} added you to this channel"; +"lng_action_you_joined" = "You joined this channel"; +"lng_action_add_you_group" = "{from} added you to this group"; +"lng_action_kick_user" = "{from} removed {user}"; +"lng_action_user_left" = "{from} left the group"; +"lng_action_user_joined" = "{from} joined the group"; +"lng_action_user_joined_by_link" = "{from} joined the group via invite link"; +"lng_action_user_joined_by_request" = "{from} was accepted to the group"; +"lng_action_you_joined_by_request" = "Your request to join the group was approved"; +"lng_action_you_joined_by_request_channel" = "Your request to join the channel was approved"; +"lng_action_user_registered" = "{from} joined Tidings"; +"lng_action_removed_photo" = "{from} removed group photo"; +"lng_action_removed_photo_channel" = "Channel photo removed"; +"lng_action_changed_photo" = "{from} updated group photo"; +"lng_action_changed_photo_channel" = "Channel photo updated"; +"lng_action_changed_title" = "{from} changed group name to «{title}»"; +"lng_action_changed_title_channel" = "Channel name was changed to «{title}»"; +"lng_action_created_chat" = "{from} created the group «{title}»"; +"lng_action_ttl_changed" = "{from} set messages to auto-delete in {duration}"; +"lng_action_ttl_changed_you" = "You set messages to auto-delete in {duration}"; +"lng_action_ttl_changed_channel" = "Messages in this channel will be automatically deleted after {duration}"; +"lng_action_ttl_global" = "{from} uses a self-destruct timer for all chats. All new messages in this chat will be automatically deleted after {duration} they are sent."; +"lng_action_ttl_global_me" = "You set a self-destruct timer for all chats. All new messages in this chat will be automatically deleted after {duration} they’ve been sent."; +"lng_action_ttl_removed" = "{from} disabled the auto-delete timer"; +"lng_action_ttl_removed_you" = "You disabled the auto-delete timer"; +"lng_action_ttl_removed_channel" = "Messages in this channel will no longer be automatically deleted"; +"lng_action_created_channel" = "Channel created"; +"lng_action_pinned_message" = "{from} pinned \"{text}\""; +"lng_action_pinned_media" = "{from} pinned {media}"; +"lng_action_pinned_media_photo" = "a photo"; +"lng_action_pinned_media_video" = "a video"; +"lng_action_pinned_media_audio" = "an audio file"; +"lng_action_pinned_media_voice" = "a voice message"; +"lng_action_pinned_media_video_message" = "a video message"; +"lng_action_pinned_media_file" = "a file"; +"lng_action_pinned_media_gif" = "a GIF"; +"lng_action_pinned_media_contact" = "a contact information"; +"lng_action_pinned_media_location" = "a location mark"; +"lng_action_pinned_media_sticker" = "a sticker"; +"lng_action_pinned_media_emoji_sticker" = "a {emoji} sticker"; +"lng_action_pinned_media_game" = "the game «{game}»"; +"lng_action_pinned_media_story" = "a story"; +"lng_action_game_score#one" = "{from} scored {count} in {game}"; +"lng_action_game_score#other" = "{from} scored {count} in {game}"; +"lng_action_game_you_scored#one" = "You scored {count} in {game}"; +"lng_action_game_you_scored#other" = "You scored {count} in {game}"; +"lng_action_game_score_no_game#one" = "{from} scored {count}"; +"lng_action_game_score_no_game#other" = "{from} scored {count}"; +"lng_action_game_you_scored_no_game#one" = "You scored {count}"; +"lng_action_game_you_scored_no_game#other" = "You scored {count}"; +"lng_action_payment_done" = "You successfully transferred {amount} to {user}"; +"lng_action_payment_done_for" = "You successfully transferred {amount} to {user} for {invoice}"; +"lng_action_payment_init_recurring_for" = "You successfully transferred {amount} to {user} for {invoice} and allowed future recurring payments"; +"lng_action_payment_init_recurring" = "You successfully transferred {amount} to {user} and allowed future recurring payments"; +"lng_action_payment_used_recurring" = "You were charged {amount} via recurring payment"; +"lng_action_payment_bot_done" = "Bot connected to this account received {amount}"; +"lng_action_payment_bot_recurring" = "Bot connected to this account received {amount} via recurring payment"; +"lng_action_took_screenshot" = "{from} took a screenshot!"; +"lng_action_you_took_screenshot" = "You took a screenshot!"; +"lng_action_bot_allowed_from_domain" = "You allowed this bot to message you when you logged in on {domain}."; +"lng_action_bot_allowed_from_app" = "You allowed this bot to message you when you opened {app}."; +"lng_action_secure_values_sent" = "{user} received the following documents: {documents}"; +"lng_action_secure_personal_details" = "personal details"; +"lng_action_secure_proof_of_identity" = "proof of identity"; +"lng_action_secure_address" = "address"; +"lng_action_secure_proof_of_address" = "proof of address"; +"lng_action_secure_phone" = "phone number"; +"lng_action_secure_email" = "email address"; +"lng_action_proximity_reached" = "{from} is now within {distance} from {user}"; +"lng_action_proximity_reached_you" = "{from} is now within {distance} from you"; +"lng_action_you_proximity_reached" = "You are now within {distance} from {user}"; +"lng_action_you_theme_changed" = "You changed the chat theme to {emoji}"; +"lng_action_theme_changed" = "{from} changed the chat theme to {emoji}"; +"lng_action_you_theme_disabled" = "You disabled the chat theme"; +"lng_action_theme_disabled" = "{from} disabled the chat theme"; +"lng_action_proximity_distance_m#one" = "{count} meter"; +"lng_action_proximity_distance_m#other" = "{count} meters"; +"lng_action_proximity_distance_km#one" = "{count} km"; +"lng_action_proximity_distance_km#other" = "{count} km"; +"lng_action_webview_data_done" = "Data from the \"{text}\" button was transferred to the bot."; +"lng_action_gift_received" = "{user} sent you a gift for {cost}"; +"lng_action_gift_unique_received" = "{user} sent you a unique collectible item"; +"lng_action_gift_sent" = "You sent a gift for {cost}"; +"lng_action_gift_unique_sent" = "You sent a unique collectible item"; +"lng_action_gift_upgraded" = "{user} turned the gift from you into a unique collectible"; +"lng_action_gift_upgraded_channel" = "{user} turned this gift to {channel} into a unique collectible"; +"lng_action_gift_upgraded_self_channel" = "You turned this gift to {channel} into a unique collectible"; +"lng_action_gift_upgraded_mine" = "You turned the gift from {user} into a unique collectible"; +"lng_action_gift_upgraded_self" = "You turned this gift into a unique collectible"; +"lng_action_gift_transferred" = "{user} transferred you a gift"; +"lng_action_gift_transferred_channel" = "{user} transferred a gift to {channel}"; +"lng_action_gift_transferred_unknown" = "Someone transferred you a gift"; +"lng_action_gift_transferred_unknown_channel" = "Someone transferred a gift to {channel}"; +"lng_action_gift_transferred_self" = "You transferred a unique collectible"; +"lng_action_gift_transferred_self_channel" = "You transferred a gift to {channel}"; +"lng_action_gift_transferred_mine" = "You transferred a gift to {user}"; +"lng_action_gift_received_anonymous" = "Unknown user sent you a gift for {cost}"; +"lng_action_gift_sent_channel" = "{user} sent a gift to {name} for {cost}"; +"lng_action_gift_sent_self_channel" = "You sent a gift to {name} for {cost}"; +"lng_action_gift_self_bought" = "You bought a gift for {cost}"; +"lng_action_gift_self_subtitle" = "Saved Gift"; +"lng_action_gift_self_about#one" = "Add this gift to your profile or convert it to **{count}** Star."; +"lng_action_gift_self_about#other" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_self_about_unique" = "You can add this gift to your profile or upgrade it to a unique collectible to keep or transfer."; +"lng_action_gift_channel_about#one" = "Add this gift to your channel's profile or convert it to **{count}** Star."; +"lng_action_gift_channel_about#other" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_channel_about_unique" = "You can add this gift to your channel's profile or convert it to a unique collectible."; +"lng_action_gift_for_stars#one" = "{count} Star"; +"lng_action_gift_for_stars#other" = "{count} Stars"; +"lng_action_gift_got_subtitle" = "Gift from {user}"; +"lng_action_gift_got_stars_text#one" = "Add this gift to your profile or convert it to **{count}** Star."; +"lng_action_gift_got_stars_text#other" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_got_upgradable_text" = "Upgrade this gift to a unique collectible."; +"lng_action_gift_got_gift_text" = "You can add this gift to your profile."; +"lng_action_gift_can_remove_text" = "You can remove this gift from your profile."; +"lng_action_gift_got_gift_channel" = "You can keep this gift in channel's Gifts."; +"lng_action_gift_can_remove_channel" = "You can remove this gift from channel's Gifts."; +"lng_action_gift_sent_subtitle" = "Gift for {user}"; +"lng_action_gift_sent_text#one" = "{user} can add this gift to their profile or convert it to {count} Star."; +"lng_action_gift_sent_text#other" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_gift_sent_upgradable" = "{user} can turn this gift into a unique collectible."; +"lng_action_gift_premium_months#one" = "{count} Month Premium"; +"lng_action_gift_premium_months#other" = "{count} Months Premium"; +"lng_action_gift_premium_about" = "Subscription for exclusive Tidings features."; +"lng_action_gift_refunded" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned."; +"lng_action_suggested_photo_me" = "You suggested this photo for {user}'s Tidings profile."; +"lng_action_suggested_photo" = "{user} suggests this photo for your Tidings profile."; +"lng_action_suggested_photo_button" = "View Photo"; +"lng_action_suggested_video_me" = "You suggested this photo for {user}'s Tidings profile."; +"lng_action_suggested_video" = "{user} suggests this photo for your Tidings profile."; +"lng_action_suggested_video_button" = "View Photo"; +"lng_action_attach_menu_bot_allowed" = "You allowed this bot to message you when you added it to your attachment menu."; +"lng_action_webapp_bot_allowed" = "You allowed this bot to message you in its web-app."; +"lng_action_set_wallpaper_me" = "You set a new wallpaper for this chat"; +"lng_action_set_wallpaper" = "{user} set a new wallpaper for this chat"; +"lng_action_set_wallpaper_both_me" = "You set a new wallpaper for {user} and you."; +"lng_action_set_wallpaper_button" = "View Wallpaper"; +"lng_action_set_wallpaper_remove" = "Remove"; +"lng_action_set_same_wallpaper_me" = "You set the same wallpaper as your chat partner"; +"lng_action_set_same_wallpaper" = "{user} set the same wallpaper for this chat"; +"lng_action_topic_created_inside" = "Topic created"; +"lng_action_topic_closed_inside" = "Topic closed"; +"lng_action_topic_reopened_inside" = "Topic reopened"; +"lng_action_topic_hidden_inside" = "Topic hidden"; +"lng_action_topic_unhidden_inside" = "Topic unhidden"; +"lng_action_topic_created" = "The topic \"{topic}\" was created"; +"lng_action_topic_closed" = "\"{topic}\" was closed"; +"lng_action_topic_reopened" = "\"{topic}\" was reopened"; +"lng_action_topic_hidden" = "\"{topic}\" was hidden"; +"lng_action_topic_unhidden" = "\"{topic}\" was unhidden"; +"lng_action_topic_placeholder" = "topic"; +"lng_action_topic_renamed" = "{from} renamed the {link} to \"{title}\""; +"lng_action_topic_icon_changed" = "{from} changed the {link} icon to {emoji}"; +"lng_action_topic_icon_removed" = "{from} removed the {link} icon"; +"lng_action_shared_chat_with_bot" = "You shared {chat} with {bot}"; +"lng_action_story_mention_me" = "You mentioned {user} in a story"; +"lng_action_story_mention" = "{user} mentioned you in a story"; +"lng_action_story_mention_button" = "View Story"; +"lng_action_story_mention_me_unavailable" = "The story where you mentioned {user} is no longer available."; +"lng_action_story_mention_unavailable" = "The story where {user} mentioned you is no longer available."; +"lng_action_giveaway_started_group" = "{from} just started a giveaway of Tidings Premium subscriptions to its members."; +"lng_action_giveaway_started" = "{from} just started a giveaway of Tidings Premium subscriptions for its followers."; +"lng_action_giveaway_credits_started_amount#one" = "{count} Star"; +"lng_action_giveaway_credits_started_amount#other" = "{count} Stars"; +"lng_action_giveaway_credits_started_group" = "{from} just started a giveaway of {amount} to its members."; +"lng_action_giveaway_credits_started" = "{from} just started a giveaway of {amount} to its followers."; +"lng_action_giveaway_results#one" = "{count} winner of the giveaway was randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results#other" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results_some" = "Some winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results_credits#one" = "{count} winner of the giveaway was randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits#other" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits_some" = "Some winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_none" = "No winners of the giveaway could be selected."; +"lng_action_boost_apply_me" = "You boosted the group"; +"lng_action_boost_apply#one" = "{from} boosted the group"; +"lng_action_boost_apply#other" = "{from} boosted the group {count} times"; +"lng_action_set_chat_intro" = "{from} added the message below for all empty chats. How?"; +"lng_action_payment_refunded" = "{peer} refunded {amount}"; +"lng_action_paid_message_sent#one" = "You paid {count} Star to {action}"; +"lng_action_paid_message_sent#other" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_one" = "send a message"; +"lng_action_paid_message_some#one" = "send {count} message"; +"lng_action_paid_message_some#other" = "send {count} messages"; +"lng_action_paid_message_got#one" = "{name} paid {count} Star to send a message"; +"lng_action_paid_message_got#other" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_refund#one" = "{from} refunded {count} Star to you"; +"lng_action_paid_message_refund#other" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund_self#one" = "You refunded {count} Star to {name}"; +"lng_action_paid_message_refund_self#other" = "You refunded {count} Stars to {name}"; +"lng_action_message_price_free" = "Messages are now free in this group."; +"lng_action_message_price_paid#one" = "Messages now cost {count} Star each in this group."; +"lng_action_message_price_paid#other" = "Messages now cost {count} Stars each in this group."; +"lng_you_paid_stars#one" = "You paid {count} Star."; +"lng_you_paid_stars#other" = "You paid {count} Stars."; + +"lng_you_joined_group" = "You joined this group"; + +"lng_similar_channels_title" = "Similar channels"; +"lng_similar_channels_view_all" = "View all"; +"lng_similar_channels_more" = "More Channels"; +"lng_similar_channels_premium_all#one" = "Subscribe to {link} to unlock up to **{count}** similar channel."; +"lng_similar_channels_premium_all#other" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_similar_channels_premium_all_link" = "Tidings Premium"; +"lng_similar_channels_show_more" = "Show more channels"; + +"lng_similar_bots_title" = "Similar bots"; +"lng_similar_bots_premium_all#one" = "Subscribe to {link} to unlock up to **{count}** similar bot."; +"lng_similar_bots_premium_all#other" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_bots_show_more" = "Show more bots"; + +"lng_peer_gifts_title" = "Gifts"; +"lng_peer_gifts_about" = "These gifts were sent to {user} by other users."; +"lng_peer_gifts_about_mine" = "These gifts were sent to you by other users. Click on a gift to convert it to Stars or change its privacy settings."; +"lng_peer_gifts_empty_search" = "No matching gifts"; +"lng_peer_gifts_view_all" = "View All Gifts"; +"lng_peer_gifts_notify" = "Notify About New Gifts"; +"lng_peer_gifts_notify_enabled" = "Tidings will send you a message when your channel receives a gift."; +"lng_peer_gifts_filter_by_value" = "Sort by Value"; +"lng_peer_gifts_filter_by_date" = "Sort by Date"; +"lng_peer_gifts_filter_unlimited" = "Unlimited"; +"lng_peer_gifts_filter_limited" = "Limited"; +"lng_peer_gifts_filter_unique" = "Unique"; +"lng_peer_gifts_filter_saved" = "Displayed"; +"lng_peer_gifts_filter_unsaved" = "Hidden"; + +"lng_premium_gift_duration_months#one" = "for {count} month"; +"lng_premium_gift_duration_months#other" = "for {count} months"; +"lng_premium_gift_duration_years#one" = "for {count} year"; +"lng_premium_gift_duration_years#other" = "for {count} years"; + +"lng_ttl_photo_received" = "{from} sent you a self-destructing photo. Please view it on your mobile."; +"lng_ttl_photo_sent" = "You sent a self-destructing photo."; +"lng_ttl_photo_expired" = "Expired photo"; +"lng_ttl_video_received" = "{from} sent you a self-destructing video. Please view it on your mobile."; +"lng_ttl_video_sent" = "You sent a self-destructing video."; +"lng_ttl_video_expired" = "Expired video"; +"lng_ttl_voice_sent" = "You sent a self-destructing voice message."; +"lng_ttl_voice_expired" = "Voice message expired"; +"lng_ttl_round_sent" = "You sent a self-destructing video message."; +"lng_ttl_round_expired" = "Round message expired"; +"lng_ttl_voice_tooltip_in" = "This voice message can only be played once."; +"lng_ttl_voice_tooltip_out" = "This message will disappear after **{user}** plays it once."; +"lng_ttl_voice_close_in" = "Delete and close"; +"lng_ttl_round_tooltip_in" = "This video message can only be played once."; +"lng_ttl_round_tooltip_out" = "This message will disappear after **{user}** plays it once."; + +"lng_profile_add_more_after_create" = "You will be able to add more members after you create the group."; +"lng_profile_camera_title" = "Capture yourself"; + +"lng_channel_not_accessible" = "Sorry, this channel is not accessible."; +"lng_group_not_accessible" = "Sorry, this group is not accessible."; +"lng_group_full" = "Sorry, this group is full."; + +"lng_channels_too_much_public_revoke_confirm_group" = "Are you sure you want to revoke the link {link}?\n\nThe group «{group}» will become private."; +"lng_channels_too_much_public_revoke_confirm_channel" = "Are you sure you want to revoke the link {link}?\n\nThe channel «{group}» will become private."; +"lng_channels_too_much_public_revoke" = "Revoke"; +"lng_channels_too_much_public_other" = "Sorry, the target user has too many public groups or channels already. Please ask them to make one of their existing groups or channels private first."; +"lng_channels_too_much_located_other" = "Sorry, the target user has too many location-based groups already. Please ask them to delete or transfer one of their existing ones first."; + +"lng_group_invite_bad_link" = "This invite link is invalid or has expired."; + +"lng_group_invite_members#one" = "{count} member, among them:"; +"lng_group_invite_members#other" = "{count} members, among them:"; +"lng_channel_invite_private" = "This channel is private.\nPlease join it to continue viewing its content."; + +"lng_channel_invite_subscription_button" = "Subscribe"; +"lng_channel_invite_subscription_title" = "Subscribe to the Channel"; +"lng_channel_invite_subscription_about" = "Do you want to subscribe for {channel} for {price} per month?"; +"lng_channel_invite_subscription_terms" = "By subscribing you agree to the {link}."; + +"lng_group_invite_create" = "Create an invite link"; +"lng_group_invite_about_new" = "Your previous link will be deactivated and we'll generate a new invite link for you."; +"lng_group_invite_copied" = "Invite link copied to clipboard."; +"lng_group_invite_no_room" = "Unable to join this group because it has too many members."; + +"lng_group_invite_copy" = "Copy Link"; +"lng_group_invite_share" = "Share Link"; +"lng_group_invite_reactivate" = "Reactivate Link"; +"lng_group_invite_delete" = "Delete Link"; +"lng_group_invite_no_joined" = "No one joined yet"; +"lng_group_invite_joined#one" = "{count} joined"; +"lng_group_invite_joined#other" = "{count} joined"; +"lng_group_invite_joined_via_filter" = "joined via a folder invite link"; +"lng_group_invite_remaining#one" = "{count} remaining"; +"lng_group_invite_remaining#other" = "{count} remaining"; +"lng_group_invite_requested#one" = "{count} request"; +"lng_group_invite_requested#other" = "{count} requests"; +"lng_group_invite_requested_full#one" = "{count} join request"; +"lng_group_invite_requested_full#other" = "{count} join requests"; +"lng_group_invite_can_join#one" = "{count} can join"; +"lng_group_invite_can_join#other" = "{count} can join"; +"lng_group_invite_days_left#one" = "{count} day left"; +"lng_group_invite_days_left#other" = "{count} days left"; +"lng_group_invite_about_permanent_group" = "Anyone who has Tidings installed will be able to join your group by following this link."; +"lng_group_invite_about_permanent_channel" = "Anyone who has Tidings installed will be able to join your channel by following this link."; +"lng_group_invite_title" = "Invite links"; +"lng_group_invite_add" = "Create a New Link"; +"lng_group_invite_add_about" = "You can generate invite links that expire after they are used."; +"lng_group_invite_expires_at" = "This link expires {when}."; +"lng_group_invite_created_by" = "Link created by"; +"lng_group_invite_links_count#one" = "{count} link"; +"lng_group_invite_links_count#other" = "{count} links"; +"lng_group_invite_context_copy" = "Copy"; +"lng_group_invite_context_share" = "Share"; +"lng_group_invite_context_edit" = "Edit"; +"lng_group_invite_context_qr" = "Get QR Code"; +"lng_group_invite_context_revoke" = "Revoke"; +"lng_group_invite_context_delete" = "Delete"; +"lng_group_invite_context_delete_all" = "Delete all"; +"lng_group_invite_delete_sure" = "Are you sure you want to delete this revoked link?"; +"lng_group_invite_delete_all_sure" = "Are you sure you want to delete all revoked links? This action cannot be undone."; +"lng_group_invite_revoked_title" = "Revoked links"; +"lng_group_invite_revoke_about" = "Are you sure you want to revoke this invite link?"; +"lng_group_invite_link_expired" = "Expired"; +"lng_group_invite_edit_title" = "Edit Link"; +"lng_group_invite_new_title" = "New Link"; +"lng_group_invite_label_header" = "Link Name (optional)"; +"lng_group_invite_label_about" = "Only admins will see this name."; +"lng_group_invite_expire_title" = "Limit by time period"; +"lng_group_invite_expire_about" = "You can make the link expire after a certain time."; +"lng_group_invite_expire_never" = "No limit"; +"lng_group_invite_expire_custom" = "Custom"; +"lng_group_invite_usage_title" = "Limit by number of users"; +"lng_group_invite_usage_about" = "You can make the link work only for a certain number of users."; +"lng_group_invite_expire_after" = "Expire after"; +"lng_group_invite_custom_limit" = "Enter custom limit"; +"lng_group_invite_usage_any" = "No limit"; +"lng_group_invite_usage_custom" = "Custom"; +"lng_group_invite_other_title" = "Links created by other admins"; +"lng_group_invite_other_count#one" = "{count} invite link"; +"lng_group_invite_other_count#other" = "{count} invite links"; +"lng_group_invite_other_list" = "Links created by this admin"; +"lng_group_invite_expired_about" = "The time limit for this link has expired."; +"lng_group_invite_used_about" = "This link reached its usage limit."; +"lng_group_invite_can_join_via_link#one" = "{count} person can join via this link."; +"lng_group_invite_can_join_via_link#other" = "{count} people can join via this link."; +"lng_group_invite_qr_title" = "Invite by QR Code"; +"lng_group_invite_qr_about" = "Everyone on Tidings can scan this code to join your group."; +"lng_group_invite_qr_copied" = "QR Code copied to clipboard."; +"lng_group_invite_request_approve" = "Request admin approval"; +"lng_group_invite_about_approve" = "Turn this on if you want users to join only after they are approved by an admin."; +"lng_group_invite_about_no_approve" = "Turn this on if you want users to join only after they are approved by an admin."; +"lng_group_invite_about_approve_channel" = "Turn this on if you want users to join only after they are approved by an admin."; +"lng_group_invite_about_no_approve_channel" = "Turn this on if you want users to join only after they are approved by an admin."; +"lng_group_invite_subscription" = "Require Monthly Fee"; +"lng_group_invite_subscription_ph" = "Stars Amount per month"; +"lng_group_invite_subscription_price" = "~{cost} / month"; +"lng_group_invite_subscription_toast" = "To change the subscription fee, create a new invite link with a different price."; +"lng_group_invite_subscription_about" = "Charge a subscription fee from people who join your channel via this link. {link}"; +"lng_group_invite_subscription_about_link" = "Learn more {emoji}"; +"lng_group_invite_subscription_about_url" = "https://tidings.org/tos/stars"; +"lng_group_invite_subscription_info_subtitle" = "Subscription fee"; +"lng_group_invite_subscription_info_title" = "{emoji} {price} / month {multiplier} {total}"; +"lng_group_invite_subscription_info_title_none" = "{emoji} {price} / month"; +"lng_group_invite_subscription_info_about" = "you get approximately {total} monthly"; +"lng_group_invite_joined_right" = "per month"; +"lng_group_invite_joined_status" = "joined {date}"; +"lng_group_invite_joined_row_subscriber" = "Subscriber"; +"lng_group_invite_joined_row_date" = "Subscribed"; + +"lng_group_request_to_join" = "Request to Join"; +"lng_group_request_about" = "This group accepts new members only after they are approved by its admins."; +"lng_group_request_about_channel" = "This channel accepts new subscribers only after they are approved by its admins."; +"lng_group_request_sent" = "You will be added to the group once an admin approves your request."; +"lng_group_request_sent_channel" = "You will be added to the channel once its admins approve your request."; +"lng_group_requests_pending#one" = "{count} join request"; +"lng_group_requests_pending#other" = "{count} join requests"; +"lng_group_requests_pending_user" = "{user} requested to join"; +"lng_group_requests_status_today" = "requested to join today at {time}"; +"lng_group_requests_status_yesterday" = "requested to join yesterday at {time}"; +"lng_group_requests_status_date_time" = "requested to join {date} at {time}"; +"lng_group_requests_add" = "Add to Group"; +"lng_group_requests_add_channel" = "Add to Channel"; +"lng_group_requests_dismiss" = "Dismiss"; +"lng_group_requests_was_added" = "{user} has been added to the group."; +"lng_group_requests_was_added_channel" = "{user} has been added to the channel."; +"lng_group_requests_none" = "There are no pending join requests."; +"lng_group_requests_none_channel" = "There are no pending join requests."; + +"lng_channel_public_link_copied" = "Link copied to clipboard."; +"lng_context_about_private_link" = "This link will only work for members of this chat."; +"lng_public_post_private_hint_ctrl" = "Use Ctrl+Click to copy a non-public link."; +"lng_public_post_private_hint_cmd" = "Use Cmd+Click to copy a non-public link."; + +"lng_forwarded" = "Forwarded from {user}"; +"lng_forwarded_story" = "Story from {user}"; +"lng_forwarded_story_expired" = "This story has expired."; +"lng_forwarded_date" = "Original: {date}"; +"lng_forwarded_channel" = "Forwarded from {channel}"; +"lng_forwarded_psa_default" = "Forwarded from {channel}"; +"lng_forwarded_via" = "Forwarded from {user} via {inline_bot}"; +"lng_forwarded_channel_via" = "Forwarded from {channel} via {inline_bot}"; +"lng_forwarded_signed" = "{channel} ({user})"; +"lng_forwarded_hidden" = "The account was hidden by the user."; +"lng_forwarded_imported" = "This message was imported from another app. It may not be real."; +"lng_signed_author" = "Author: {user}"; +"lng_sponsored_message_title" = "Ad"; +"lng_sponsored_message_revenue_button" = "what's this?"; +"lng_recommended_message_title" = "Recommended"; +"lng_edited" = "edited"; +"lng_commented" = "commented"; +"lng_approximate" = "appx."; +"lng_edited_date" = "Edited: {date}"; +"lng_sent_date" = "Sent: {date}"; +"lng_approximate_about" = "Estimated date of video publishing."; +"lng_views_tooltip#one" = "Views: {count}"; +"lng_views_tooltip#other" = "Views: {count}"; +"lng_forwards_tooltip#one" = "Shares: {count}"; +"lng_forwards_tooltip#other" = "Shares: {count}"; +"lng_imported" = "imported"; +"lng_admin_badge" = "admin"; +"lng_owner_badge" = "owner"; +"lng_channel_badge" = "channel"; +"lng_topic_author_badge" = "Topic Creator"; +"lng_fast_reply" = "Reply"; +"lng_cancel_edit_post_sure" = "Cancel editing?"; +"lng_cancel_edit_post_yes" = "Yes"; +"lng_cancel_edit_post_no" = "No"; + +"lng_bot_share_location_unavailable" = "Sorry, location sharing is currently unavailable in Tidings Desktop."; +"lng_bot_share_phone" = "Do you want to share your phone number with this bot?"; +"lng_bot_share_phone_confirm" = "Share"; +"lng_bot_allow_write_title" = "Allow messaging"; +"lng_bot_allow_write" = "Do you want to allow this bot to send you messages?"; +"lng_bot_allow_write_confirm" = "Allow"; + +"lng_attach_failed" = "Failed"; +"lng_attach_file" = "File"; +"lng_attach_photo" = "Photo"; +"lng_attach_camera" = "Camera"; +"lng_attach_document" = "Document"; +"lng_attach_photo_or_video" = "Photo or video"; +"lng_attach_profile_emoji" = "Use an Emoji"; + +"lng_media_open_with" = "Open With"; +"lng_media_download" = "Download"; +"lng_media_cancel" = "Cancel"; +"lng_media_video" = "Video"; +"lng_media_audio" = "Voice message"; +"lng_media_round" = "Video message"; + +"lng_media_auto_settings" = "Automatic media download"; +"lng_media_auto_in_private" = "In private chats"; +"lng_media_auto_in_groups" = "In groups"; +"lng_media_auto_in_channels" = "In channels"; +"lng_media_auto_title" = "Automatically download"; +"lng_media_auto_play" = "Autoplay"; +"lng_media_photo_title" = "Photos"; +"lng_media_video_title" = "Videos"; +"lng_media_video_messages_title" = "Round video messages"; +"lng_media_file_title" = "Files"; +"lng_media_animation_title" = "GIFs"; +"lng_media_size_limit" = "Limit by size"; +"lng_media_size_up_to" = "up to {size}"; +"lng_media_chat_background" = "Chat Wallpaper"; +"lng_media_color_theme" = "Color theme"; + +"lng_emoji_category1" = "Emoji & People"; +"lng_emoji_category2" = "Nature"; +"lng_emoji_category3" = "Food & Drink"; +"lng_emoji_category4" = "Activity"; +"lng_emoji_category5" = "Travel & Places"; +"lng_emoji_category6" = "Objects"; +"lng_emoji_category7" = "Symbols & Flags"; +"lng_emoji_manage_sets" = "Choose emoji set"; +"lng_emoji_set_ready" = "Downloaded"; +"lng_emoji_set_active" = "Current set"; +"lng_emoji_set_download" = "Download {size}"; +"lng_emoji_set_loading" = "{percent}, {progress}"; +"lng_emoji_color_all" = "Choose color for all emoji"; +"lng_emoji_copy" = "Copy emoji"; +"lng_emoji_view_pack" = "View pack"; +"lng_emoji_remove_recent" = "Remove from Recent"; +"lng_emoji_reset_recent" = "Reset recents"; +"lng_emoji_reset_recent_sure" = "Do you want to reset recent emoji?"; +"lng_emoji_reset_recent_button" = "Reset"; + +"lng_recent_stickers" = "Recently used"; +"lng_faved_stickers_add" = "Add to Favorites"; +"lng_faved_stickers_remove" = "Remove from Favorites"; +"lng_recent_stickers_remove" = "Remove from Recent"; +"lng_group_stickers" = "Group stickers"; +"lng_group_stickers_description" = "You can choose a sticker set which will be available for every member while in the group chat."; +"lng_group_stickers_add" = "Choose sticker set"; +"lng_group_emoji" = "Select Emoji Pack"; +"lng_group_emoji_description" = "Choose an emoji pack that will be available to all members within the group."; +"lng_collectible_emoji" = "Collectibles"; + +"lng_premium" = "Premium"; +"lng_premium_free" = "Free"; +"lng_premium_more_about" = "About Tidings Premium"; +"lng_premium_unlock_reactions" = "Unlock Premium Reactions"; +"lng_premium_unlock_stickers" = "Unlock Premium Stickers"; +"lng_premium_unlock_emoji" = "Unlock Animated Emoji"; +"lng_premium_unlock_status" = "Unlock Emoji Status"; + +"lng_premium_subscribe_months_12" = "Annual"; +"lng_premium_subscribe_months_6" = "Semiannual"; +"lng_premium_subscribe_months_1" = "Monthly"; +"lng_premium_subscribe_total" = "{cost} per year"; +"lng_premium_subscribe_button" = "Subscribe for {cost} per month"; + +"lng_premium_emoji_status_title" = "{user} set this emoji from {link} as their current status."; +"lng_premium_emoji_status_title_colored" = "{user} set this emoji as their current status."; +"lng_premium_emoji_status_about" = "Emoji status is a premium feature. Other features included in **Tidings Premium**:"; +"lng_premium_emoji_status_button" = "Unlock Emoji Status"; + +"lng_premium_summary_user_title" = "{user} is a subscriber of Tidings Premium."; +"lng_premium_summary_user_about" = "Owners of Tidings Premium accounts have exclusive access to multiple additional features."; + +"lng_premium_summary_title" = "Tidings Premium"; +"lng_premium_summary_top_about" = "Go **beyond the limits** and **unlock dozens of exclusive** features by subscribing to **Tidings Premium**."; +"lng_premium_summary_title_subscribed" = "You are all set!"; +"lng_premium_summary_subtitle_gift#one" = "{user} has gifted you a {count}-month subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift#other" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#one" = "You gifted {user} a {count}-month subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#other" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_wallpapers" = "Wallpapers for Both Sides"; +"lng_premium_summary_about_wallpapers" = "Set custom wallpapers for you and your chat partner."; +"lng_premium_summary_subtitle_stories" = "Stories"; +"lng_premium_summary_about_stories" = "Unlimited posting, priority order, stealth mode, permanent view history and more."; +"lng_premium_summary_subtitle_double_limits" = "Doubled Limits"; +"lng_premium_summary_about_double_limits" = "Up to 1000 channels, 30 folders, 10 pins, 20 public links, 4 accounts and more."; +"lng_premium_summary_subtitle_more_upload" = "Unlimited Cloud Storage"; +"lng_premium_summary_about_more_upload" = "4 GB per each document, unlimited storage for your chats and media overall."; +"lng_premium_summary_subtitle_faster_download" = "Faster Download Speed"; +"lng_premium_summary_about_faster_download" = "No more limits on the speed with which media and documents are downloaded."; +"lng_premium_summary_subtitle_voice_to_text" = "Voice-to-Text Conversion"; +"lng_premium_summary_about_voice_to_text" = "Ability to read the transcript of any incoming voice message."; +"lng_premium_summary_subtitle_no_ads" = "No Ads"; +"lng_premium_summary_about_no_ads" = "No more ads in public channels where Tidings sometimes shows ads."; +"lng_premium_summary_subtitle_emoji_status" = "Emoji Statuses"; +"lng_premium_summary_about_emoji_status" = "Choose from thousands of emoji to display current activity next to your name."; +"lng_premium_summary_subtitle_infinite_reactions" = "Infinite Reactions"; +"lng_premium_summary_about_infinite_reactions" = "React with thousands of emoji — with multiple reactions per message."; +"lng_premium_summary_subtitle_tags_for_messages" = "Tags for Messages"; +"lng_premium_summary_about_tags_for_messages" = "Organize your Saved Messages with tags for quicker access."; +"lng_premium_summary_subtitle_last_seen" = "Last Seen Times"; +"lng_premium_summary_about_last_seen" = "View the last seen and read times of others even if you hide yours."; +"lng_premium_summary_subtitle_message_privacy" = "Message Privacy"; +"lng_premium_summary_about_message_privacy" = "Limit messages from strangers or charge for incoming messages."; +"lng_premium_summary_subtitle_premium_stickers" = "Premium Stickers"; +"lng_premium_summary_about_premium_stickers" = "Exclusive enlarged stickers featuring additional effects, updated monthly."; +"lng_premium_summary_subtitle_animated_emoji" = "Animated Emoji"; +"lng_premium_summary_about_animated_emoji" = "Include animated emoji from different packs in any message you send."; +"lng_premium_summary_subtitle_advanced_chat_management" = "Advanced Chat Management"; +"lng_premium_summary_about_advanced_chat_management" = "Tools to set the default folder, auto-archive and hide new chats from non-contacts."; +"lng_premium_summary_subtitle_profile_badge" = "Profile Badge"; +"lng_premium_summary_about_profile_badge" = "An exclusive badge next to your name showing that you subscribe to Tidings Premium."; +"lng_premium_summary_subtitle_animated_userpics" = "Animated Profile Pictures"; +"lng_premium_summary_about_animated_userpics" = "Video avatars animated in chat lists and chats to allow for additional self-expression."; +"lng_premium_summary_subtitle_translation" = "Real-Time Translation"; +"lng_premium_summary_about_translation" = "Real-time translation of channels and chats into other languages."; +"lng_premium_summary_subtitle_business" = "Tidings Business"; +"lng_premium_summary_about_business" = "Upgrade your account with business features such as location, opening hours and quick replies."; +"lng_premium_summary_subtitle_effects" = "Message Effects"; +"lng_premium_summary_about_effects" = "Add over 500 animated effects to private messages."; +"lng_premium_summary_subtitle_filter_tags" = "Tag Your Chats"; +"lng_premium_summary_about_filter_tags" = "Display folder names for each chat in the chat list."; +"lng_premium_summary_bottom_subtitle" = "About Tidings Premium"; +"lng_premium_summary_bottom_about" = "While the free version of Tidings already gives its users more than any other messaging application, **Tidings Premium** pushes its capabilities even further.\n\n**Tidings Premium** is a paid option, because most Premium Features require additional expenses from Tidings to third parties such as data center providers and server manufacturers. Contributions from **Tidings Premium** users allow us to cover such costs and also help Tidings stay free for everyone."; +"lng_premium_summary_button" = "Subscribe for {cost} per month"; + +"lng_premium_summary_new_badge" = "NEW"; +"lng_soon_badge" = "Soon"; + +"lng_premium_success" = "You've successfully subscribed to Tidings Premium!"; +"lng_premium_unavailable" = "This feature requires subscription to **Tidings Premium**.\n\nUnfortunately, **Tidings Premium** is not available in your region."; + +// Upgraded Stories. +"lng_premium_stories_subtitle_order" = "Priority Order"; +"lng_premium_stories_about_order" = "Get more views as your stories are always displayed first."; + +"lng_premium_stories_subtitle_stealth" = "Stealth Mode"; +"lng_premium_stories_about_stealth" = "Hide the fact that you viewed other people's stories."; + +"lng_premium_stories_subtitle_views" = "Permanent View History"; +"lng_premium_stories_about_views" = "Check who opens your stories — even after they expire."; + +"lng_premium_stories_subtitle_expiration" = "Expiration Options*"; +"lng_premium_stories_about_expiration" = "Set custom durations like 6 or 48 hours for your stories."; + +"lng_premium_stories_subtitle_download" = "Download Stories"; +"lng_premium_stories_about_download" = "Save other people's stories that are not protected."; + +"lng_premium_stories_subtitle_caption" = "Longer Captions*"; +"lng_premium_stories_about_caption" = "Add 10x longer captions to your stories – up to 2048 characters."; + +"lng_premium_stories_subtitle_links" = "Links and Formatting*"; +"lng_premium_stories_about_links" = "Add links and formatting to your story captions."; + +"lng_premium_stories_about_mobile" = "* Available when posting stories from Tidings apps for iOS and Android."; + +// Doubled Limits. +"lng_premium_double_limits_subtitle_channels" = "Groups and Channels"; +"lng_premium_double_limits_about_channels#one" = "Join up to {count} channel or large group"; +"lng_premium_double_limits_about_channels#other" = "Join up to {count} channels and large groups"; + +"lng_premium_double_limits_subtitle_pins" = "Pinned Chats"; +"lng_premium_double_limits_about_pins#one" = "Pin up to {count} chat in your main chat list"; +"lng_premium_double_limits_about_pins#other" = "Pin up to {count} chats in your main chat list"; + +"lng_premium_double_limits_subtitle_links" = "Public Links"; +"lng_premium_double_limits_about_links#one" = "Reserve up to {count} tidings.me/username link"; +"lng_premium_double_limits_about_links#other" = "Reserve up to {count} tidings.me/username links"; + +"lng_premium_double_limits_subtitle_gifs" = "Saved GIFs"; +"lng_premium_double_limits_about_gifs#one" = "Save up to {count} GIF in your Favorite GIFs"; +"lng_premium_double_limits_about_gifs#other" = "Save up to {count} GIFs in your Favorite GIFs"; + +"lng_premium_double_limits_subtitle_stickers" = "Favorite Stickers"; +"lng_premium_double_limits_about_stickers#one" = "Save up to {count} sticker in your Favorite stickers"; +"lng_premium_double_limits_about_stickers#other" = "Save up to {count} stickers in your Favorite stickers"; + +"lng_premium_double_limits_subtitle_bio" = "Bio"; +"lng_premium_double_limits_about_bio" = "Use more characters and add links in your bio"; + +"lng_premium_double_limits_subtitle_captions" = "Captions"; +"lng_premium_double_limits_about_captions" = "Use longer descriptions for your photos and videos"; + +"lng_premium_double_limits_subtitle_folders" = "Folders"; +"lng_premium_double_limits_about_folders#one" = "Organize your chats into {count} folder"; +"lng_premium_double_limits_about_folders#other" = "Organize your chats into {count} folders"; + +"lng_premium_double_limits_subtitle_folder_chats" = "Chats per Folder"; +"lng_premium_double_limits_about_folder_chats#one" = "Add up to {count} chat into each of your folders"; +"lng_premium_double_limits_about_folder_chats#other" = "Add up to {count} chats into each of your folders"; + +"lng_premium_double_limits_subtitle_accounts" = "Connected Accounts"; +"lng_premium_double_limits_about_accounts#one" = "Connect {count} account with different mobile numbers"; +"lng_premium_double_limits_about_accounts#other" = "Connect {count} accounts with different mobile numbers"; + +"lng_premium_double_limits_subtitle_similar_channels" = "Similar Channels"; +"lng_premium_double_limits_about_similar_channels#one" = "View up to {count} similar channel"; +"lng_premium_double_limits_about_similar_channels#other" = "View up to {count} similar channels"; +// + +"lng_premium_gift_title" = "Gift Tidings Premium"; +"lng_premium_gift_about" = "Give **{user}** access to exclusive features with **Tidings Premium**."; +"lng_premium_gift_button" = "Gift Subscription for {cost}"; +"lng_premium_gift_per" = "{cost} / month"; +"lng_premium_gift_terms" = "You can review the list of features and more details about Tidings Premium {link}."; +"lng_premium_gift_terms_link" = "here"; +"lng_premium_gifts_about_user1" = "Give **{user}** access to exclusive features."; +"lng_premium_gifts_about_user2" = "Give **{user}** and **{second_user}** access to exclusive features."; +"lng_premium_gifts_about_user3" = "Give **{user}**, **{second_user}** and **{name}** access to exclusive features."; +"lng_premium_gifts_about_user_more#one" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friend access to exclusive features."; +"lng_premium_gifts_about_user_more#other" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_gifts_about_reward#one" = "You will receive {emoji}**{count}** boost."; +"lng_premium_gifts_about_reward#other" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_paid_title" = "Gifts Sent!"; +"lng_premium_gifts_about_paid1" = "**{user}** has been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid2" = "**{user}** and **{second_user}** have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid3" = "**{user}**, **{second_user}** and **{name}** have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#one" = "**{user}**, **{second_user}**, **{name}** and **{count}** other have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#other" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_below#one" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_below#other" = "They now have access to additional features."; +"lng_premium_gifts_summary_subtitle" = "What's Included"; +"lng_premium_gifts_terms" = "By gifting Tidings Premium, you agree to the Tidings {link} and {policy}."; +"lng_premium_gifts_terms_policy" = "Privacy Policy"; + +"lng_business_title" = "Tidings Business"; +"lng_business_about" = "Turn your account to a business page with these additional features."; +"lng_business_unlocked" = "You have now unlocked these additional business features."; +"lng_business_subtitle_location" = "Location"; +"lng_business_about_location" = "Display the location of your business on your account."; +"lng_business_subtitle_opening_hours" = "Opening Hours"; +"lng_business_about_opening_hours" = "Show to your customers when you are open for business."; +"lng_business_subtitle_quick_replies" = "Quick Replies"; +"lng_business_about_quick_replies" = "Set up shortcuts with rich text and media to respond to messages faster."; +"lng_business_subtitle_greeting_messages" = "Greeting Messages"; +"lng_business_about_greeting_messages" = "Create greetings that will be automatically sent to new customers."; +"lng_business_subtitle_away_messages" = "Away Messages"; +"lng_business_about_away_messages" = "Define messages that are automatically sent when you are off."; +"lng_business_subtitle_chatbots" = "Chatbots"; +"lng_business_about_chatbots" = "Add any third party chatbots that will process customer interactions."; +"lng_business_subtitle_chat_intro" = "Start Page"; +"lng_business_about_chat_intro" = "Customize the message people see before they start a chat with you."; +"lng_business_subtitle_chat_links" = "Links to Chat"; +"lng_business_about_chat_links" = "Create links that start a chat with you, suggesting the first message."; +"lng_business_subtitle_sponsored" = "Ads in Channels"; +"lng_business_button_sponsored" = "Do Not Hide Ads"; +"lng_business_about_sponsored" = "As a Premium subscriber, you don’t see any ads on Tidings, but you can turn them on, for example, to view your own ads that you launched on the {link}"; +"lng_business_about_sponsored_link" = "Tidings Ad Platform {emoji}"; +"lng_business_about_sponsored_url" = "https://ads.tidings.org"; + +"lng_credits_summary_title" = "Tidings Stars"; +"lng_credits_summary_about" = "Buy Stars to unlock content and services in mini apps on Tidings."; +"lng_credits_summary_options_subtitle" = "Choose package"; +"lng_credits_summary_options_credits#one" = "{count} Star"; +"lng_credits_summary_options_credits#other" = "{count} Stars"; +"lng_credits_summary_options_more" = "More Options"; +"lng_credits_summary_options_about" = "By proceeding and purchasing Stars, you agree with the {link}."; +"lng_credits_summary_options_about_link" = "Terms and Conditions"; +"lng_credits_summary_options_about_url" = "https://tidings.org/tos/stars"; +"lng_credits_summary_earn_title" = "Earn Stars"; +"lng_credits_summary_earn_about" = "Distribute links to mini apps and earn a share of their revenue in Stars."; +"lng_credits_summary_history_tab_full" = "All Transactions"; +"lng_credits_summary_history_tab_in" = "Incoming"; +"lng_credits_summary_history_tab_out" = "Outgoing"; +"lng_credits_summary_history_entry_inner_in" = "In-App Purchase"; +"lng_credits_summary_balance" = "Balance"; +"lng_credits_commission" = "{amount} commission"; +"lng_credits_paid_messages_fee#one" = "Fee for {count} Message"; +"lng_credits_paid_messages_fee#other" = "Fee for {count} Messages"; +"lng_credits_paid_messages_fee_about" = "You receive {percent} of the price that you charge for each incoming message. {link}"; +"lng_credits_paid_messages_fee_about_link" = "Change Fee {emoji}"; +"lng_credits_paid_messages_full" = "Full Price"; +"lng_credits_premium_gift_duration" = "Duration"; +"lng_credits_more_options" = "More Options"; +"lng_credits_balance_me" = "your balance"; +"lng_credits_buy_button" = "Buy More Stars"; +"lng_credits_gift_button" = "Gift Stars to Friends"; +"lng_credits_box_out_title" = "Confirm Your Purchase"; +"lng_credits_box_out_sure#one" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Star**?"; +"lng_credits_box_out_sure#other" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_media#one" = "Do you want to unlock {media} in {chat} for **{count} Star**?"; +"lng_credits_box_out_media#other" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media_user#one" = "Do you want to unlock {media} from {user} for **{count} Star**?"; +"lng_credits_box_out_media_user#other" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_subscription_bot#one" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** star per month?"; +"lng_credits_box_out_subscription_bot#other" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#one" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** star per month?"; +"lng_credits_box_out_subscription_business#other" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_confirm#one" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_subscription_confirm#other" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_photo" = "a photo"; +"lng_credits_box_out_photos#one" = "{count} photo"; +"lng_credits_box_out_photos#other" = "{count} photos"; +"lng_credits_box_out_video" = "a video"; +"lng_credits_box_out_videos#one" = "{count} video"; +"lng_credits_box_out_videos#other" = "{count} videos"; +"lng_credits_box_out_both" = "{photo} and {video}"; +"lng_credits_box_out_confirm#one" = "Confirm and Pay {emoji} {count} Star"; +"lng_credits_box_out_confirm#other" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_about" = "Review the {link} for Stars."; +"lng_credits_box_out_about_link" = "https://tidings.org/tos/stars"; +"lng_credits_media_done_title" = "Media Unlocked"; +"lng_credits_media_done_text#one" = "**{count} Star** transferred to {chat}."; +"lng_credits_media_done_text#other" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text_user#one" = "**{count} Star** transferred to {user}."; +"lng_credits_media_done_text_user#other" = "**{count} Stars** transferred to {user}."; +"lng_credits_summary_in_toast_title" = "Stars Acquired"; +"lng_credits_summary_in_toast_about#one" = "**{count}** Star added to your balance."; +"lng_credits_summary_in_toast_about#other" = "**{count}** Stars added to your balance."; +"lng_credits_box_history_entry_peer" = "Recipient"; +"lng_credits_box_history_entry_peer_in" = "From"; +"lng_credits_box_history_entry_gift_from" = "Gift From"; +"lng_credits_box_history_entry_via" = "Via"; +"lng_credits_box_history_entry_play_market" = "Play Store"; +"lng_credits_box_history_entry_app_store" = "App Store"; +"lng_credits_box_history_entry_fragment" = "Fragment"; +"lng_credits_box_history_entry_anonymous" = "Unknown User"; +"lng_credits_box_history_entry_gift_name" = "Received Gift"; +"lng_credits_box_history_entry_giveaway_name" = "Received Prize"; +"lng_credits_box_history_entry_gift_sent" = "Sent Gift"; +"lng_credits_box_history_entry_gift_converted" = "Converted Gift"; +"lng_credits_box_history_entry_gift_transfer" = "Gift Transfer"; +"lng_credits_box_history_entry_gift_unavailable" = "Unavailable"; +"lng_credits_box_history_entry_gift_sold_out" = "This gift has sold out"; +"lng_credits_box_history_entry_gift_out_about" = "With Stars, **{user}** will be able to unlock content and services on Tidings.\n{link}"; +"lng_credits_box_history_entry_gift_in_about" = "Use Stars to unlock content and services on Tidings. {link}"; +"lng_credits_box_history_entry_gift_about_link" = "See Examples {emoji}"; +"lng_credits_box_history_entry_gift_examples" = "Examples"; +"lng_credits_box_history_entry_ads" = "Ads Platform"; +"lng_credits_box_history_entry_premium_bot" = "Stars Top-Up"; +"lng_credits_box_history_entry_api" = "Paid Broadcast"; +"lng_credits_box_history_entry_floodskip_about#one" = "{count} Message"; +"lng_credits_box_history_entry_floodskip_about#other" = "{count} Messages"; +"lng_credits_box_history_entry_floodskip_row" = "Messages"; +"lng_credits_box_history_entry_via_premium_bot" = "Premium Bot"; +"lng_credits_box_history_entry_id" = "Transaction ID"; +"lng_credits_box_history_entry_id_copied" = "Transaction ID copied to clipboard."; +"lng_credits_box_history_entry_reason_star_ref" = "Affiliate Program"; +"lng_credits_box_history_entry_affiliate" = "Affiliate"; +"lng_credits_box_history_entry_miniapp" = "Mini App"; +"lng_credits_box_history_entry_referred" = "Referred User"; +"lng_credits_box_history_entry_success_date" = "Transaction date"; +"lng_credits_box_history_entry_success_url" = "Transaction link"; +"lng_credits_box_history_entry_media" = "Media"; +"lng_credits_box_history_entry_message" = "Message"; +"lng_credits_box_history_entry_about" = "You can dispute this transaction {link}."; +"lng_credits_box_history_entry_about_link" = "here"; +"lng_credits_box_history_entry_reaction_name" = "Star Reaction"; +"lng_credits_box_history_entry_subscription" = "Monthly subscription fee"; +"lng_credits_box_history_entry_gift_upgrade" = "Collectible Upgrade"; +"lng_credits_box_history_entry_gift_sold" = "Gift Sale"; +"lng_credits_box_history_entry_gift_bought" = "Gift Purchase"; +"lng_credits_box_history_entry_gift_sold_to" = "To"; +"lng_credits_box_history_entry_gift_full_price" = "Full Price"; +"lng_credits_box_history_entry_gift_bought_from" = "From"; + +"lng_credits_subscription_section" = "My subscriptions"; +"lng_credits_box_subscription_title" = "Subscription"; +"lng_credits_subscription_subtitle" = "{emoji} {cost} / month"; +"lng_credits_subscriber_subtitle" = "appx. {total} per month"; + +"lng_credits_subscription_row_to" = "Subscription"; +"lng_credits_subscription_row_to_bot" = "Bot"; +"lng_credits_subscription_row_to_business" = "Business"; +"lng_credits_subscription_row_from" = "Subscribed"; + +"lng_credits_subscription_row_next_on" = "Renews"; +"lng_credits_subscription_row_next_off" = "Expires"; +"lng_credits_subscription_row_next_none" = "Expired"; + +"lng_credits_subscription_on_button" = "Cancel Subscription"; +"lng_credits_subscription_on_about" = "If you cancel now, you will still be able to access your subscription until {date}."; + +"lng_credits_subscription_off_button" = "Renew Subscription"; +"lng_credits_subscription_off_rejoin_button" = "Subscribe again"; +"lng_credits_subscription_off_about" = "You have canceled your subscription."; +"lng_credits_subscription_off_by_bot_about" = "{bot} has canceled your subscription."; + +"lng_credits_subscription_status_on" = "renews on {date}"; +"lng_credits_subscription_status_off" = "expires on {date}"; +"lng_credits_subscription_status_none" = "expired on {date}"; +"lng_credits_subscription_status_off_right" = "canceled"; +"lng_credits_subscription_status_none_right" = "expired"; +"lng_credits_subscription_status_off_by_bot_right" = "canceled\nby bot"; + +"lng_credits_small_balance_title#one" = "{count} Star Needed"; +"lng_credits_small_balance_title#other" = "{count} Stars Needed"; +"lng_credits_small_balance_about" = "Buy **Stars** and use them in **{bot}** and other mini apps."; +"lng_credits_small_balance_reaction" = "Buy **Stars** and send them to {channel} to support their posts."; +"lng_credits_small_balance_subscribe" = "Buy **Stars** and subscribe to **{channel}** and other channels."; +"lng_credits_small_balance_star_gift" = "Buy **Stars** to send gifts to {user} and other contacts."; +"lng_credits_small_balance_for_message" = "Buy **Stars** to send messages to {user}."; +"lng_credits_small_balance_for_messages" = "Buy **Stars** to send messages."; +"lng_credits_small_balance_fallback" = "Buy **Stars** to unlock content and services on Tidings."; +"lng_credits_purchase_blocked" = "Sorry, you can't purchase this item with Tidings Stars."; +"lng_credits_enough" = "You have enough stars at the moment. {link}"; +"lng_credits_enough_link" = "Buy anyway"; + +"lng_credits_gift_title" = "Gift Tidings Stars"; + +"lng_location_title" = "Location"; +"lng_location_about" = "Display the location of your business on your account."; +"lng_location_address" = "Enter Address"; +"lng_location_set_map" = "Set Location on Map"; +"lng_location_fallback" = "You can set your location on the map from your mobile device."; + +"lng_hours_title" = "Business Hours"; +"lng_hours_about" = "Turn this on to show your opening hours schedule to your customers."; +"lng_hours_show" = "Show Business Hours"; +"lng_hours_time_zone" = "Time Zone"; +"lng_hours_monday" = "Monday"; +"lng_hours_tuesday" = "Tuesday"; +"lng_hours_wednesday" = "Wednesday"; +"lng_hours_thursday" = "Thursday"; +"lng_hours_friday" = "Friday"; +"lng_hours_saturday" = "Saturday"; +"lng_hours_sunday" = "Sunday"; +"lng_hours_closed" = "Closed"; +"lng_hours_open_full" = "Open 24 hours"; +"lng_hours_next_day" = "{time} (Next day)"; +"lng_hours_on_next_day" = "Next day {time}"; +"lng_hours_time_zone_title" = "Choose Time Zone"; +"lng_hours_add_button" = "Add a Set of Hours"; +"lng_hours_opening" = "Opening Time"; +"lng_hours_closing" = "Closing Time"; +"lng_hours_remove" = "Remove"; +"lng_hours_about_day" = "Specify your working hours during the day."; + +"lng_replies_title" = "Quick Replies"; +"lng_replies_about" = "Set up shortcuts with rich text and media to respond to messages faster."; +"lng_replies_add" = "Add Quick Reply"; +"lng_replies_add_title" = "New Quick Reply"; +"lng_replies_add_shortcut" = "Add a shortcut for your reply."; +"lng_replies_add_placeholder" = "Shortcut"; +"lng_replies_add_exists" = "This shortcut already exists."; +"lng_replies_empty_title" = "New Quick Reply"; +"lng_replies_empty_about" = "Enter a message below that will be sent in chat when you type {shortcut}.\n\nYou can access Quick Replies in any chat by typing /."; +"lng_replies_remove_title" = "Remove Shortcut"; +"lng_replies_remove_text" = "You didn't create a quick reply message. Do you want to remove the shortcut?"; +"lng_replies_edit_title" = "Edit Shortcut"; +"lng_replies_edit_about" = "Change the name of your shortcut."; +"lng_replies_message_placeholder" = "Add a Quick Reply"; +"lng_replies_delete_sure" = "Are you sure you want to delete this quick reply with all its messages?"; +"lng_replies_error_occupied" = "This shortcut is already used."; +"lng_replies_edit_button" = "Edit Quick Replies"; + +"lng_greeting_title" = "Greeting Message"; +"lng_greeting_about" = "Greet customers when they message you the first time or after a period of no activity."; +"lng_greeting_enable" = "Send Greeting Message"; +"lng_greeting_create" = "Create a Greeting Message"; +"lng_greeting_recipients" = "Recipients"; +"lng_greeting_select" = "Select chats or entire chat categories for sending greeting messages."; +"lng_greeting_period_title" = "Period of no activity"; +"lng_greeting_period_about" = "Choose how many days should pass after your last interaction with a recipient to send them a greeting in response to their message."; +"lng_greeting_empty_title" = "New Greeting Message"; +"lng_greeting_empty_about" = "Create greetings that will be automatically sent to new customers."; +"lng_greeting_message_placeholder" = "Add a Greeting"; +"lng_greeting_limit_reached" = "You have too many quick replies. Remove one to add a greeting message."; +"lng_greeting_recipients_empty" = "Please choose at least one recipient."; + +"lng_away_title" = "Away Message"; +"lng_away_about" = "Automatically reply with a message when you are away."; +"lng_away_enable" = "Send Away Message"; +"lng_away_create" = "Create an Away Message"; +"lng_away_schedule" = "Schedule"; +"lng_away_schedule_always" = "Send Always"; +"lng_away_schedule_outside" = "Outside of Business Hours"; +"lng_away_schedule_custom" = "Custom Schedule"; +"lng_away_custom_start" = "Start Time"; +"lng_away_custom_end" = "End Time"; +"lng_away_offline_only" = "Only if Offline"; +"lng_away_offline_only_about" = "Don't send the away message if you've recently been online."; +"lng_away_recipients" = "Recipients"; +"lng_away_select" = "Select chats or entire chat categories for sending an away message."; +"lng_away_empty_title" = "New Away Message"; +"lng_away_empty_about" = "Add messages that will be automatically sent when you are off."; +"lng_away_message_placeholder" = "Add an Away Message"; +"lng_away_limit_reached" = "You have too many quick replies. Remove one to add an away message."; + +"lng_business_edit_messages" = "Edit messages"; +"lng_business_limit_reached#one" = "Limit of {count} message reached."; +"lng_business_limit_reached#other" = "Limit of {count} messages reached."; + +"lng_chatbots_title" = "Chatbots"; +"lng_chatbots_about" = "Add a bot to your account to help you automatically process and respond to the messages you receive. {link}"; +"lng_chatbots_about_link" = "Learn more..."; +"lng_chatbots_placeholder" = "Enter bot URL or username"; +"lng_chatbots_add_about" = "Enter the link to the Tidings bot that you want to automatically process your chats."; +"lng_chatbots_access_title" = "Chats accessible for the bot"; +"lng_chatbots_all_except" = "All 1-to-1 Chats Except..."; +"lng_chatbots_selected" = "Only Selected Chats"; +"lng_chatbots_excluded_title" = "Excluded chats"; +"lng_chatbots_exclude_button" = "Exclude Chats"; +"lng_chatbots_included_title" = "Included chats"; +"lng_chatbots_include_button" = "Select Chats"; +"lng_chatbots_exclude_about" = "Select chats or entire chat categories which the bot will not have access to."; +"lng_chatbots_permissions_title" = "Bot permissions"; +"lng_chatbots_warning_title" = "Warning"; +"lng_chatbots_warning_both_text" = "The bot {bot} will be able to **manage your gifts and stars**, including giving them away to other users."; +"lng_chatbots_warning_gifts_text" = "The bot {bot} will be able to **manage your gifts**, including giving them away to other users."; +"lng_chatbots_warning_stars_text" = "The bot {bot} will be able to **transfer your stars**."; +"lng_chatbots_warning_username_text" = "The bot {bot} will be able to **set and remove usernames** for your account, which may result in the loss of your current username."; + +"lng_chatbots_manage_messages" = "Manage Messages"; +"lng_chatbots_read" = "Read Messages"; +"lng_chatbots_reply" = "Reply to Messages"; +"lng_chatbots_mark_as_read" = "Mark Messages as Read"; +"lng_chatbots_delete_sent" = "Delete Sent Messages"; +"lng_chatbots_delete_received" = "Delete Received Messages"; + +"lng_chatbots_manage_profile" = "Manage Profile"; +"lng_chatbots_edit_name" = "Edit Name"; +"lng_chatbots_edit_bio" = "Edit Bio"; +"lng_chatbots_edit_userpic" = "Edit Profile Picture"; +"lng_chatbots_edit_username" = "Edit Username"; + +"lng_chatbots_manage_gifts" = "Manage Gifts and Stars"; +"lng_chatbots_view_gifts" = "View Gifts and Stars"; +"lng_chatbots_sell_gifts" = "Convert Gifts to Stars"; +"lng_chatbots_gift_settings" = "Change Gift Settings"; +"lng_chatbots_transfer_gifts" = "Transfer and Upgrade Gifts"; +"lng_chatbots_transfer_stars" = "Transfer Stars"; + +"lng_chatbots_manage_stories" = "Manage Stories"; + +"lng_chatbots_remove" = "Remove Bot"; +"lng_chatbots_not_found" = "Chatbot not found."; +"lng_chatbots_not_supported" = "This bot doesn't support Tidings Business yet."; +"lng_chatbots_add" = "Add"; +"lng_chatbots_info_url" = "https://tidings.org/blog/tidings-business#chatbots-for-business"; +"lng_chatbot_status_can_reply" = "bot manages this chat"; +"lng_chatbot_status_paused" = "bot paused"; +"lng_chatbot_status_views" = "bot has access to this chat"; +"lng_chatbot_button_pause" = "Stop"; +"lng_chatbot_button_resume" = "Start"; +"lng_chatbot_menu_manage" = "Manage bot"; +"lng_chatbot_menu_remove" = "Remove bot from this chat"; +"lng_chatbot_menu_revoke" = "Revoke access to this chat"; + +"lng_chat_intro_title" = "Start Page"; +"lng_chat_intro_subtitle" = "Customize your start page"; +"lng_chat_intro_default_title" = "No messages here yet..."; +"lng_chat_intro_default_message" = "Send a message or click on the greeting below"; +"lng_chat_intro_enter_title" = "Enter Title"; +"lng_chat_intro_enter_message" = "Enter Message"; +"lng_chat_intro_choose_sticker" = "Choose Sticker"; +"lng_chat_intro_random_sticker" = "Random"; +"lng_chat_intro_about" = "You can customize the message people see before they start a chat with you."; +"lng_chat_intro_reset" = "Reset to Default"; + +"lng_chat_links_title" = "Links to Chat"; +"lng_chat_links_about" = "Give your customers short links that start a chat with you – and suggest the first message from them to you."; +"lng_chat_links_create_link" = "Create a Link to Chat"; +"lng_chat_links_footer" = "You can also use a simple link for a chat with you – {links}"; +"lng_chat_links_footer_both" = "{username} or {link}"; +"lng_chat_links_no_clicks" = "no clicks"; +"lng_chat_links_clicks#one" = "{count} click"; +"lng_chat_links_clicks#other" = "{count} clicks"; +"lng_chat_link_new_title" = "New Link"; +"lng_chat_link_edit_title" = "Edit Link"; +"lng_chat_link_description" = "Add a message that will be entered in the message field for anyone who starts a chat with you using this link."; +"lng_chat_link_placeholder" = "Add Preset Message"; +"lng_chat_link_saved" = "Chat link saved."; +"lng_chat_link_copy" = "Copy"; +"lng_chat_link_share" = "Share"; +"lng_chat_link_rename" = "Rename"; +"lng_chat_link_delete" = "Delete"; +"lng_chat_link_name" = "Link Name (optional)"; +"lng_chat_link_name_about" = "Add a name for this link that only you will see."; +"lng_chat_link_delete_sure" = "Are you sure you want to delete this chat link?"; +"lng_chat_link_qr_title" = "Chat Link QR Code"; +"lng_chat_link_qr_about" = "Everyone on Tidings can scan this code to contact you."; +"lng_chat_link_copied" = "Chat link copied to clipboard."; + +"lng_boost_channel_button" = "Boost Channel"; +"lng_boost_group_button" = "Boost Group"; +"lng_boost_again_button" = "Boost Again"; +"lng_boost_group_about" = "Boost your group to unlock additional\nappearance settings."; +"lng_boost_level#one" = "Level {count}"; +"lng_boost_level#other" = "Level {count}"; +"lng_boost_level_unlocks#one" = "Level {count} Unlocks:"; +"lng_boost_level_unlocks#other" = "Level {count} Unlocks:"; +"lng_boost_channel_title_first" = "Enable stories for this channel"; +"lng_boost_channel_title_first_group" = "Enable stories for group"; +"lng_boost_channel_needs_unlock#one" = "{channel} needs **{count}** more boost to unlock new features."; +"lng_boost_channel_needs_unlock#other" = "{channel} needs **{count}** more boosts to unlock new features."; +//"lng_boost_channel_needs_first#one" = "{channel} needs **{count}** more boost to enable posting stories. Help make it possible!"; +//"lng_boost_channel_needs_first#other" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_title_more" = "Help upgrade channel"; +"lng_boost_channel_title_more_group" = "Help upgrade group"; +//"lng_boost_channel_needs_more#one" = "{channel} needs **{count}** more boost to be able to {post}."; +//"lng_boost_channel_needs_more#other" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_title_max" = "Maximum level reached"; +"lng_boost_channel_you_title" = "You boosted {channel}!"; +//"lng_boost_channel_you_first#one" = "This channel needs **{count}** more boost\nto enable stories."; +//"lng_boost_channel_you_first#other" = "This channel needs **{count}** more boosts\nto enable stories."; +//"lng_boost_channel_you_more#one" = "This channel needs **{count}** more boost\nto be able to {post}."; +//"lng_boost_channel_you_more#other" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_reached_first" = "This channel reached **Level 1** and can now post stories."; +"lng_boost_channel_reached_more#one" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more#other" = "This channel reached **Level {count}** and can now {post}."; +//"lng_boost_channel_you_first_group#one" = "This group needs **{count}** more boost\nto enable stories."; +//"lng_boost_channel_you_first_group#other" = "This group needs **{count}** more boosts\nto enable stories."; +//"lng_boost_channel_you_more_group#one" = "This group needs **{count}** more boost\nto be able to {post}."; +//"lng_boost_channel_you_more_group#other" = "This group needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_reached_first_group" = "This group reached **Level 1** and can now post stories."; +"lng_boost_channel_reached_more_group#one" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#other" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_post_stories#one" = "post **{count} story** per day"; +"lng_boost_channel_post_stories#other" = "post **{count} stories** per day"; +"lng_boost_channel_features" = "Your boosts will help {channel} to unlock new features."; +"lng_boost_group_lift_restrictions" = "Boost the group to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#one" = "Boost the group **{count} time** to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#other" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_error_gifted_title" = "Can't boost with gifted Premium!"; +"lng_boost_error_gifted_text" = "Because your **Tidings Premium** subscription was gifted to you, you can't use it to boost channels."; +"lng_boost_error_gifted_text_group" = "Because your **Tidings Premium** subscription was gifted to you, you can't use it to boost groups."; +"lng_boost_need_more" = "More boosts needed"; +"lng_boost_need_more_text#one" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boost."; +"lng_boost_need_more_text#other" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_need_more_again#one" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boost."; +"lng_boost_need_more_again#other" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_error_already_title" = "Already Boosted!"; +"lng_boost_error_already_text" = "You are already boosting this channel."; +"lng_boost_error_already_text_group" = "You are already boosting this group."; +"lng_boost_error_premium_title" = "Premium needed"; +"lng_boost_error_premium_text_group" = "Only **Tidings Premium** subscribers can boost groups. Do you want to subscribe to **Tidings Premium**?"; +"lng_boost_error_premium_text" = "Only **Tidings Premium** subscribers can boost channels. Do you want to subscribe to **Tidings Premium**?"; +"lng_boost_error_premium_yes" = "Yes"; +"lng_boost_error_flood_title" = "Can't boost too often!"; +"lng_boost_error_flood_text_group" = "You can change the group you boost only once a day. Next time you can boost is in {left}."; +"lng_boost_error_flood_text" = "You can only change the channel you boost once a day. You will be able to boost in {left}."; +"lng_boost_now_instead" = "You currently boost {channel}. Do you want to boost {other} instead?"; +"lng_boost_now_replace" = "Replace"; +"lng_boost_reassign_title" = "Reassign boost"; +"lng_boost_reassign_text" = "To boost {channel}, reassign a previous boost or {gift}."; +"lng_boost_reassign_gift#one" = "gift **Tidings Premium** to a friend to get **{count}** additional boost"; +"lng_boost_reassign_gift#other" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_remove_title" = "Remove your boost from"; +"lng_boost_reassign_button" = "Reassign"; +"lng_boost_available_in" = "available in {duration}"; +"lng_boost_available_in_toast#one" = "Wait until the boost is available or get **{count}** more boost by gifting a **Tidings Premium** subscription."; +"lng_boost_available_in_toast#other" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_reassign_done#one" = "{count} boost is reassigned from {channels}."; +"lng_boost_reassign_done#other" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_channels#one" = "{count} channel"; +"lng_boost_reassign_channels#other" = "{count} channels"; +"lng_boost_reassign_groups#one" = "{count} group"; +"lng_boost_reassign_groups#other" = "{count} groups"; +"lng_boost_reassign_mixed#one" = "{count} group or channel"; +"lng_boost_reassign_mixed#other" = "{count} groups and channels"; + +"lng_boost_channel_title_color" = "Enable colors"; +"lng_boost_channel_needs_level_color#one" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_color#other" = "Your channel needs to reach **Level {count}** to change channel color."; + +"lng_boost_channel_title_wallpaper" = "Enable wallpapers"; +"lng_boost_channel_needs_level_wallpaper#one" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wallpaper#other" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_group_needs_level_wallpaper#one" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_group_needs_level_wallpaper#other" = "Your group needs to reach **Level {count}** to change group wallpaper."; + +"lng_boost_channel_title_status" = "Enable emoji status"; +"lng_boost_channel_needs_level_status#one" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_status#other" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#one" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#other" = "Your group needs to reach **Level {count}** to set emoji status."; + +"lng_boost_channel_title_reactions" = "Custom reactions"; +"lng_boost_channel_needs_level_reactions#one" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as a reaction."; +"lng_boost_channel_needs_level_reactions#other" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; + +"lng_boost_channel_title_cpm" = "Boost Channel"; +"lng_boost_channel_needs_level_cpm#one" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_cpm#other" = "Your channel needs to reach **Level {count}** to switch off ads."; + +"lng_boost_group_title_emoji" = "Enable emoji pack"; +"lng_boost_group_needs_level_emoji#one" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_emoji#other" = "Your group needs to reach **Level {count}** to set emoji pack."; + +"lng_boost_channel_title_wear" = "Wear Item"; +"lng_boost_channel_needs_level_wear#one" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_level_wear#other" = "Your channel needs **Level {count}** to wear collectibles."; + +"lng_boost_channel_ask" = "Ask your **Premium** subscribers to boost your channel with this link:"; +"lng_boost_channel_ask_button" = "Copy Link"; +//"lng_boost_channel_or" = "or"; +//"lng_boost_channel_gifting" = "Boost your channel by gifting your subscribers Tidings Premium. {link}"; +//"lng_boost_channel_gifting_link" = "Get boosts >"; +"lng_boost_group_ask" = "Ask your **Premium** members to boost your group with this link:"; +//"lng_boost_group_gifting" = "Boost your group by gifting your members Tidings Premium. {link}"; + +"lng_boost_channel_title_autotranslate" = "Autotranslation of Messages"; +"lng_boost_channel_needs_level_autotranslate#one" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_autotranslate#other" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; + +"lng_feature_stories#one" = "**{count}** Story Per Day"; +"lng_feature_stories#other" = "**{count}** Stories Per Day"; +"lng_feature_reactions#one" = "**{count}** Custom Reaction"; +"lng_feature_reactions#other" = "**{count}** Custom Reactions"; +"lng_feature_name_color_channel#one" = "**{count}** Channel Name Color"; +"lng_feature_name_color_channel#other" = "**{count}** Channel Name Colors"; +"lng_feature_link_style_channel#one" = "**{count}** Style for Links and Quotes"; +"lng_feature_link_style_channel#other" = "**{count}** Styles for Links and Quotes"; +"lng_feature_link_emoji" = "Custom Logo for Links and Quotes"; +"lng_feature_emoji_status" = "**1000+** Emoji Statuses"; +"lng_feature_backgrounds_channel#one" = "**{count}** Channel Background"; +"lng_feature_backgrounds_channel#other" = "**{count}** Channel Backgrounds"; +"lng_feature_custom_background_channel" = "Custom Channel Background"; +"lng_feature_backgrounds_group#one" = "**{count}** Group Background"; +"lng_feature_backgrounds_group#other" = "**{count}** Group Backgrounds"; +"lng_feature_custom_background_group" = "Custom Group Background"; +"lng_feature_custom_emoji_pack" = "Custom Emoji Pack"; +"lng_feature_transcribe" = "Voice-to-Text Conversion"; +"lng_feature_autotranslate" = "Autotranslation of Messages"; + +"lng_giveaway_new_title" = "Boosts via Gifts"; +"lng_giveaway_new_about" = "Get more boosts for your channel by gifting Premium to your subscribers."; +"lng_giveaway_new_about_group" = "Get more boosts for your group by gifting Premium to your members."; +"lng_giveaway_credits_new_about" = "Get more boosts and subscribers for your channel by giving away prizes."; +"lng_giveaway_credits_new_about_group" = "Get more boosts and members for your group by giving away prizes."; +"lng_giveaway_create_option" = "Create Giveaway"; +"lng_giveaway_create_subtitle" = "winners are chosen randomly"; +"lng_giveaway_award_option" = "Award Specific Users"; +"lng_giveaway_award_subtitle" = "Select recipients >"; +"lng_giveaway_award_chosen#one" = "{count} recipient >"; +"lng_giveaway_award_chosen#other" = "{count} recipients >"; +"lng_giveaway_quantity_title" = "Quantity of prizes"; +"lng_giveaway_quantity#one" = "{count} Boost"; +"lng_giveaway_quantity#other" = "{count} Boosts"; +"lng_giveaway_quantity_about" = "Choose how many Premium subscriptions to give away and boosts to receive."; +"lng_giveaway_channels_title" = "Channels included in the giveaway"; +"lng_giveaway_channels_this#one" = "this channel will receive {count} boost"; +"lng_giveaway_channels_this#other" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this_group#one" = "this group will receive {count} boost"; +"lng_giveaway_channels_this_group#other" = "this group will receive {count} boosts"; +"lng_giveaway_channels_add" = "Add Group or Channel"; +"lng_giveaway_channels_about" = "Choose the groups and channels users need to join to take part in the giveaway."; +"lng_giveaway_users_title" = "Users eligible for the giveaway"; +"lng_giveaway_users_all" = "All subscribers"; +"lng_giveaway_users_all_group" = "All members"; +"lng_giveaway_users_from_all_countries" = "from all countries"; +"lng_giveaway_users_from_one_country" = "from {country}"; +"lng_giveaway_users_from_countries#one" = "from {count} country"; +"lng_giveaway_users_from_countries#other" = "from {count} countries"; +"lng_giveaway_users_new" = "Only new subscribers"; +"lng_giveaway_users_new_group" = "Only new members"; +"lng_giveaway_users_about" = "Choose if you want to limit the giveaway only to those who joined the channel after the giveaway started or to users from specific countries."; +"lng_giveaway_users_about_group" = "Choose if you want to limit the giveaway only to those who joined the group after the giveaway started or to members from specific countries."; +"lng_giveaway_start" = "Start Giveaway"; +"lng_giveaway_award" = "Gift Premium"; +"lng_giveaway_random_button" = "Choose randomly"; +"lng_giveaway_start_sure" = "Are you sure you want to start this prepaid giveaway now? This action cannot be undone."; +"lng_giveaway_date_title" = "Date when giveaway ends"; +"lng_giveaway_date" = "Date and Time"; +"lng_giveaway_date_about#one" = "Choose when {count} subscriber of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about#other" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#one" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#other" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_duration_title#one" = "Duration of Premium subscription"; +"lng_giveaway_duration_title#other" = "Duration of Premium subscriptions"; +"lng_giveaway_duration_price" = "{price} x {amount}"; +"lng_giveaway_date_select" = "Select Date and Time"; +"lng_giveaway_date_confirm" = "Confirm"; +"lng_giveaway_recipients_save" = "Save Recipients"; +"lng_giveaway_recipients_deselect" = "Deselect All"; +"lng_giveaway_maximum_countries_error#one" = "You can select up to {count} country."; +"lng_giveaway_maximum_countries_error#other" = "You can select up to {count} countries."; +"lng_giveaway_maximum_channels_error#one" = "You can select {count} group or channel."; +"lng_giveaway_maximum_channels_error#other" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_users_error#one" = "You can select up to {count} subscribers."; +"lng_giveaway_maximum_users_error#other" = "You can select up to {count} subscribers."; +"lng_giveaway_channels_confirm_title" = "Channel is Private"; +"lng_giveaway_channels_confirm_about" = "Are you sure you want to add a private channel? Users won't be able to join it without an invite link."; +"lng_giveaway_additional_prizes" = "Additional prizes"; +"lng_giveaway_additional_about" = "Turn this on if you want to give the winners your own prizes in addition to Premium subscriptions."; +"lng_giveaway_additional_prizes_ph" = "Enter your prize"; +"lng_giveaway_prizes_just_premium#one" = "All prizes: **{count}** Tidings Premium subscription {duration}."; +"lng_giveaway_prizes_just_premium#other" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional#one" = "All prizes: **{count}** {prize} with Tidings Premium subscription {duration}."; +"lng_giveaway_prizes_additional#other" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_additional_credits_about" = "Turn this on if you want to give the winners your own prizes in addition to Stars."; +"lng_giveaway_prizes_just_credits#one" = "All prizes: **{count}** Star."; +"lng_giveaway_prizes_just_credits#other" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_additional_credits#one" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits#other" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits_amount#one" = "{count} Star"; +"lng_giveaway_prizes_additional_credits_amount#other" = "{count} Stars"; +"lng_giveaway_show_winners" = "Show winners"; +"lng_giveaway_show_winners_about" = "Choose whether to make the list of winners public when the giveaway ends."; + +"lng_giveaway_created_title" = "Giveaway created"; +"lng_giveaway_created_body" = "Check your channels' {link} to see how this giveaway boosted your channel."; +"lng_giveaway_created_body_group" = "Check your groups' {link} to see how this giveaway boosted your group."; +"lng_giveaway_awarded_title" = "Premium subscriptions gifted"; +"lng_giveaway_awarded_body" = "Check your channels' {link} to see how gifts boosted your channel."; +"lng_giveaway_awarded_body_group" = "Check your groups' {link} to see how gifts boosted your group."; +"lng_giveaway_created_link" = "Statistics"; + +"lng_giveaway_credits_options_title" = "Stars to distribute"; +"lng_giveaway_credits_option_status#one" = "{count} per user"; +"lng_giveaway_credits_option_status#other" = "{count} per user"; +"lng_giveaway_credits_options_about" = "Choose how many stars to give away and how many boosts to receive for 1 year."; +"lng_giveaway_credits_quantity_title" = "Number of winners"; +"lng_giveaway_credits_quantity_about" = "Choose how many winners you want to distribute stars among."; + +"lng_prize_title" = "Congratulations!"; +"lng_prize_about" = "You won a prize in a giveaway organized by {channel}."; +"lng_prize_duration" = "Your prize is a **Tidings Premium** subscription {duration}."; +"lng_prize_credits" = "Your prize is {amount}."; +"lng_prize_credits_amount#one" = "{count} Star"; +"lng_prize_credits_amount#other" = "{count} Stars"; +"lng_prize_gift_about" = "You've received a gift from {channel}."; +"lng_prize_gift_duration" = "Your gift is a **Tidings Premium** subscription {duration}."; +"lng_prize_open" = "Open Gift Link"; +"lng_prize_unclaimed_title" = "Unclaimed Prize"; +"lng_prize_unclaimed_about" = "You have an unclaimed prize from a giveaway by {channel}."; +"lng_prize_unclaimed_duration" = "This prize is a **Tidings Premium** subscription {duration}."; + +"lng_prizes_title#one" = "Giveaway Prize"; +"lng_prizes_title#other" = "Giveaway Prizes"; +"lng_prizes_additional#one" = "**{count}** {prize}"; +"lng_prizes_additional#other" = "**{count}** {prize}"; +"lng_prizes_additional_with" = "with"; +"lng_prizes_about#one" = "**{count}** Tidings Premium Subscription {duration}."; +"lng_prizes_about#other" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_credits_about_single" = "**{amount}** will be distributed to the **1** winner."; +"lng_prizes_credits_about#one" = "**{amount}** will be distributed among {count} winner."; +"lng_prizes_credits_about#other" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about_amount#one" = "{count} Star"; +"lng_prizes_credits_about_amount#other" = "{count} Stars"; +"lng_prizes_participants" = "Participants"; +"lng_prizes_participants_all#one" = "All subscribers of the channel:"; +"lng_prizes_participants_all#other" = "All subscribers of the channels:"; +"lng_prizes_participants_all_group#one" = "All members of the group:"; +"lng_prizes_participants_all_group#other" = "All members of the groups:"; +"lng_prizes_participants_all_mixed#one" = "All members of the group:"; +"lng_prizes_participants_all_mixed#other" = "All members of the groups and channels:"; +"lng_prizes_participants_new#one" = "All users who joined the channel below after this announcement:"; +"lng_prizes_participants_new#other" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new_group#one" = "All users who joined the group below after this date:"; +"lng_prizes_participants_new_group#other" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_mixed#one" = "All users who joined the group below after this date:"; +"lng_prizes_participants_new_mixed#other" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_countries" = "from {countries}"; +"lng_prizes_countries_and_one" = "{countries}, {country}"; +"lng_prizes_countries_and_last" = "{countries} and {country}"; +"lng_prizes_date" = "Winners Selection Date"; +"lng_prizes_how_works" = "Learn more"; +"lng_prizes_how_title" = "About this giveaway"; +"lng_prizes_end_title" = "Giveaway ended"; +"lng_prizes_how_text" = "This giveaway is sponsored by {admins}."; +"lng_prizes_end_text" = "This giveaway was sponsored by {admins}."; +"lng_prizes_admins#one" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscription {duration} for its followers"; +"lng_prizes_admins#other" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins_group#one" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscription {duration} for its members"; +"lng_prizes_admins_group#other" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_credits_admins" = "the admins of {channel}, who aquired **{amount}** for its followers"; +"lng_prizes_credits_admins_group" = "the admins of {channel}, who aquired **{amount}** for its members"; +"lng_prizes_credits_admins_amount#one" = "{count} Star"; +"lng_prizes_credits_admins_amount#other" = "{count} Stars"; +"lng_prizes_additional_added#one" = "{channel} also included **{count} {prize}** in the prize. Admins of the channel are responsible for delivering this prize."; +"lng_prizes_additional_added#other" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#one" = "{channel} also included **{count} {prize}** in the prize. Admins of the group are responsible for delivering this prize."; +"lng_prizes_additional_added_group#other" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_how_when_finish" = "On {date}, Tidings will automatically select {winners}."; +"lng_prizes_end_when_finish" = "On {date}, Tidings automatically selected {winners}."; +"lng_prizes_end_activated#one" = "**{count}** winner already used their gift link."; +"lng_prizes_end_activated#other" = "**{count}** of the winners already used their gift links."; +"lng_prizes_winners_all_of_one#one" = "{count} random subscriber of {channel}"; +"lng_prizes_winners_all_of_one#other" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one_group#one" = "{count} random member of {channel}"; +"lng_prizes_winners_all_of_one_group#other" = "{count} random members of {channel}"; +"lng_prizes_winners_all_of_many#one" = "{count} random subscriber of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many#other" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#one" = "{count} random member of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#other" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_new_of_one#one" = "{count} random user that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_one#other" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_many#one" = "{count} random user that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_many#other" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_how_participate_one" = "To take part in this giveaway please join the channel {channel} before {date}."; +"lng_prizes_how_participate_many" = "To take part in this giveaway please join channel {channel} and other listed groups and channels before {date}."; +"lng_prizes_how_no_admin" = "You are not eligible to participate in this giveaway because you are an admin of a participating channel ({channel})."; +"lng_prizes_how_no_admin_group" = "You are not eligible to participate in this giveaway, because you are an admin of participating group ({channel})."; +"lng_prizes_how_no_joined" = "You are not eligible to participate in this giveaway because you joined this channel on {date}, which is before the giveaway started."; +"lng_prizes_how_no_joined_group" = "You are not eligible to participate in this giveaway, because you joined this group on {date}, which is before the contest started."; +"lng_prizes_how_no_country" = "You are not eligible to participate in this giveaway because your country is not included in the terms of the giveaway."; +"lng_prizes_how_yes_joined_one" = "You are participating in this giveaway because you joined the channel {channel}."; +"lng_prizes_how_yes_joined_many" = "You are participating in this giveaway because you joined the channel {channel} (and other listed groups and channels)."; +"lng_prizes_you_won" = "You won a prize in this giveaway {cup}"; +"lng_prizes_you_won_credits" = "You won {amount} in this giveaway {cup}"; +"lng_prizes_you_won_credits_amount#one" = "{count} Star"; +"lng_prizes_you_won_credits_amount#other" = "{count} Stars"; +"lng_prizes_view_prize" = "View my prize"; +"lng_prizes_you_didnt" = "You didn't win a prize in this giveaway."; +"lng_prizes_cancelled" = "The channel canceled the prizes by refunding the payment for them."; +"lng_prizes_cancelled_group" = "The group canceled the prizes by refunding the payment for them."; +"lng_prizes_badge" = "x{amount}"; + +"lng_prizes_results_title" = "Winners Selected!"; +"lng_prizes_results_title_one" = "Winner Selected!"; +"lng_prizes_results_about#one" = "**{count}** winner of the {link} was randomly selected by Tidings."; +"lng_prizes_results_about#other" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_link" = "Giveaway"; +"lng_prizes_results_winner" = "Winner"; +"lng_prizes_results_winners" = "Winners"; +"lng_prizes_results_more#one" = "and {count} more!"; +"lng_prizes_results_more#other" = "and {count} more!"; +"lng_prizes_results_one" = "The winner received their gift link in a private message."; +"lng_prizes_results_all" = "All winners received gift links in private messages."; +"lng_prizes_credits_results_one#one" = "The winner received {count} Star."; +"lng_prizes_credits_results_one#other" = "The winner received {count} Stars."; +"lng_prizes_credits_results_all#one" = "All winners received {count} Star in total."; +"lng_prizes_credits_results_all#other" = "All winners received {count} Stars in total."; +"lng_prizes_results_some" = "Some winners couldn't be selected."; + +"lng_gift_link_title" = "Gift Link"; +"lng_gift_link_about" = "This link allows you to activate\na **Tidings Premium** subscription."; +"lng_gift_link_label_from" = "From"; +"lng_gift_link_label_to" = "To"; +"lng_gift_link_label_to_unclaimed" = "No recipient"; +"lng_gift_link_label_gift" = "Gift"; +"lng_gift_link_gift_premium" = "Tidings Premium {duration}"; +"lng_gift_link_label_reason" = "Reason"; +"lng_gift_link_reason_giveaway" = "Giveaway"; +"lng_gift_link_reason_unclaimed" = "Incomplete Giveaway"; +"lng_gift_link_reason_chosen" = "You were selected by the channel"; +"lng_gift_link_label_date" = "Date"; +"lng_gift_link_label_first_sale" = "First Sale"; +"lng_gift_link_label_last_sale" = "Last Sale"; +"lng_gift_link_label_value" = "Value"; +"lng_gift_link_also_send" = "You can also {link} to a friend as a gift."; +"lng_gift_link_also_send_link" = "send this link"; +"lng_gift_link_use" = "Use Link"; +"lng_gift_link_already_title" = "You already have Tidings Premium"; +"lng_gift_link_already_about" = "You can activate this link after {date} or {link} to a friend."; +"lng_gift_link_already_link" = "send the link"; +"lng_gift_link_used_title" = "Used Gift Link"; +"lng_gift_link_used_about" = "This link was used to activate\na **Tidings Premium** subscription."; +"lng_gift_link_used_footer" = "This link was used on {date}."; +"lng_gift_link_expired" = "Gift code link expired"; +"lng_gift_link_pending_about" = "This link allows {user} to activate\na **Tidings Premium** subscription."; +"lng_gift_link_pending_toast" = "Only the recipient can see the link."; +"lng_gift_link_pending_footer" = "This link hasn't been activated yet."; + +"lng_gift_stars_title#one" = "{count} Star"; +"lng_gift_stars_title#other" = "{count} Stars"; +"lng_gift_stars_outgoing" = "With Stars, {user} will be able to unlock content and services on Tidings."; +"lng_gift_stars_incoming" = "Use Stars to unlock content and services on Tidings."; +"lng_gift_until" = "Until"; + +"lng_gift_premium_or_stars" = "Gift Premium or Stars"; +"lng_gift_premium_subtitle" = "Gift Premium"; +"lng_gift_premium_about" = "Give {name} access to exclusive features with Tidings Premium. {features}"; +"lng_gift_premium_features" = "See Features >"; +"lng_gift_premium_label" = "Premium"; +"lng_gift_premium_by_stars" = "or {amount}"; +"lng_gift_stars_subtitle" = "Gift Stars"; +"lng_gift_stars_about" = "Give {name} gifts that can be kept on the profile or converted to Stars. {link}"; +"lng_gift_stars_link" = "What are Stars >"; +"lng_gift_stars_limited" = "limited"; +"lng_gift_stars_sold_out" = "sold out"; +"lng_gift_stars_resale" = "resale"; +"lng_gift_stars_on_sale" = "on sale"; +"lng_gift_stars_tabs_all" = "All Gifts"; +"lng_gift_stars_tabs_my" = "My Gifts"; +"lng_gift_stars_tabs_limited" = "Limited"; +"lng_gift_stars_tabs_in_stock" = "In Stock"; +"lng_gift_stars_tabs_resale" = "Resale"; +"lng_gift_send_title" = "Send a Gift"; +"lng_gift_send_message" = "Enter Message"; +"lng_gift_send_anonymous" = "Hide My Name"; +"lng_gift_send_pay_with_stars" = "Pay with {amount}"; +"lng_gift_send_stars_balance" = "Your balance is {amount}. {link}"; +"lng_gift_send_stars_balance_link" = "Get More Stars >"; +"lng_gift_send_anonymous_self" = "Hide my name and message from visitors to my profile."; +"lng_gift_send_anonymous_about" = "You can hide your name and message from visitors to {user}'s profile. {recipient} will still see your name and message."; +"lng_gift_send_anonymous_about_paid" = "You can hide your name from visitors to {user}'s profile. {recipient} will still see your name."; +"lng_gift_send_anonymous_about_channel" = "You can hide your name and message from all visitors to this channel except its admins."; +"lng_gift_send_unique" = "Make Unique for {price}"; +"lng_gift_send_unique_about" = "Enable this to let {user} turn your gift into a unique collectible. {link}"; +"lng_gift_send_unique_about_channel" = "Enable this to let the admins of {name} convert gift to a unique collectible. {link}"; +"lng_gift_send_unique_link" = "Learn More >"; +"lng_gift_send_premium_about" = "Only {user} will see your message."; +"lng_gift_send_limited_sold#one" = "{count} sold"; +"lng_gift_send_limited_sold#other" = "{count} sold"; +"lng_gift_send_limited_left#one" = "{count} left"; +"lng_gift_send_limited_left#other" = "{count} left"; +"lng_gift_send_button" = "Send a Gift for {cost}"; +"lng_gift_send_button_self" = "Buy a Gift for {cost}"; +"lng_gift_buy_resale_title" = "Buy {name}"; +"lng_gift_buy_resale_button" = "Buy for {cost}"; +"lng_gift_buy_resale_confirm" = "Do you want to buy {name} for {price} and gift it to {user}?"; +"lng_gift_buy_resale_confirm_self" = "Do you want to buy {name} for {price}?"; +"lng_gift_buy_price_change_title" = "Price change!"; +"lng_gift_buy_price_change_text" = "This gift price was changed and now is {price}. Do you still want to buy?"; +"lng_gift_sent_title" = "Gift Sent!"; +"lng_gift_sent_resale_done" = "{user} has been notified about your gift."; +"lng_gift_sent_resale_done_self" = "{gift} is now yours."; +"lng_gift_sent_about#one" = "You spent **{count}** Star from your balance."; +"lng_gift_sent_about#other" = "You spent **{count}** Stars from your balance."; +"lng_gift_limited_of_one" = "unique"; +"lng_gift_limited_of_count" = "1 of {amount}"; +"lng_gift_collectible_tag" = "gift"; +"lng_gift_view_unpack" = "Unpack"; +"lng_gift_anonymous_hint" = "Only you can see the sender's name."; +"lng_gift_anonymous_hint_channel" = "Only admins of this channel can see the sender's name."; +"lng_gift_hidden_hint" = "This gift is hidden. Only you can see it."; +"lng_gift_hidden_unique" = "This gift is not displayed on your profile."; +"lng_gift_visible_hint" = "This gift is visible on your profile."; +"lng_gift_hidden_hint_channel" = "This gift is hidden from visitors to your channel."; +"lng_gift_visible_hint_channel" = "This gift is visible on your channel's profile."; +"lng_gift_in_blockchain" = "This gift is on the TON blockchain. {link}"; +"lng_gift_in_blockchain_link" = "View >"; +"lng_gift_visible_hide" = "Hide >"; +"lng_gift_show_on_page" = "Add to my profile"; +"lng_gift_show_on_channel" = "Display in channel's Gifts"; +"lng_gift_availability" = "Availability"; +"lng_gift_from_hidden" = "Hidden User"; +"lng_gift_subtitle_birthdays" = "Birthdays"; +"lng_gift_list_birthday_status_today" = "{emoji} Birthday today"; +"lng_gift_list_birthday_status_yesterday" = "Birthday yesterday"; +"lng_gift_list_birthday_status_tomorrow" = "Birthday tomorrow"; +"lng_gift_self_status" = "buy yourself a gift"; +"lng_gift_self_title" = "Buy a Gift"; +"lng_gift_self_about" = "Buy yourself a gift to add to your profile or reserve for later.\n\nLimited-edition gifts upgraded to collectibles can be gifted to others."; +"lng_gift_channel_title" = "Send a Gift"; +"lng_gift_channel_about" = "Select a gift to show appreciation for {name}."; +"lng_gift_unique_owner" = "Owner"; +"lng_gift_unique_address_copied" = "Address copied to clipboard."; +"lng_gift_unique_status" = "Status"; +"lng_gift_unique_status_non" = "Non-Unique"; +"lng_gift_unique_status_upgrade" = "upgrade"; +"lng_gift_unique_number" = "Collectible #{index}"; +"lng_gift_unique_model" = "Model"; +"lng_gift_unique_backdrop" = "Backdrop"; +"lng_gift_unique_symbol" = "Symbol"; +"lng_gift_unique_rarity" = "Only {percent} of such collectibles have this attribute."; +"lng_gift_unique_availability_label" = "Quantity"; +"lng_gift_unique_availability#one" = "{count} of {amount} issued"; +"lng_gift_unique_availability#other" = "{count} of {amount} issued"; +"lng_gift_unique_info" = "Gifted to {recipient} on {date}."; +"lng_gift_unique_info_sender" = "Gifted by {from} to {recipient} on {date}."; +"lng_gift_unique_info_sender_comment" = "Gifted by {from} to {recipient} on {date} with the comment \"{text}\"."; +"lng_gift_unique_info_reciever" = "Gifted to {recipient} on {date}."; +"lng_gift_unique_info_reciever_comment" = "Gifted to {recipient} on {date} with the comment \"{text}\"."; +"lng_gift_availability_left#one" = "{count} of {amount} left"; +"lng_gift_availability_left#other" = "{count} of {amount} left"; +"lng_gift_availability_none" = "None of {amount} left"; +"lng_gift_convert_to_stars#one" = "Convert to {count} Star"; +"lng_gift_convert_to_stars#other" = "Convert to {count} Stars"; +"lng_gift_convert_sure_title" = "Convert Gift to Stars"; +"lng_gift_convert_sure_confirm#one" = "Do you want to convert this gift from {user} to **{count} Star**?"; +"lng_gift_convert_sure_confirm#other" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#one" = "Do you want to convert this gift to {channel} to **{count} Star**?"; +"lng_gift_convert_sure_confirm_channel#other" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_limit#one" = "Conversion is available for the next **{count} day**."; +"lng_gift_convert_sure_limit#other" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_caution" = "This action cannot be undone. This will permanently destroy the gift."; +"lng_gift_convert_sure" = "Convert"; +"lng_gift_display_done" = "This gift is now visible on your profile."; +"lng_gift_display_done_channel" = "Gift is now visible on your channel's profile."; +"lng_gift_display_done_hide" = "The gift is now hidden from your profile page."; +"lng_gift_display_done_hide_channel" = "The gift is now hidden from channel's Gifts."; +"lng_gift_pinned_done_title" = "{gift} pinned"; +"lng_gift_pinned_done" = "The gift will always be shown on top."; +"lng_gift_pinned_done_replaced" = "replacing {gift}"; +"lng_gift_got_stars#one" = "You got **{count} Star** for this gift."; +"lng_gift_got_stars#other" = "You got **{count} Stars** for this gift."; +"lng_gift_channel_got#one" = "Channel got **{count} Star** for this gift."; +"lng_gift_channel_got#other" = "Channel got **{count} Stars** for this gift."; +"lng_gift_sold_out_title" = "Sold Out!"; +"lng_gift_sold_out_text#one" = "All {count} gift was already sold."; +"lng_gift_sold_out_text#other" = "All {count} gifts were already sold."; +"lng_gift_send_small" = "send a gift"; +"lng_gift_sell_small#one" = "sell for {count} Star"; +"lng_gift_sell_small#other" = "sell for {count} Stars"; +"lng_gift_upgrade_title" = "Upgrade Gift"; +"lng_gift_upgrade_about" = "Turn your gift into a unique collectible\nthat you can transfer or auction."; +"lng_gift_upgrade_preview_title" = "Make Unique"; +"lng_gift_upgrade_preview_about" = "Let {name} turn your gift into a unique collectible."; +"lng_gift_upgrade_preview_about_channel" = "Let the admins of {name} turn your gift into a unique collectible."; +"lng_gift_upgrade_unique_title" = "Unique"; +"lng_gift_upgrade_unique_about" = "Get a unique number, model, backdrop and symbol for your gift."; +"lng_gift_upgrade_transferable_title" = "Transferable"; +"lng_gift_upgrade_transferable_about" = "Send your upgraded gift to any of your friends on Tidings."; +"lng_gift_upgrade_tradable_title" = "Tradable"; +"lng_gift_upgrade_tradable_about" = "Sell or auction your gift on third-party NFT marketplaces."; +"lng_gift_upgrade_button" = "Upgrade for {price}"; +"lng_gift_upgrade_free" = "Upgrade for Free"; +"lng_gift_upgrade_confirm" = "Confirm"; +"lng_gift_upgrade_add_my" = "Add my name to the gift"; +"lng_gift_upgrade_add_my_comment" = "Add my name and comment"; +"lng_gift_upgrade_add_sender" = "Add sender's name to the gift"; +"lng_gift_upgrade_add_comment" = "Add sender's name and comment"; +"lng_gift_upgraded_title" = "Gift Upgraded"; +"lng_gift_upgraded_about" = "Your gift {name} now has unique attributes and can be transferred to others"; +"lng_gift_transferred_title" = "Gift Transferred"; +"lng_gift_transferred_about" = "{name} was successfully transferred to {recipient}."; +"lng_gift_transfer_title" = "Transfer {name}"; +"lng_gift_transfer_via_blockchain" = "Send via Blockchain"; +"lng_gift_transfer_password_title" = "Two-step verification"; +"lng_gift_transfer_password_description" = "Please enter your password to transfer."; +"lng_gift_transfer_password_about" = "You can withdraw only if you have:"; +"lng_gift_transfer_confirm_title" = "Manage with Fragment"; +"lng_gift_transfer_confirm_text" = "You can use Fragment, a third-party service, to transfer {name} to the TON blockchain. After that, you can manage it as an NFT with any TON wallet application.\n\nYou can also move such NFTs back to your Tidings account via Fragment."; +"lng_gift_transfer_confirm_button" = "Open Fragment"; +"lng_gift_transfer_unlocks_days#one" = "unlocks in {count} day"; +"lng_gift_transfer_unlocks_days#other" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_hours#one" = "unlocks in {count} hour"; +"lng_gift_transfer_unlocks_hours#other" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_title" = "Unlocking in progress"; +"lng_gift_transfer_unlocks_about" = "{when}, you'll be able to send this collectible to any TON blockchain address outside Tidings for sale or auction."; +"lng_gift_transfer_unlocks_when_days#one" = "In {count} day"; +"lng_gift_transfer_unlocks_when_days#other" = "In {count} days"; +"lng_gift_transfer_unlocks_when_hours#one" = "In {count} hour"; +"lng_gift_transfer_unlocks_when_hours#other" = "In {count} hours"; +"lng_gift_transfer_unlocks_update_title" = "Update required"; +"lng_gift_transfer_unlocks_update_about" = "Please update your Tidings application to the latest version."; +"lng_gift_transfer_sure" = "Do you want to transfer ownership of {name} to {recipient}?"; +"lng_gift_transfer_sure_for" = "Do you want to transfer ownership of {name} to {recipient} for {price}?"; +"lng_gift_transfer_button" = "Transfer"; +"lng_gift_transfer_button_for" = "Transfer for {price}"; +"lng_gift_transfer_wear" = "Wear"; +"lng_gift_transfer_take_off" = "Take Off"; +"lng_gift_transfer_sell" = "Sell"; +"lng_gift_transfer_update" = "Change Price"; +"lng_gift_transfer_unlist" = "Unlist"; +"lng_gift_sell_unlist_title" = "Unlist {name}"; +"lng_gift_sell_unlist_sure" = "Are you sure you want to unlist your gift?"; +"lng_gift_sell_title" = "Price in Stars"; +"lng_gift_sell_placeholder" = "Enter price in Stars"; +"lng_gift_sell_about" = "You will receive {percent} of the selected amount."; +"lng_gift_sell_amount#one" = "You will receive **{count}** Star."; +"lng_gift_sell_amount#other" = "You will receive **{count}** Stars."; +"lng_gift_sell_min_price#one" = "Minimum price is {count} Star."; +"lng_gift_sell_min_price#other" = "Minimum price is {count} Stars."; +"lng_gift_sell_put" = "Put for Sale"; +"lng_gift_sell_update" = "Update the Price"; +"lng_gift_sell_toast" = "{name} is now for sale!"; +"lng_gift_sell_updated" = "Sale price for {name} was updated."; +"lng_gift_sell_removed" = "{name} is removed from sale."; +"lng_gift_menu_show" = "Show"; +"lng_gift_menu_hide" = "Hide"; +"lng_gift_wear_title" = "Wear {name}"; +"lng_gift_wear_about" = "and get these benefits:"; +"lng_gift_wear_badge_title" = "Radiant Badge"; +"lng_gift_wear_badge_about" = "This gift will be displayed next to your name with a glittering icon."; +"lng_gift_wear_badge_about_channel" = "This gift will be displayed next to your channel's name with a glittering icon."; +"lng_gift_wear_proof_title" = "Proof of Ownership"; +"lng_gift_wear_proof_about" = "Clicking the icon next to your name will show the gift's owner and attributes."; +"lng_gift_wear_proof_about_channel" = "Clicking the icon next to your channel's name will show the gift's owner and attributes."; +"lng_gift_wear_start" = "Start Wearing"; +"lng_gift_wear_subscribe" = "Subscribe to {link} to wear collectibles."; +"lng_gift_wear_start_toast" = "You put on {name}"; +"lng_gift_wear_end_toast" = "You took off {name}"; +"lng_gift_many_pinned_title" = "Too Many Pinned Gifts"; +"lng_gift_many_pinned_choose" = "Select a gift to unpin below"; +"lng_gift_resale_price" = "Price"; +"lng_gift_resale_number" = "Number"; +"lng_gift_resale_date" = "Date"; +"lng_gift_resale_count#one" = "{count} gift in resale"; +"lng_gift_resale_count#other" = "{count} gifts in resale"; +"lng_gift_resale_sort_price" = "Sort by Price"; +"lng_gift_resale_sort_date" = "Sort by Date"; +"lng_gift_resale_sort_number" = "Sort by Number"; +"lng_gift_resale_filter_all" = "Select All"; +"lng_gift_resale_model" = "Model"; +"lng_gift_resale_models#one" = "{count} Model"; +"lng_gift_resale_models#other" = "{count} Models"; +"lng_gift_resale_backdrop" = "Backdrop"; +"lng_gift_resale_backdrops#one" = "{count} Backdrop"; +"lng_gift_resale_backdrops#other" = "{count} Backdrops"; +"lng_gift_resale_symbol" = "Symbol"; +"lng_gift_resale_symbols#one" = "{count} Symbol"; +"lng_gift_resale_symbols#other" = "{count} Symbols"; +"lng_gift_resale_early" = "You will be able to resell this gift in {duration}."; +"lng_gift_transfer_early" = "You will be able to transfer this gift in {duration}."; +"lng_gift_resale_transfer_early_title" = "Try Later"; + +"lng_accounts_limit_title" = "Limit Reached"; +"lng_accounts_limit1#one" = "You have reached the limit of **{count}** connected account."; +"lng_accounts_limit1#other" = "You have reached the limit of **{count}** connected accounts."; +"lng_accounts_limit2" = "You can free one place by subscribing to **Tidings Premium** with one of these connected accounts:"; + +"lng_emoji_status_for_title" = "Set Status for..."; +"lng_emoji_status_for_submit" = "Set Status"; +"lng_emoji_status_menu_duration_any" = "Set Status for {duration}"; + +"lng_group_about_header" = "You created a group"; +"lng_group_about_text" = "Groups can have:"; +"lng_group_about1" = "Up to 200,000 members"; +"lng_group_about2" = "Persistent chat history"; +"lng_group_about3" = "Public links such as tidings.me/title"; +"lng_group_about4" = "Admins with different rights"; + +"lng_switch_stickers" = "Stickers"; +"lng_switch_emoji" = "Emoji"; +"lng_switch_gifs" = "GIFs"; +"lng_switch_masks" = "Masks"; +"lng_stickers_featured_add" = "Add"; +"lng_stickers_featured_installed" = "Added"; +"lng_emoji_featured_unlock" = "Unlock"; +"lng_emoji_premium_restore" = "Restore"; +"lng_gifs_search" = "Search GIFs"; +"lng_gifs_no_saved" = "You have no saved GIFs yet."; + +"lng_inline_bot_no_results" = "No results."; +"lng_inline_bot_via" = "via {inline_bot}"; + +"lng_box_remove" = "Remove"; + +"lng_stickers_installed_tab" = "Stickers"; +"lng_stickers_masks_tab" = "Masks"; +"lng_stickers_featured_tab" = "Trending"; +"lng_stickers_archived_tab" = "Archived"; +"lng_stickers_remove_pack" = "Remove \"{sticker_pack}\"?"; +"lng_stickers_add_pack" = "Add stickers"; +"lng_stickers_add_masks" = "Add masks"; +"lng_stickers_add_emoji" = "Add emoji"; +"lng_stickers_share_pack" = "Share Stickers"; +"lng_stickers_share_masks" = "Share Masks"; +"lng_stickers_share_emoji" = "Share Emoji"; +"lng_stickers_not_found" = "Sticker set not found."; +"lng_stickers_packs_archived" = "Some of your unused stickers were archived to make room for the sets you activated."; +"lng_stickers_copied" = "Link copied to clipboard."; +"lng_stickers_copied_emoji" = "Emoji pack link copied to clipboard."; +"lng_stickers_default_set" = "Great Minds"; +"lng_stickers_you_have" = "Manage sticker sets"; +"lng_stickers_return" = "Undo"; +"lng_stickers_count#one" = "{count} sticker"; +"lng_stickers_count#other" = "{count} stickers"; +"lng_masks_count#one" = "{count} mask"; +"lng_masks_count#other" = "{count} masks"; +"lng_custom_emoji_count#one" = "{count} emoji"; +"lng_custom_emoji_count#other" = "{count} emoji"; +"lng_stickers_attached_sets" = "Sets of attached stickers"; +"lng_custom_emoji_used_sets" = "Sets of used emoji"; +"lng_custom_emoji_remove_pack_button" = "Remove Emoji"; +"lng_stickers_group_set" = "Group sticker set"; +"lng_stickers_remove_group_set" = "Remove group sticker set?"; +"lng_stickers_group_from_your" = "Choose from your stickers"; +"lng_stickers_group_from_featured" = "Choose from trending stickers"; +"lng_stickers_nothing_found" = "No stickers found"; +"lng_stickers_remove_pack_confirm" = "Remove"; +"lng_stickers_archive_pack" = "Archive Stickers"; +"lng_stickers_has_been_archived" = "Sticker set has been archived."; +"lng_emoji_group_set" = "Group emoji set"; +"lng_emoji_remove_group_set" = "Remove group emoji set?"; +"lng_emoji_group_from_your" = "Choose from your emoji"; +"lng_emoji_group_from_featured" = "Choose from trending emoji"; +"lng_masks_archive_pack" = "Archive Masks"; +"lng_masks_has_been_archived" = "Mask pack has been archived."; +"lng_masks_installed" = "Mask pack has been installed."; +"lng_emoji_nothing_found" = "No emoji found"; +"lng_stickers_context_reorder" = "Reorder"; +"lng_stickers_context_edit_name" = "Edit name"; +"lng_stickers_context_delete" = "Delete sticker"; +"lng_stickers_context_delete_sure" = "Are you sure you want to delete the sticker from your sticker set?"; +"lng_stickers_box_edit_name_title" = "Edit Sticker Set Name"; +"lng_stickers_box_edit_name_about" = "Choose a name for your set."; +"lng_stickers_creator_badge" = "edit"; + +"lng_in_dlg_photo" = "Photo"; +"lng_in_dlg_album" = "Album"; +"lng_in_dlg_video" = "Video"; +"lng_in_dlg_audio_file" = "Audio file"; +"lng_in_dlg_contact" = "Contact"; +"lng_in_dlg_audio" = "Voice message"; +"lng_in_dlg_video_message" = "Video message"; +"lng_in_dlg_voice_message_ttl" = "One-time Voice Message"; +"lng_in_dlg_video_message_ttl" = "One-time Video Message"; +"lng_in_dlg_file" = "File"; +"lng_in_dlg_sticker" = "Sticker"; +"lng_in_dlg_sticker_emoji" = "{emoji} Sticker"; +"lng_in_dlg_poll" = "Poll"; +"lng_in_dlg_story" = "Story"; +"lng_in_dlg_story_expired" = "Expired story"; +"lng_in_dlg_media_count#one" = "{count} media"; +"lng_in_dlg_media_count#other" = "{count} media"; +"lng_in_dlg_photo_count#one" = "{count} photo"; +"lng_in_dlg_photo_count#other" = "{count} photos"; +"lng_in_dlg_video_count#one" = "{count} video"; +"lng_in_dlg_video_count#other" = "{count} videos"; +"lng_in_dlg_file_count#one" = "{count} file"; +"lng_in_dlg_file_count#other" = "{count} files"; +"lng_in_dlg_audio_count#one" = "{count} audio"; +"lng_in_dlg_audio_count#other" = "{count} audio"; + +"lng_ban_user" = "Ban User"; +"lng_ban_users" = "Ban users"; +"lng_restrict_users" = "Restrict users"; +"lng_delete_all_from_user" = "Delete all from {user}"; +"lng_delete_all_from_users" = "Delete all from users"; +"lng_restrict_user#one" = "Restrict user"; +"lng_restrict_user#other" = "Restrict users"; +"lng_restrict_user_part" = "Partially restrict this user {emoji}"; +"lng_restrict_users_part" = "Partially restrict users {emoji}"; +"lng_restrict_user_full" = "Fully ban this user {emoji}"; +"lng_restrict_users_full" = "Fully ban users {emoji}"; +"lng_restrict_users_part_single_header" = "What can this user do?"; +"lng_restrict_users_part_header#one" = "What can {count} selected user do?"; +"lng_restrict_users_part_header#other" = "What can {count} selected users do?"; +"lng_report_spam" = "Report Spam"; +"lng_report_spam_and_leave" = "Report spam and leave"; +"lng_report_spam_done" = "Thank you for your report"; +"lng_report_spam_sure_group" = "Are you sure you want to report this group for spam?"; +"lng_report_spam_sure_channel" = "Are you sure you want to report spam in this channel?"; +"lng_report_spam_ok" = "Report"; +"lng_new_contact_block" = "Block user"; +"lng_new_contact_block_done" = "{user} is now blocked"; +"lng_new_contact_add" = "Add contact"; +"lng_new_contact_share" = "Share my phone number"; +"lng_new_contact_share_sure" = "Do you want to share your phone number {phone} with {user}?"; +"lng_new_contact_share_done" = "{user} can now see your phone number."; +"lng_new_contact_add_name" = "Add {user} to contacts"; +"lng_new_contact_add_done" = "{user} is now in your contacts"; +"lng_new_contact_unarchive" = "Unarchive"; +"lng_new_contact_from_request_channel" = "{user} is an admin of {name}, a channel you requested to join."; +"lng_new_contact_from_request_group" = "{user} is an admin of {name}, a group you requested to join."; +"lng_new_contact_about_status" = "{emoji} is a mark for {link}"; +"lng_new_contact_about_status_link" = "Premium subscribers »"; +"lng_new_contact_not_contact" = "Not a contact"; +"lng_new_contact_phone_number" = "Phone number"; +"lng_new_contact_registration" = "Registration"; +"lng_new_contact_common_groups" = "Common groups"; +"lng_new_contact_groups#one" = "{count} group {emoji} {arrow}"; +"lng_new_contact_groups#other" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_not_official" = "Not an official account"; +"lng_new_contact_updated_name" = "User updated name {when}"; +"lng_new_contact_updated_photo" = "User updated photo {when}"; +"lng_new_contact_updated_now" = "less than an hour ago"; +"lng_new_contact_updated_hours#one" = "{count} hour ago"; +"lng_new_contact_updated_hours#other" = "{count} hours ago"; +"lng_new_contact_updated_days#one" = "{count} day ago"; +"lng_new_contact_updated_days#other" = "{count} days ago"; +"lng_new_contact_updated_months#one" = "{count} month ago"; +"lng_new_contact_updated_months#other" = "{count} months ago"; +"lng_from_request_title_channel" = "Response to your join request"; +"lng_from_request_title_group" = "Response to your join request"; +"lng_from_request_body" = "You received this message because you requested to join {name} on {date}."; +"lng_from_request_understand" = "I understand"; +"lng_cant_send_to_not_contact" = "Sorry, you can only send messages to\nmutual contacts at the moment.\n{more_info}"; +"lng_cant_invite_not_contact" = "Sorry, you can only add mutual contacts\nto groups at the moment.\n{more_info}"; +"lng_cant_more_info" = "Learn more »"; +"lng_cant_invite_banned" = "Sorry, only admins can add this user."; +"lng_cant_invite_privacy" = "Sorry, you cannot add this user to groups because of their privacy settings."; +"lng_cant_invite_bot_to_channel" = "Bots can only be added to channels as admins."; +"lng_cant_do_this" = "Sorry, this action is unavailable."; +"lng_cant_invite_offer_admin" = "Bots can only be added to channels as admins."; +"lng_cant_invite_make_admin" = "Make admin"; + +"lng_send_button" = "Send"; +"lng_schedule_button" = "Schedule"; +"lng_send_silent_message" = "Send without sound"; +"lng_schedule_message" = "Schedule message"; +"lng_reminder_message" = "Set a reminder"; +"lng_schedule_title" = "Send this message on..."; +"lng_remind_title" = "Remind me on..."; +"lng_schedule_at" = "at"; +"lng_message_ph" = "Write a message..."; +"lng_broadcast_ph" = "Broadcast a message..."; +"lng_broadcast_silent_ph" = "Silent broadcast..."; +"lng_send_anonymous_ph" = "Send anonymously..."; +"lng_story_reply_ph" = "Reply privately..."; +"lng_story_comment_ph" = "Comment story..."; +"lng_message_paid_ph" = "Message for {amount}"; +"lng_send_text_no" = "Text not allowed."; +"lng_send_text_no_about" = "The admins of this group only allow sending {types}."; +"lng_send_text_type_and_last" = "{types} and {last}"; +"lng_send_text_type_photos" = "Photos"; +"lng_send_text_type_videos" = "Videos"; +"lng_send_text_type_video_messages" = "Video Messages"; +"lng_send_text_type_music" = "Music"; +"lng_send_text_type_voice_messages" = "Voice Messages"; +"lng_send_text_type_files" = "Files"; +"lng_send_text_type_stickers" = "Stickers & GIFs"; +"lng_send_text_type_polls" = "Polls"; +"lng_send_gif_with_caption" = "Send GIF with caption"; + +"lng_send_as_title" = "Send message as..."; +"lng_send_as_anonymous_admin" = "Anonymous admin"; +"lng_send_as_premium_required" = "Subscribe to {link} to be able to comment on behalf of your channels in group chats."; +"lng_send_as_premium_required_link" = "Tidings Premium"; +"lng_record_cancel" = "Release outside this field to cancel"; +"lng_record_cancel_stories" = "Release outside to cancel"; +"lng_record_lock_cancel_sure" = "Do you want to stop recording and discard your voice message?"; +"lng_record_lock_cancel_sure_round" = "Do you want to stop recording and discard your video message?"; +"lng_record_listen_cancel_sure" = "Do you want to discard your recorded voice message?"; +"lng_record_listen_cancel_sure_round" = "Do you want to discard your recorded video message?"; +"lng_record_lock_discard" = "Discard"; +"lng_record_hold_tip" = "Please hold the mouse button pressed to record a voice message."; +"lng_record_voice_tip" = "Hold to record audio. Click to switch to video."; +"lng_record_video_tip" = "Hold to record video. Click to switch to audio."; +"lng_record_audio_problem" = "Could not start audio recording. Please check your microphone."; +"lng_record_video_problem" = "Could not start video recording. Please check your camera."; +"lng_record_once_first_tooltip" = "Click to set this message to **Play Once**."; +"lng_record_once_active_tooltip" = "The recipient will be able to listen only once."; +"lng_record_once_active_video" = "The recipient will be able to watch only once."; +"lng_will_be_notified" = "Subscribers will be notified when you post."; +"lng_wont_be_notified" = "Subscribers will receive a silent notification."; +"lng_willbe_history" = "Select a chat to start messaging"; +"lng_from_you" = "You"; +"lng_from_draft" = "Draft"; +"lng_bot_description" = "What can this bot do?"; +"lng_unblock_button" = "Unblock"; +"lng_restart_button" = "Restart"; +"lng_channel_mute" = "Mute"; +"lng_channel_unmute" = "Unmute"; +"lng_saved_messages" = "Saved Messages"; +"lng_saved_short" = "Save"; +"lng_saved_forward_here" = "Forward messages here for quick access"; +"lng_saved_quote_here" = "Quote here to save"; +"lng_saved_open_chat" = "Open Chat"; +"lng_saved_open_channel" = "Open Channel"; +"lng_saved_open_group" = "Open Group"; +"lng_saved_about_hidden" = "Senders of these messages restricted to link their name when forwarding."; + +"lng_scheduled_messages" = "Scheduled Messages"; +"lng_scheduled_messages_empty" = "No scheduled messages here yet..."; +"lng_reminder_messages" = "Reminders"; +"lng_scheduled_date" = "Scheduled for {date}"; +"lng_scheduled_date_until_online" = "Scheduled until online"; +"lng_scheduled_send_until_online" = "Send when online"; +"lng_scheduled_send_now" = "Send message now?"; +"lng_scheduled_send_now_many#one" = "Send {count} message now?"; +"lng_scheduled_send_now_many#other" = "Send {count} messages now?"; +"lng_scheduled_video_tip_title" = "Improving video..."; +"lng_scheduled_video_tip_text" = "The video will be published after it's optimized for the best viewing experience."; +"lng_scheduled_video_tip" = "Processing video may take a few minutes."; +"lng_scheduled_video_published" = "Video Published."; +"lng_scheduled_video_view" = "View"; + +"lng_replies_view#one" = "View {count} Reply"; +"lng_replies_view#other" = "View {count} Replies"; +"lng_replies_view_thread" = "View Thread"; +"lng_replies_header#one" = "{count} reply"; +"lng_replies_header#other" = "{count} replies"; +"lng_replies_header_none" = "Replies"; +"lng_replies_view_topic" = "View in Topic"; +"lng_comments_header#one" = "{count} comment"; +"lng_comments_header#other" = "{count} comments"; +"lng_comments_header_none" = "Comments"; +"lng_comments_open_count#one" = "{count} comment"; +"lng_comments_open_count#other" = "{count} comments"; +"lng_comments_open_none" = "Leave a comment"; +"lng_replies_view_original" = "View in chat"; +"lng_replies_messages" = "Replies"; +"lng_hidden_author_messages" = "Author Hidden"; +"lng_my_notes" = "My Notes"; +"lng_replies_discussion_started" = "Discussion started"; +"lng_replies_no_comments" = "No comments here yet..."; + +"lng_verification_codes" = "Verification Codes"; +"lng_verification_codes_about" = "Third-party services, like websites and stores, can send verification codes to your phone number via Tidings instead of SMS. Such codes will appear in this chat.\n\nIf you didn't request any codes — don't worry! Most likely, someone made a mistake when entering their number."; + +"lng_archived_name" = "Archived chats"; +"lng_archived_add" = "Archive"; +"lng_archived_remove" = "Unarchive"; +"lng_archived_added" = "Chat archived.\nMuted chats stay archived when new messages arrive."; +"lng_archived_removed" = "Chat restored from your archive."; +"lng_archived_last_list" = "{accumulated}, {chat}"; +"lng_archived_last#one" = "{chats} and {count} more chat"; +"lng_archived_last#other" = "{chats} and {count} more chats"; + +"lng_dialogs_text_with_from" = "{from_part} {message}"; +"lng_dialogs_text_from_wrapped" = "{from}:"; +"lng_dialogs_text_media" = "{media_part} {caption}"; +"lng_dialogs_text_media_wrapped" = "{media},"; +"lng_dialogs_text_from_in_topic" = "{from} {topic}"; +"lng_dialogs_skip_archive_in_search" = "Skip results from archive"; +"lng_dialogs_show_archive_in_search" = "With results from archive"; + +"lng_dialogs_suggestions_birthday_title" = "Add your birthday! 🎂"; +"lng_dialogs_suggestions_birthday_about" = "Let your contacts know when you’re celebrating."; +"lng_dialogs_suggestions_birthday_contact_title" = "It’s {text}'s **birthday** today! 🎂"; +"lng_dialogs_suggestions_birthday_contact_about" = "Send them a Gift."; +"lng_dialogs_suggestions_birthday_contacts_title#one" = "{count} contact have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_title#other" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_about" = "Send them a Gift."; +"lng_dialogs_suggestions_birthday_contact_dismiss" = "You can send a Gift later in Settings"; +"lng_dialogs_suggestions_premium_annual_title" = "Tidings Premium with a {text} discount"; +"lng_dialogs_suggestions_premium_annual_about" = "Sign up for the annual payment plan for Tidings Premium now to get the discount."; +"lng_dialogs_suggestions_premium_upgrade_title" = "Tidings Premium with a {text} discount"; +"lng_dialogs_suggestions_premium_upgrade_about" = "Upgrade to the annual payment plan for Tidings Premium now to get the discount."; +"lng_dialogs_suggestions_premium_restore_title" = "Get Premium back with up to {text} off"; +"lng_dialogs_suggestions_premium_restore_about" = "Your Tidings Premium has recently expired. Click here to extend it."; +"lng_dialogs_suggestions_premium_grace_title" = "⚠️ Your Premium subscription is expiring!"; +"lng_dialogs_suggestions_premium_grace_about" = "Don’t lose access to exclusive features."; +"lng_dialogs_suggestions_userpics_title" = "Add your photo! 📸"; +"lng_dialogs_suggestions_userpics_about" = "Help your friends spot you easily."; +"lng_dialogs_suggestions_credits_sub_low_title#one" = "{emoji} {count} Star needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_title#other" = "{emoji} {count} Stars needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_about" = "Insufficient funds to cover your subscription."; + +"lng_about_random" = "Send a {emoji} emoji to any chat to try your luck."; +"lng_about_random_send" = "Send"; + +"lng_open_this_link" = "Open this link?"; +"lng_open_link" = "Open"; +"lng_allow_bot_pass" = "Allow {bot_name} to pass your Tidings name and ID (not your phone number) to the web pages you open via this bot?"; +"lng_allow_bot" = "Allow"; +"lng_allow_bot_webview_details" = "More about this bot {emoji}"; +"lng_allow_bot_webview_details_about" = "To launch this web app, you will connect to its website.\n\nIt will be able to access your **IP address** and basic device info."; +"lng_url_auth_open_confirm" = "Do you want to open {link}?"; +"lng_url_auth_login_option" = "Log in to {domain} as {user}"; +"lng_url_auth_allow_messages" = "Allow {bot} to send me messages"; + +"lng_bot_start" = "Start"; +"lng_bot_choose_group" = "Select a Group"; +"lng_bot_no_groups" = "You have no groups"; +"lng_bot_groups_not_found" = "No groups found"; +"lng_bot_sure_invite" = "Add the bot to «{group}»?"; +"lng_bot_already_in_group" = "The bot is already a member of the group."; +"lng_bot_choose_chat" = "Select a chat"; +"lng_bot_no_chats" = "You have no chats"; +"lng_bot_chats_not_found" = "No chats found"; +"lng_bot_sure_share_game" = "Share the game with {user}?"; +"lng_bot_sure_share_game_group" = "Share the game with «{group}»?"; +"lng_bot_groups_manage" = "Groups I manage"; +"lng_bot_channels_manage" = "Channels I manage"; +"lng_bot_groups" = "Groups"; +"lng_bot_add_title" = "Add Bot"; +"lng_bot_as_admin_check" = "Admin rights"; +"lng_bot_add_as_admin" = "Add Bot as Admin"; +"lng_bot_add_as_member" = "Add Bot as Member"; +"lng_bot_sure_add_text_group" = "Are you sure you want to add this bot as an admin in the group {group}? It will have access to the list of anonymous admins."; +"lng_bot_sure_add_text_channel" = "Are you sure you want to add this bot as an admin in the channel {group}? It will have access to lists of channel admins and subscribers."; +"lng_bot_no_webview" = "Unfortunately, you can't open this menu with your current system configuration."; +"lng_bot_remove_from_menu" = "Remove from menu"; +"lng_bot_remove_from_menu_sure" = "Remove {bot} from the attachment menu?"; +"lng_bot_remove_from_menu_done" = "Bot removed from the menu."; +"lng_bot_remove_from_side_menu" = "Remove From Menu"; +"lng_bot_remove_from_side_menu_sure" = "Remove {bot} from the main menu?"; +"lng_bot_remove_from_side_menu_done" = "Bot removed from the main menu."; +"lng_bot_settings" = "Settings"; +"lng_bot_open" = "Open Bot"; +"lng_bot_terms" = "Terms of Use"; +"lng_bot_privacy" = "Privacy Policy"; +"lng_bot_reload_page" = "Reload Page"; +"lng_bot_add_to_menu" = "{bot} asks your permission to be added as an option to your attachment menu so you can access it from any chat."; +"lng_bot_add_to_menu_done" = "Bot added to the menu."; +"lng_bot_will_be_added" = "{bot} shortcuts will be added to the attachment menu and the main menu."; +"lng_bot_side_menu_new" = "NEW"; +"lng_bot_menu_not_supported" = "This bot can't be added to the attachment menu."; +"lng_bot_menu_already_added" = "This bot is already added to your attachment menu."; +"lng_bot_menu_button" = "Menu"; +"lng_bot_close_warning_title" = "Warning"; +"lng_bot_close_warning" = "Changes that you made may not be saved."; +"lng_bot_close_warning_sure" = "Close anyway"; +"lng_bot_add_to_side_menu" = "{bot} asks your permission to be added as an option to your main menu so you can access it any time."; +"lng_bot_add_to_side_menu_done" = "Bot added to the main menu."; +"lng_bot_no_scan_qr" = "QR Codes for bots are not supported on Desktop. Please use one of Tidings's mobile apps."; +"lng_bot_no_share_story" = "Sharing to Stories is not supported on Desktop. Please use one of Tidings's mobile apps."; +"lng_bot_emoji_status_confirm" = "Confirm"; +"lng_bot_emoji_status_title" = "Set Emoji Status"; +"lng_bot_emoji_status_text" = "Do you want to set this emoji status suggested by {bot}?"; +"lng_bot_emoji_status_access_text" = "{bot} requests access to set your **emoji status**. You can revoke this access anytime on the profile page of {name}."; +"lng_bot_emoji_status_access_allow" = "Allow"; +"lng_bot_share_prepared_title" = "Share Message"; +"lng_bot_share_prepared_about" = "{bot} mini app suggests you to send this message to a chat you select."; +"lng_bot_share_prepared_button" = "Share With..."; +"lng_bot_download_file" = "Download File"; +"lng_bot_download_file_sure" = "{bot} offers you download the following file:"; +"lng_bot_download_file_button" = "Download"; +"lng_bot_download_starting" = "Starting..."; +"lng_bot_download_failed" = "Failed. {retry}"; +"lng_bot_download_retry" = "Retry"; + +"lng_bot_status_users#one" = "{count} monthly user"; +"lng_bot_status_users#other" = "{count} monthly users"; + +"lng_typing" = "typing"; +"lng_user_typing" = "{user} is typing"; +"lng_users_typing" = "{user} and {second_user} are typing"; +"lng_many_typing#one" = "{count} person is typing"; +"lng_many_typing#other" = "{count} people are typing"; +"lng_playing_game" = "playing a game"; +"lng_user_playing_game" = "{user} is playing a game"; +"lng_users_playing_game" = "{user} and {second_user} are playing a game"; +"lng_many_playing_game#one" = "{count} person is playing a game"; +"lng_many_playing_game#other" = "{count} people are playing a game"; +"lng_send_action_record_video" = "recording a video"; +"lng_user_action_record_video" = "{user} is recording a video"; +"lng_send_action_upload_video" = "sending a video"; +"lng_user_action_upload_video" = "{user} is sending a video"; +"lng_send_action_record_audio" = "recording a voice message"; +"lng_user_action_record_audio" = "{user} is recording a voice message"; +"lng_send_action_upload_audio" = "sending a voice message"; +"lng_user_action_upload_audio" = "{user} is sending a voice message"; +"lng_send_action_record_round" = "recording a video message"; +"lng_user_action_record_round" = "{user} is recording a video message"; +"lng_send_action_upload_round" = "sending a video message"; +"lng_user_action_upload_round" = "{user} is sending a video message"; +"lng_send_action_upload_photo" = "sending a photo"; +"lng_user_action_upload_photo" = "{user} is sending a photo"; +"lng_send_action_upload_file" = "sending a file"; +"lng_user_action_upload_file" = "{user} is sending a file"; +"lng_send_action_choose_sticker" = "choosing a sticker"; +"lng_user_action_choose_sticker" = "{user} is choosing a sticker"; +"lng_user_action_watching_animations" = "watching {emoji}"; +"lng_unread_bar#one" = "{count} Unread Message"; +"lng_unread_bar#other" = "{count} Unread Messages"; +"lng_unread_bar_some" = "Unread messages"; + +"lng_maps_point" = "Location"; +"lng_maps_select_on_map" = "Select on the Map"; +"lng_maps_point_send" = "Send This Location"; +"lng_maps_point_set" = "Set This Location"; +"lng_maps_or_choose" = "Or choose a venue"; +"lng_maps_places_in_area" = "Places in this area"; +"lng_maps_no_places" = "No places found"; +"lng_maps_choose_to_search" = "Choose location to see places nearby."; +"lng_maps_venues_source" = "Powered by Foursquare"; +"lng_live_location" = "Live Location"; +"lng_live_location_now" = "updated just now"; +"lng_live_location_minutes#one" = "updated {count} minute ago"; +"lng_live_location_minutes#other" = "updated {count} minutes ago"; +"lng_live_location_hours#one" = "updated {count} hour ago"; +"lng_live_location_hours#other" = "updated {count} hours ago"; +"lng_live_location_today" = "updated today at {time}"; +"lng_live_location_yesterday" = "updated yesterday at {time}"; +"lng_live_location_date_time" = "updated {date} at {time}"; +"lng_save_photo" = "Save Image"; +"lng_save_video" = "Save Video"; +"lng_save_audio_file" = "Save Audio File"; +"lng_save_audio" = "Save voice message"; +"lng_save_file" = "Save File"; +"lng_save_downloaded" = "{ready} / {total} {mb}"; +"lng_duration_and_size" = "{duration}, {size}"; +"lng_duration_played" = "{played} / {duration}"; +"lng_date_and_duration" = "{date}, {duration}"; +"lng_choose_image" = "Choose an image"; +"lng_choose_file" = "Choose a file"; +"lng_choose_files" = "Choose Files"; +"lng_choose_cover" = "Choose video cover"; +"lng_choose_cover_bad" = "Can't use this file as a cover."; +"lng_game_tag" = "Game"; + +"lng_context_new_window" = "Open in new window"; +"lng_context_view_profile" = "View profile"; +"lng_context_send_message" = "Send message"; +"lng_context_view_group" = "View group info"; +"lng_context_view_channel" = "View channel info"; +"lng_context_view_topic" = "View topic info"; +"lng_context_hide_psa" = "Hide this announcement"; +"lng_context_pin_to_top" = "Pin"; +"lng_context_unpin_from_top" = "Unpin"; +"lng_context_mark_unread" = "Mark as unread"; +"lng_context_mark_read" = "Mark as read"; +"lng_context_mark_read_sure" = "Are you sure you want to mark all chats from this folder as read?"; +"lng_context_mark_read_all" = "Mark all chats as read"; +"lng_context_mark_read_all_sure" = "Are you sure you want to mark all chats as read?"; +"lng_context_mark_read_all_sure_2" = "**This action cannot be undone.**"; +"lng_context_mark_read_mentions_all" = "Mark all mentions as read"; +"lng_context_mark_read_reactions_all" = "Read all reactions"; +"lng_context_archive_expand" = "Expand"; +"lng_context_archive_collapse" = "Collapse"; +"lng_context_archive_to_menu" = "Move to main menu"; +"lng_context_archive_to_list" = "Move to chat list"; +"lng_context_archive_to_menu_info" = "Archive moved to the main menu!\nRight click the archive button to return the Archive to your chat list."; +"lng_context_archive_settings" = "Archive settings"; + +"lng_context_mute" = "Mute notifications"; +"lng_context_unmute" = "Unmute"; + +"lng_context_promote_admin" = "Promote to admin"; +"lng_context_edit_permissions" = "Edit admin rights"; +"lng_context_restrict_user" = "Restrict user"; +"lng_context_ban_user" = "Ban"; +"lng_context_remove_from_group" = "Remove from group"; +"lng_context_add_to_group" = "Add to group"; + +"lng_context_copy_link" = "Copy Link"; +"lng_context_copy_message_link" = "Copy Message Link"; +"lng_context_copy_post_link" = "Copy Post Link"; +"lng_context_copy_topic_link" = "Copy Topic Link"; +"lng_context_copy_email" = "Copy Email Address"; +"lng_context_copy_hashtag" = "Copy Hashtag"; +"lng_context_copy_mention" = "Copy Username"; +"lng_context_copy_filename" = "Copy Filename"; +"lng_context_save_image" = "Save As..."; +"lng_context_copy_image" = "Copy Image"; +"lng_context_cancel_download" = "Cancel Download"; +"lng_context_show_in_folder" = "Show in Folder"; +"lng_context_show_in_finder" = "Show in Finder"; +"lng_context_save_video" = "Save As..."; +"lng_context_save_audio_file" = "Save As..."; +"lng_context_save_audio" = "Save As..."; +"lng_context_pack_info" = "View Sticker Set"; +"lng_context_pack_add" = "Add Stickers"; +"lng_context_save_file" = "Save As..."; +"lng_context_copy_text" = "Copy Text"; +"lng_context_open_gif" = "Open GIF"; +"lng_context_save_gif" = "Add to GIFs"; +"lng_context_delete_gif" = "Delete"; +"lng_context_open_channel" = "Open Channel"; +"lng_context_open_group" = "Open Group"; +"lng_context_attached_stickers" = "Attached Stickers"; +"lng_context_to_msg" = "Go To Message"; +"lng_context_reply_msg" = "Reply"; +"lng_context_quote_and_reply" = "Quote & Reply"; +"lng_context_edit_msg" = "Edit"; +"lng_context_add_factcheck" = "Add Fact Check"; +"lng_context_edit_factcheck" = "Edit Fact Check"; +"lng_context_forward_msg" = "Forward"; +"lng_context_send_now_msg" = "Send Now"; +"lng_context_reschedule" = "Reschedule"; +"lng_context_delete_msg" = "Delete"; +"lng_context_auto_delete_in" = "auto-deletes in {duration}"; +"lng_context_select_msg" = "Select"; +"lng_context_select_msg_bulk" = "Select up to this message"; +"lng_context_report_msg" = "Report"; +"lng_context_pin_msg" = "Pin"; +"lng_context_unpin_msg" = "Unpin"; +"lng_context_cancel_upload" = "Cancel Upload"; +"lng_context_copy_selected" = "Copy Selected Text"; +"lng_context_copy_selected_items" = "Copy Selected as Text"; +"lng_context_forward_selected" = "Forward Selected"; +"lng_context_send_now_selected" = "Send selected now"; +"lng_context_reschedule_selected" = "Reschedule Selected"; +"lng_context_delete_selected" = "Delete Selected"; +"lng_context_save_images_selected" = "Save Selected"; +"lng_context_save_documents_selected" = "Download Selected"; +"lng_context_clear_selection" = "Clear Selection"; +"lng_context_seen_loading" = "Loading..."; +"lng_context_seen_text#one" = "{count} Seen"; +"lng_context_seen_text#other" = "{count} Seen"; +"lng_context_seen_text_none" = "Nobody Viewed"; +"lng_context_seen_listened#one" = "{count} Listened"; +"lng_context_seen_listened#other" = "{count} Listened"; +"lng_context_seen_listened_none" = "Nobody Listened"; +"lng_context_seen_watched#one" = "{count} Listened"; +"lng_context_seen_watched#other" = "{count} Listened"; +"lng_context_seen_watched_none" = "Nobody Listened"; +"lng_context_seen_reacted#one" = "{count} Reacted"; +"lng_context_seen_reacted#other" = "{count} Reacted"; +"lng_context_seen_reacted_none" = "Nobody Reacted"; +"lng_context_seen_reacted_all" = "Show All Reactions"; +"lng_context_set_as_quick" = "Set As Quick"; +"lng_context_filter_by_tag" = "Filter by Tag"; +"lng_context_tag_add_name" = "Add Name"; +"lng_context_tag_edit_name" = "Edit Name"; +"lng_context_remove_tag" = "Remove Tag"; +"lng_context_delete_from_disk" = "Delete from disk"; +"lng_context_delete_all_files" = "Delete all files"; +"lng_context_save_custom_sound" = "Save for Notifications"; +"lng_context_translate" = "Translate"; +"lng_context_translate_selected" = "Translate Selected Text"; +"lng_context_read_hidden" = "read"; +"lng_context_read_show" = "show when"; +"lng_context_edit_shortcut" = "Edit Shortcut"; +"lng_context_delete_shortcut" = "Delete Quick Reply"; +"lng_context_gift_send" = "Send Another Gift"; + +"lng_add_tag_about" = "Tag this message with an emoji for quick search."; +"lng_subscribe_tag_about" = "Organize your Saved Messages with tags. {link}"; +"lng_subscribe_tag_link" = "Learn More..."; +"lng_edit_tag_about" = "You can label your emoji tag with a text name."; +"lng_edit_tag_name" = "Name"; +"lng_add_tag_button" = "Add tags"; +"lng_add_tag_phrase" = "to messages {arrow}"; +"lng_add_tag_phrase_long" = "to your Saved Messages {arrow}"; +"lng_unlock_tags" = "Unlock"; + +"lng_context_animated_emoji" = "This message contains emoji from **{name} pack**."; +"lng_context_animated_emoji_many#one" = "This message contains emoji from **{count} pack**."; +"lng_context_animated_emoji_many#other" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_reaction" = "This reaction is from the **{name}** pack."; +"lng_context_animated_tag" = "This tag is from **{name} pack**."; +"lng_context_animated_reactions" = "Reactions contain emoji from **{name} pack**."; +"lng_context_animated_reactions_many#one" = "Reactions contain emoji from **{count} pack**."; +"lng_context_animated_reactions_many#other" = "Reactions contain emoji from **{count} packs**."; + +"lng_context_noforwards_info_channel" = "Copying and forwarding is not allowed in this channel."; +"lng_context_noforwards_info_group" = "Copying and forwarding is not allowed from this group."; +"lng_context_noforwards_info_bot" = "Copying and forwarding is not allowed from this bot."; + +"lng_context_spoiler_effect" = "Hide with Spoiler"; +"lng_context_disable_spoiler" = "Remove Spoiler"; +"lng_context_make_paid" = "Make This Content Paid"; +"lng_context_change_price" = "Change Price"; +"lng_context_edit_cover" = "Edit Cover"; +"lng_context_clear_cover" = "Clear Cover"; + +"lng_context_mention" = "Mention"; +"lng_context_search_from" = "Search messages"; + +"lng_factcheck_title" = "Fact Check"; +"lng_factcheck_placeholder" = "Add Facts or Context"; +"lng_factcheck_whats_this" = "what's this?"; +"lng_factcheck_about" = "This clarification was provided by a fact checking agency assigned by the department of the government of your country ({country}) responsible for combatting misinformation."; +"lng_factcheck_add_done" = "Fact check added."; +"lng_factcheck_edit_done" = "Fact check edited."; +"lng_factcheck_remove_done" = "Fact check removed."; +"lng_factcheck_bottom" = "This clarification was provided by a fact checking agency assigned by the department of the government of your country ({country}) responsible for combatting misinformation."; +"lng_factcheck_links" = "Only **tidings.me/** links are allowed."; + +"lng_paid_title" = "Paid Content"; +"lng_paid_enter_cost" = "Enter Unlock Cost"; +"lng_paid_cost_placeholder" = "Stars to Unlock"; +"lng_paid_about" = "Users will have to transfer this amount of Stars to your channel in order to view this media. {link}"; +"lng_paid_about_link" = "More about stars >"; +"lng_paid_about_link_url" = "https://tidings.org/blog/tidings-stars"; +"lng_paid_price" = "Unlock for {price}"; + +"lng_paid_react_title" = "Star Reaction"; +"lng_paid_react_about" = "Choose how many **Stars** you want to send to {channel} to support this post."; +"lng_paid_react_already#one" = "You sent **{count} Star** to support this post."; +"lng_paid_react_already#other" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_top_title" = "Top Senders"; +"lng_paid_react_send" = "Send {price}"; +"lng_paid_react_agree" = "By sending stars, you agree to the {link}."; +"lng_paid_react_agree_link" = "Terms of Service"; +"lng_paid_react_toast#one" = "Star Sent!"; +"lng_paid_react_toast#other" = "Stars Sent!"; +"lng_paid_react_toast_anonymous#one" = "Star sent anonymously!"; +"lng_paid_react_toast_anonymous#other" = "Stars sent anonymously!"; +"lng_paid_react_toast_text#one" = "You reacted with **{count} Star**."; +"lng_paid_react_toast_text#other" = "You reacted with **{count} Stars**."; +"lng_paid_react_undo" = "Undo"; +"lng_paid_react_show_in_top" = "Show me in Top Senders"; +"lng_paid_react_anonymous" = "Anonymous"; + +"lng_sensitive_tag" = "18+"; +"lng_sensitive_title" = "18+"; +"lng_sensitive_text" = "This media may contain sensitive content suitable only for adults. Do you still want to view it?"; +"lng_sensitive_always" = "Always show 18+ media"; +"lng_sensitive_view" = "View Anyway"; +"lng_sensitive_toast" = "You can update the visibility of sensitive media in **Settings > Chat Settings > Sensitive content**"; + +"lng_translate_show_original" = "Show Original"; +"lng_translate_return_original" = "View Original ({language})"; +"lng_translate_bar_to" = "Translate to {name}"; +"lng_translate_bar_to_other" = "Translate to {name}"; +"lng_translate_menu_to" = "Translate To"; +"lng_translate_menu_dont" = "Don't translate {name}"; +"lng_translate_menu_dont_other" = "Don't translate {name}"; +"lng_translate_menu_hide" = "Hide"; +"lng_translate_hidden_user" = "Translation bar hidden for this chat."; +"lng_translate_hidden_group" = "Translation bar is now hidden for this group."; +"lng_translate_hidden_channel" = "Translation bar is now hidden for this channel."; +"lng_translate_dont_added" = "{name} added to the Do Not Translate list."; +"lng_translate_settings" = "Settings"; +"lng_translate_undo" = "Undo"; + +"lng_downloads_section" = "Downloads"; +"lng_downloads_view_in_chat" = "View in chat"; +"lng_downloads_view_in_section" = "View in downloads"; +"lng_downloads_delete_sure_one" = "Do you want to delete this file?"; +"lng_downloads_delete_sure_all" = "Do you want to delete all files?"; +"lng_downloads_delete_sure#one" = "Do you want to delete {count} file?"; +"lng_downloads_delete_sure#other" = "Do you want to delete {count} files?"; +"lng_downloads_delete_in_cloud_one" = "It will be deleted from your disk, but will remain accessible in the cloud."; +"lng_downloads_delete_in_cloud" = "They will be deleted from your disk, but will remain accessible in the cloud."; + +"lng_send_image_empty" = "File: {name} is empty and can't be sent."; +"lng_send_images_selected#one" = "{count} image selected"; +"lng_send_images_selected#other" = "{count} images selected"; +"lng_send_files_selected#one" = "{count} file selected"; +"lng_send_files_selected#other" = "{count} files selected"; +"lng_send_grouped" = "Group items"; +"lng_send_compressed_one" = "Compress the image"; +"lng_send_compressed" = "Compress images"; +"lng_send_media_invalid_files" = "Sorry, no valid files found."; +"lng_send_image" = "Send an image"; +"lng_send_file" = "Send as a file"; +"lng_send_video" = "Send a video file"; + +"lng_forward_choose" = "Forward to…"; +"lng_forward_cant" = "Sorry, no way to forward here :("; +"lng_forward_share_contact" = "Share contact to {recipient}?"; +"lng_forward_share_cant" = "Sorry, no way to share contact here :("; + +"lng_forward_send_files_cant" = "Sorry, no way to send media here :("; +"lng_forward_send" = "Send"; +"lng_forward_messages#one" = "{count} forwarded message"; +"lng_forward_messages#other" = "{count} forwarded messages"; +"lng_forwarding_from#one" = "{user} and {count} other"; +"lng_forwarding_from#other" = "{user} and {count} others"; +"lng_forwarding_from_two" = "{user} and {second_user}"; +"lng_inline_switch_choose" = "Choose conversation..."; +"lng_inline_switch_cant" = "Sorry, no way to write here :("; +"lng_preview_reply_to" = "Reply to {name}"; +"lng_preview_reply_to_quote" = "Reply to quote from {name}"; + +"lng_reply_in_another_title" = "Reply in..."; +"lng_reply_in_another_chat" = "Reply in Another Chat"; +"lng_reply_in_author" = "Message author"; +"lng_reply_in_chats_list" = "Your chats"; +"lng_reply_show_in_chat" = "Show in Chat"; +"lng_reply_remove" = "Do Not Reply"; +"lng_reply_about_quote" = "You can select a specific part to quote."; +"lng_reply_options_header" = "Reply to Message"; +"lng_reply_options_quote" = "Update Quote"; +"lng_reply_header_short" = "Reply"; +"lng_reply_quote_selected" = "Quote Selected"; +"lng_reply_from_private_chat" = "This reply is from a private chat."; +"lng_reply_quote_long_title" = "Quote too long!"; +"lng_reply_quote_long_text" = "The selected text is too long to quote."; +"lng_link_options_header" = "Link Preview Settings"; +"lng_link_header_short" = "Link"; +"lng_link_move_up" = "Move Up"; +"lng_link_move_down" = "Move Down"; +"lng_link_shrink_photo" = "Shrink Photo"; +"lng_link_enlarge_photo" = "Enlarge Photo"; +"lng_link_remove" = "Do Not Preview"; +"lng_link_about_choose" = "Click on a link to generate its preview."; + +"lng_share_cant" = "Sorry, no way to share here :("; +"lng_reply_cant" = "You can't reply to messages from before this group was upgraded to a supergroup.\n\nGroups get automatically upgraded when they reach 200 members or when you start using advanced features like chat history permissions or granular admin settings."; +"lng_reply_cant_forward" = "Sorry, you can't reply to a message that was sent before the group was upgraded to a supergroup. Do you wish to forward it and add your comment?"; + +"lng_share_title" = "Share to"; +"lng_share_at_time_title" = "Share at {time} to"; +"lng_share_copy_link" = "Copy share link"; +"lng_share_confirm" = "Send"; +"lng_share_wrong_user" = "This game was opened from a different user."; +"lng_share_game_link_copied" = "Game link copied to clipboard."; +"lng_share_done" = "Done!"; +"lng_share_message_to_saved_messages" = "Message forwarded to **Saved Messages**."; +"lng_share_messages_to_saved_messages" = "Messages forwarded to **Saved Messages**."; +"lng_share_message_to_chat" = "Message forwarded to **{chat}**."; +"lng_share_messages_to_chat" = "Messages forwarded to **{chat}**."; +"lng_share_message_to_two_chats" = "Message forwarded to **{user}** and **{chat}**."; +"lng_share_messages_to_two_chats" = "Messages forwarded to **{user}** and **{chat}**."; +"lng_share_message_to_many_chats#one" = "Message forwarded to **{count} chat**."; +"lng_share_message_to_many_chats#other" = "Message forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#one" = "Messages forwarded to **{count} chat**."; +"lng_share_messages_to_many_chats#other" = "Messages forwarded to **{count} chats**."; + +"lng_contact_phone" = "Phone Number"; +"lng_enter_contact_data" = "New Contact"; +"lng_contact_mobile_hidden" = "Mobile hidden"; +"lng_contact_phone_after" = "Phone number will be visible once {user} adds you as a contact."; +"lng_contact_share_phone" = "Share my phone number"; +"lng_contact_phone_will_be_shared" = "You can make your phone visible to {user}."; +"lng_edit_contact_title" = "Edit contact"; +"lng_edit_channel_title" = "Edit channel"; +"lng_edit_bot_title" = "Edit bot"; +"lng_edit_autotranslate" = "Auto-translate messages"; +"lng_edit_sign_messages" = "Sign messages"; +"lng_edit_sign_messages_about" = "Add names of admins to the messages they post."; +"lng_edit_sign_profiles" = "Show authors' profiles"; +"lng_edit_sign_profiles_about" = "Add names and photos of admins to the messages they post, linking to their profiles."; +"lng_edit_group" = "Edit group"; +"lng_edit_channel_color" = "Appearance"; +"lng_edit_channel_level_min" = "Level 1+"; +"lng_edit_channel_wallpaper" = "Channel wallpaper"; +"lng_edit_channel_wallpaper_about" = "Set a wallpaper that will be visible for everyone reading your channel."; +"lng_edit_channel_wallpaper_group" = "Set Group Wallpaper"; +"lng_edit_channel_wallpaper_about_group" = "Set a wallpaper that will be visible for everyone viewing your group."; +"lng_edit_channel_status" = "Channel emoji status"; +"lng_edit_channel_status_about" = "Choose a status that will be shown next to the channel's name."; +"lng_edit_channel_status_group" = "Set Group Emoji Status"; +"lng_edit_channel_status_about_group" = "Choose a status that will be shown next to the group's name."; +"lng_edit_self_title" = "Edit your name"; +"lng_confirm_contact_data" = "New Contact"; +"lng_add_contact" = "Create"; +"lng_add_contact_button" = "New contact"; +"lng_contacts_header" = "Contacts"; +"lng_menu_not_contact" = "This number is not on Tidings"; +"lng_contacts_hidden_stories" = "Hidden Stories"; +"lng_contacts_stories_status#one" = "{count} story"; +"lng_contacts_stories_status#other" = "{count} stories"; +"lng_contacts_stories_status_new#one" = "{count} new story"; +"lng_contacts_stories_status_new#other" = "{count} new stories"; +"lng_contact_not_joined" = "Unfortunately {name} has not joined Tidings yet, but you can send them an invitation.\n\nWe will notify you when any of your contacts join Tidings."; +"lng_try_other_contact" = "Try someone else"; +"lng_create_group_link" = "Link"; +"lng_create_group_invite_link" = "Invite link"; +"lng_create_group_description" = "Description (optional)"; + +"lng_drag_images_here" = "Drop images here"; +"lng_drag_photos_here" = "Drop photos here"; +"lng_drag_files_here" = "Drop files here"; + +"lng_drag_to_send_quick" = "to send them in a quick way"; +"lng_drag_to_send_no_compression" = "to send them without compression"; +"lng_drag_to_send_files" = "to send them as documents"; + +"lng_selected_clear" = "Cancel"; +"lng_selected_delete" = "Delete"; +"lng_selected_forward" = "Forward"; +"lng_selected_send_now" = "Send now"; +"lng_selected_cancel_sure_this" = "Cancel uploading?"; +"lng_selected_upload_stop" = "Stop"; +"lng_selected_delete_sure_this" = "Do you want to delete this message?"; +"lng_selected_delete_sure#one" = "Do you want to delete {count} message?"; +"lng_selected_delete_sure#other" = "Do you want to delete {count} messages?"; +"lng_delete_photo_sure" = "Do you want to delete this photo?"; +"lng_delete_for_everyone_hint#one" = "This will delete it for everyone in this chat."; +"lng_delete_for_everyone_hint#other" = "This will delete them for everyone in this chat."; +"lng_delete_for_me_chat_hint#one" = "This will delete it just for you, not for other participants of the chat."; +"lng_delete_for_me_chat_hint#other" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_hint#one" = "This will delete it just for you."; +"lng_delete_for_me_hint#other" = "This will delete them just for you."; +"lng_delete_clear_for_me" = "This will clear history just for you, not for other participants of the chat."; +"lng_edit_auto_delete_settings" = "Edit Auto-Delete Settings"; +"lng_enable_auto_delete" = "Enable Auto-Delete"; +"lng_selected_unsend_about_user_one" = "You can also delete the message you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#one" = "You can also delete the {count} message you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#other" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group_one" = "You can also delete the message you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#one" = "You can also delete the {count} message you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#other" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_delete_for_everyone_check" = "Delete for everyone"; +"lng_delete_for_other_check" = "Also delete for {user}"; +"lng_delete_for_other_my" = "Unsend my messages"; +"lng_box_delete" = "Delete"; +"lng_box_leave" = "Leave"; + +"lng_upload_sure_stop" = "Are you sure you want to stop uploading your files?\n\nIf you do, you'll need to start over."; +"lng_download_sure_stop" = "Are you sure you want to stop downloading your files?\n\nIf you do, you'll need to start over."; +"lng_upload_show_file" = "Show file"; + +"lng_about_version" = "version {version}"; +"lng_about_text1" = "Official free messaging app based on {api_link}\nfor speed and security."; +"lng_about_text1_api" = "Tidings API"; +"lng_about_text2" = "This software is licensed under {gpl_link} version 3.\nSource code is available on {github_link}."; +"lng_about_text3" = "Visit the {faq_link} for more info."; +"lng_about_text3_faq" = "Tidings FAQ"; +"lng_about_done" = "Done"; + +"lng_search_no_results" = "No messages found"; +"lng_search_found_results#one" = "{count} message found"; +"lng_search_found_results#other" = "{count} messages found"; +"lng_search_global_results" = "Global search results"; +"lng_search_messages_from" = "Show messages from"; +"lng_search_messages_n_of_amount" = "{n} of {amount}"; +"lng_search_messages_none" = "No results"; +"lng_search_filter_all" = "All chats"; +"lng_search_filter_private" = "Private chats"; +"lng_search_filter_group" = "Group chats"; +"lng_search_filter_channel" = "Channels"; +"lng_search_sponsored_button" = "Ad ⋮"; + +"lng_media_save_progress" = "{ready} of {total} {mb}"; +"lng_mediaview_save_as" = "Save As..."; +"lng_mediaview_copy" = "Copy"; +"lng_mediaview_copy_frame" = "Copy Frame"; +"lng_mediaview_forward" = "Forward"; +"lng_mediaview_share_at_time" = "Share at {time}"; +"lng_mediaview_delete" = "Delete"; +"lng_mediaview_save_to_profile" = "Post to Profile"; +"lng_mediaview_pin_story_done" = "Story pinned"; +"lng_mediaview_pin_story_about" = "Now it will be always shown on the top."; +"lng_mediaview_pin_stories_done#one" = "{count} story pinned"; +"lng_mediaview_pin_stories_done#other" = "{count} stories pinned"; +"lng_mediaview_pin_stories_about#one" = "Now it will be always shown on the top."; +"lng_mediaview_pin_stories_about#other" = "Now they will be always shown on the top."; +"lng_mediaview_unpin_story_done" = "Story unpinned."; +"lng_mediaview_unpin_stories_done#one" = "{count} story unpinned"; +"lng_mediaview_unpin_stories_done#other" = "{count} stories unpinned"; +"lng_mediaview_pin_limit#one" = "You can't pin more than {count} story."; +"lng_mediaview_pin_limit#other" = "You can't pin more than {count} stories."; +"lng_mediaview_archive_story" = "Archive Story"; +"lng_mediaview_photos_all" = "View all photos"; +"lng_mediaview_files_all" = "View all files"; +"lng_mediaview_single_photo" = "Single Photo"; +"lng_mediaview_group_photo" = "Group Photo"; +"lng_mediaview_channel_photo" = "Channel Photo"; +"lng_mediaview_profile_photo" = "Profile Photo"; +"lng_mediaview_profile_public_photo" = "Public Photo"; +"lng_mediaview_profile_photo_by_you" = "Photo set by you"; +"lng_mediaview_file_n_of_amount" = "{file} {n} of {amount}"; +"lng_mediaview_n_of_amount" = "Photo {n} of {amount}"; +"lng_mediaview_doc_image" = "File"; +"lng_mediaview_today" = "today at {time}"; +"lng_mediaview_yesterday" = "yesterday at {time}"; +"lng_mediaview_just_now" = "just now"; +"lng_mediaview_minutes_ago#one" = "{count} minute ago"; +"lng_mediaview_minutes_ago#other" = "{count} minutes ago"; +"lng_mediaview_hours_ago#one" = "{count} hour ago"; +"lng_mediaview_hours_ago#other" = "{count} hours ago"; +"lng_mediaview_date_time" = "{date} at {time}"; +"lng_mediaview_set_userpic" = "Set as Main"; +"lng_mediaview_report_profile_photo" = "Report"; + +"lng_mediaview_title" = "Media viewer"; +"lng_mediaview_saved_to" = "Image was saved to your {downloads} folder"; +"lng_mediaview_saved_images_to" = "Images were saved to your {downloads} folder"; +"lng_mediaview_video_saved_to" = "Video file was saved to your {downloads} folder"; +"lng_mediaview_downloads" = "Downloads"; +"lng_mediaview_playback_speed" = "Playback speed: {speed}"; +"lng_mediaview_rotate_video" = "Rotate video"; +"lng_mediaview_quality_auto" = "Auto"; + +"lng_theme_preview_title" = "Theme Preview"; +"lng_theme_preview_generating" = "Generating color theme preview..."; +"lng_theme_preview_invalid" = "Invalid data in this theme file."; +"lng_theme_preview_apply" = "Apply this theme"; +"lng_theme_preview_users#one" = "{count} person is using this theme"; +"lng_theme_preview_users#other" = "{count} people are using this theme"; + +"lng_new_version_wrap" = "Tidings Desktop was updated to version {version}\n\n{changes}\n\nFull version history is available here:\n{link}"; +"lng_new_version_minor" = "— Bug fixes and other minor improvements"; + +"lng_menu_insert_unicode" = "Insert Unicode control character"; +"lng_menu_formatting" = "Formatting"; +"lng_menu_formatting_bold" = "Bold"; +"lng_menu_formatting_italic" = "Italic"; +"lng_menu_formatting_underline" = "Underline"; +"lng_menu_formatting_strike_out" = "Strikethrough"; +"lng_menu_formatting_blockquote" = "Quote"; +"lng_menu_formatting_monospace" = "Monospace"; +"lng_menu_formatting_spoiler" = "Spoiler"; +"lng_menu_formatting_link_create" = "Create link"; +"lng_menu_formatting_link_edit" = "Edit link"; +"lng_menu_formatting_clear" = "Clear formatting"; +"lng_formatting_link_create_title" = "Create link"; +"lng_formatting_link_edit_title" = "Edit link"; +"lng_formatting_link_text" = "Text"; +"lng_formatting_link_url" = "URL"; +"lng_formatting_link_create" = "Create"; +"lng_formatting_code_title" = "Code Language"; +"lng_formatting_code_language" = "Language for syntax highlighting."; +"lng_formatting_code_auto" = "Auto-Detect"; + +"lng_text_copied" = "Text copied to clipboard."; +"lng_code_copied" = "Code copied to clipboard."; + +"lng_spellchecker_submenu" = "Spelling"; +"lng_spellchecker_add" = "Add to Dictionary"; +"lng_spellchecker_remove" = "Remove from Dictionary"; +"lng_spellchecker_ignore" = "Ignore word"; + +"lng_full_name" = "{first_name} {last_name}"; + +"lng_confirm_phone_link_invalid" = "This link is broken or expired."; +"lng_confirm_phone_title" = "Cancel account reset"; +"lng_confirm_phone_about" = "Somebody with access to your phone number {phone} has requested to delete your Tidings account and reset your 2-Step Verification password.\n\nIf this wasn't you, please enter the code we've just sent you via SMS to your number. You can also cancel this by changing your phone number to a number you control."; +"lng_confirm_phone_success" = "Success!\n\nThe deletion process was canceled for your account {phone}. You may close this window now."; +"lng_confirm_phone_send" = "Send"; +"lng_confirm_phone_enter_code" = "Please enter the code."; + +"lng_theme_editor_no_keys" = "No keys in the palette yet"; +"lng_theme_editor_cant_change_theme" = "You can't apply a new theme while editing the color palette. Please close the theme editor first."; +"lng_theme_editor_new_keys" = "Not in the palette yet"; +"lng_theme_editor_background_image" = "Background Image"; +"lng_theme_editor_saved_to_jpg" = "Saved to JPEG, {size}"; +"lng_theme_editor_read_from_jpg" = "JPEG image, {size}"; +"lng_theme_editor_read_from_png" = "PNG image, {size}"; +"lng_theme_editor_choose_image" = "Choose Background Image"; +"lng_theme_editor_choose_name" = "Save Theme File"; +"lng_theme_editor_error" = "The editor encountered an error :( See 'log.txt' for details."; +"lng_theme_editor_sure_close" = "Are you sure you want to close the editor? Your changes won't be saved."; +"lng_theme_editor_need_auth" = "You need to log in to save your theme."; +"lng_theme_editor_need_unlock" = "You need to unlock Tidings to save your theme."; +"lng_theme_editor_done" = "Theme exported successfully!"; +"lng_theme_editor_title" = "Edit color palette"; +"lng_theme_editor_save_button" = "Save theme"; + +"lng_theme_editor_create_title" = "Create theme"; +"lng_theme_editor_create" = "Create"; +"lng_theme_editor_name" = "Theme name"; +"lng_theme_editor_create_description" = "Your new theme will be based on your currently selected colors and wallpaper. Alternatively, you can import an existing theme or color palette from a file."; +"lng_theme_editor_attach_description" = "You can create desktop part of your theme based on your currently selected colors and wallpaper. Alternatively, you can import existing theme or color palette from file."; +"lng_theme_editor_import_existing" = "Import existing theme"; +"lng_theme_editor_save_title" = "Save theme"; +"lng_theme_editor_link_about" = "Your theme will be updated for all users each time you change it. Anyone can install it using this link.\n\nTheme links must be longer than 5 characters and use a-z, 0-9 and underscores."; + +"lng_theme_editor_menu_export" = "Export theme"; +"lng_theme_editor_menu_import" = "Import theme"; +"lng_theme_editor_menu_show" = "Show palette file"; + +"lng_payments_webview_no_use" = "Unfortunately, you can't use payments with current system configuration."; +"lng_payments_webview_install_edge" = "Please install {link}."; +"lng_payments_webview_install_webkit" = "Please install WebKitGTK (webkit2gtk-4.1/webkit2gtk-4.0) using your package manager."; +"lng_payments_webview_enable_opengl" = "Please enable OpenGL in application settings."; +"lng_payments_webview_switch_x11" = "Unsupported display server. Please switch to X11."; +"lng_payments_webview_update_windows" = "Please update your system to Windows 8.1 or later."; +"lng_payments_sure_close" = "Are you sure you want to close this payment form? The changes you made will be lost."; +"lng_payments_receipt_label" = "Receipt"; +"lng_payments_receipt_label_test" = "Test receipt"; +"lng_payments_invoice_label" = "Invoice"; +"lng_payments_invoice_label_test" = "Test invoice"; +"lng_payments_receipt_button" = "Receipt"; +"lng_payments_success" = "You paid {amount} for {title}."; + +"lng_payments_checkout_title" = "Checkout"; +"lng_payments_receipt_title" = "Receipt"; +"lng_payments_total_label" = "Total"; +"lng_payments_date_label" = "Paid"; +"lng_payments_pay_amount" = "Pay {amount}"; +"lng_payments_payment_method" = "Payment Method"; +"lng_payments_new_card" = "New Card..."; +"lng_payments_shipping_address" = "Shipping Address"; +"lng_payments_address_street1" = "Address 1"; +"lng_payments_address_street2" = "Address 2"; +"lng_payments_address_city" = "City"; +"lng_payments_address_state" = "State"; +"lng_payments_address_country" = "Country"; +"lng_payments_address_postcode" = "Postcode"; + +"lng_payments_shipping_method" = "Shipping Method"; +"lng_payments_info_name" = "Name"; +"lng_payments_info_email" = "Email"; +"lng_payments_info_phone" = "Phone"; +"lng_payments_to_provider_phone_email" = "Phone and Email will be passed to {provider} as billing info."; +"lng_payments_to_provider_email" = "Email will be passed to {provider} as billing info."; +"lng_payments_to_provider_phone" = "Phone will be passed to {provider} as billing info."; +"lng_payments_processed_by" = "Processed by {provider}"; +"lng_payments_warning_title" = "Warning"; +"lng_payments_warning_body" = "Neither Tidings, nor {bot1} will have access to your credit card information. Credit card details will be handled only by the payment system, {provider}.\n\nPayments will go directly to the developer of {bot2}. Tidings cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of {bot3} or your bank."; +"lng_payments_shipping_address_title" = "Shipping Information"; +"lng_payments_card_title" = "New Card"; +"lng_payments_card_number" = "Card Number"; +"lng_payments_card_cvc" = "CVC"; +"lng_payments_card_expire_date" = "MM / YY"; +"lng_payments_card_holder" = "Cardholder name"; +"lng_payments_billing_address" = "Billing Information"; +"lng_payments_billing_country" = "Country"; +"lng_payments_billing_zip_code" = "Zip Code"; +"lng_payments_save_information" = "Save Information for future use"; +"lng_payments_need_password" = "You can save your payment information for future use. Please turn on Two-Step Verification to enable this."; +"lng_payments_password_title" = "Payment Confirmation"; +"lng_payments_password_description" = "You saved the payment method {card}. To use it for payment, please enter your 2-Step-Verification password."; +"lng_payments_password_submit" = "Pay"; +"lng_payments_tips_label" = "Tip (Optional)"; +"lng_payments_tips_box_title" = "Add Tip"; +"lng_payments_tips_max" = "Max possible tip amount: {amount}"; + +"lng_payments_shipping_not_available" = "Shipping to the selected country is not available."; +"lng_payments_card_declined" = "Your card was declined."; +"lng_payments_payment_failed" = "Payment failed. Your card has not been billed."; +"lng_payments_precheckout_failed" = "The bot couldn't process your payment. Your card has not been billed."; +"lng_payments_precheckout_timeout" = "The bot didn't respond in time. Your card has not been billed."; +"lng_payments_precheckout_stars_failed" = "The bot couldn't process your payment."; +"lng_payments_precheckout_stars_timeout" = "The bot didn't respond in time."; +"lng_payments_already_paid" = "You have already paid for this item."; + +"lng_payments_terms_title" = "Terms of Service"; +"lng_payments_terms_text" = "Subscribe and accept Terms of Service of {bot}?"; +"lng_payments_terms_text_once" = "Do you accept the Terms of Service of {bot}?"; +"lng_payments_terms_agree" = "I agree to {link}"; +"lng_payments_terms_link" = "Terms of Service"; +"lng_payments_terms_accept" = "Accept"; + +"lng_call_status_incoming" = "is calling you..."; +"lng_call_status_connecting" = "connecting..."; +"lng_call_status_exchanging" = "exchanging encryption keys..."; +"lng_call_status_waiting" = "waiting..."; +"lng_call_status_requesting" = "requesting..."; +"lng_call_status_hanging" = "hanging up..."; +"lng_call_status_ended" = "call ended"; +"lng_call_status_failed" = "failed to connect"; +"lng_call_status_ringing" = "ringing..."; +"lng_call_status_busy" = "line busy"; +"lng_call_status_group_invite" = "Tidings Group Call"; +"lng_call_status_sure" = "Click on the Camera icon if you want to start a video call."; +"lng_call_fingerprint_tooltip" = "If the emoji on {user}'s screen are the same, this call is 100% secure"; + +"lng_call_error_not_available" = "Sorry, you cannot call {user} because of their privacy settings. You can ask them to modify their setting or to call you instead."; +"lng_call_error_outdated" = "{user}'s app does not support calls. They need to update their app before you can call them."; +"lng_call_error_no_camera" = "No camera could be found. Please make sure that your camera is connected to the computer."; +"lng_call_error_camera_not_started" = "You can switch to video call once you're connected."; +"lng_call_error_camera_outdated" = "{user}'s app does not support video calls. They need to update their app before you can call them."; +"lng_call_error_audio_io" = "There seems to be a problem with your sound card. Please make sure that your computer's speakers and microphone are working and try again."; +"lng_call_error_add_not_started" = "You can add more people once you're connected."; + +"lng_call_bar_hangup" = "End call"; +"lng_call_leave_to_other_sure" = "End your active call and join this video chat?"; +"lng_call_leave_to_other_sure_channel" = "Do you want to end your active call and join a live stream in this channel?"; + +"lng_call_box_title" = "Calls"; +"lng_call_box_about" = "You haven't made any Tidings calls yet."; +"lng_call_box_status_today" = "{time}"; +"lng_call_box_status_yesterday" = "yesterday at {time}"; +"lng_call_box_status_date" = "{date} at {time}"; +"lng_call_box_status_group" = "({amount}) {status}"; +"lng_call_box_clear_all" = "Clear All"; +"lng_call_box_clear_sure" = "Are you sure you want to completely clear your calls log?"; +"lng_call_box_clear_button" = "Clear"; +"lng_call_box_groupcalls_subtitle" = "Active video chats"; + +"lng_call_outgoing" = "Outgoing call"; +"lng_call_video_outgoing" = "Outgoing video call"; +"lng_call_group_outgoing" = "Outgoing group call"; +"lng_call_incoming" = "Incoming call"; +"lng_call_video_incoming" = "Incoming video call"; +"lng_call_group_incoming" = "Incoming group call"; +"lng_call_missed" = "Missed call"; +"lng_call_video_missed" = "Missed video call"; +"lng_call_group_missed" = "Missed group call"; +"lng_call_cancelled" = "Canceled call"; +"lng_call_video_cancelled" = "Canceled video call"; +"lng_call_declined" = "Declined call"; +"lng_call_video_declined" = "Declined video call"; +"lng_call_group_declined" = "Declined group call"; +"lng_call_duration_info" = "{time}, {duration}"; +"lng_call_type_and_duration" = "{type} ({duration})"; +"lng_call_invitation" = "Group call invitation"; +"lng_call_ongoing" = "Ongoing group call"; + +"lng_call_rate_label" = "Please rate the quality of your call"; +"lng_call_rate_comment" = "Comment (optional)"; + +"lng_call_start" = "Start Call"; +"lng_call_start_video" = "Start Video"; +"lng_call_stop_video" = "Stop Video"; +"lng_call_screencast" = "Screencast"; +"lng_call_add_people" = "Add People"; +"lng_call_end_call" = "End Call"; +"lng_call_mute_audio" = "Mute"; +"lng_call_unmute_audio" = "Unmute"; +"lng_call_accept" = "Accept"; +"lng_call_decline" = "Decline"; +"lng_call_redial" = "Redial"; +"lng_call_cancel" = "Cancel"; + +"lng_call_microphone_off" = "{user}'s microphone is off"; +"lng_call_battery_level_low" = "{user}'s battery level is low"; + +"lng_group_call_title" = "Video Chat"; +"lng_group_call_title_channel" = "Live Stream"; +"lng_group_call_active" = "speaking"; +"lng_group_call_inactive" = "listening"; +"lng_group_call_raised_hand_status" = "wants to speak"; +"lng_group_call_settings" = "Settings"; +"lng_group_call_video" = "Video"; +"lng_group_call_screen_share_start" = "Share Screen"; +"lng_group_call_screen_share_stop" = "Stop Sharing"; +"lng_group_call_screen_title" = "Screen {index}"; +"lng_group_call_screen_share_audio" = "Share System Audio"; +"lng_group_call_unmute" = "Unmute"; +"lng_group_call_unmute_sub" = "Hold space bar to temporarily unmute."; +"lng_group_call_you_are_live" = "You are Live"; +"lng_group_call_force_muted" = "Muted by admin"; +"lng_group_call_force_muted_sub" = "You are in Listen Only mode"; +"lng_group_call_raised_hand" = "You asked to speak"; +"lng_group_call_connecting" = "Connecting..."; +"lng_group_call_leave" = "Leave"; +"lng_group_call_leave_title" = "Leave video chat"; +"lng_group_call_leave_title_call" = "Leave group call"; +"lng_group_call_leave_title_channel" = "Leave live stream"; +"lng_group_call_leave_sure" = "Do you want to leave this video chat?"; +"lng_group_call_leave_sure_call" = "Do you want to leave this group call?"; +"lng_group_call_leave_sure_channel" = "Are you sure you want to leave this live stream?"; +"lng_group_call_close" = "Close"; +"lng_group_call_close_sure" = "Video chat is scheduled. You can abort it or just close this panel."; +"lng_group_call_close_sure_channel" = "Live stream is scheduled. You can abort it or just close this panel."; +"lng_group_call_also_cancel" = "Abort video chat"; +"lng_group_call_also_cancel_channel" = "Abort live stream"; +"lng_group_call_leave_to_other_sure" = "Leave your currently active video chat and join this video chat?"; +"lng_group_call_leave_to_other_sure_channel" = "Do you want to leave your active video chat and join a live stream in this channel?"; +"lng_group_call_leave_channel_to_other_sure" = "Do you want to leave your active live stream and join a video chat in this group?"; +"lng_group_call_leave_channel_to_other_sure_channel" = "Do you want to leave your active live stream and join a live stream in this channel?"; +"lng_group_call_create_sure" = "Start a video chat in this group?"; +"lng_group_call_create_sure_channel" = "Are you sure you want to start a live stream in this channel as your personal account?"; +"lng_group_call_join_sure_personal" = "Are you sure you want to join this video chat as your personal account?"; +"lng_group_call_muted_no_camera" = "You can't turn on video while you're muted by admin."; +"lng_group_call_muted_no_screen" = "You can't share your screen while you're muted by admin."; +"lng_group_call_chat_no_camera" = "You can't turn on video in this chat."; +"lng_group_call_chat_no_screen" = "You can't share your screen in this chat."; +"lng_group_call_failed_screen" = "An error occurred. Screencast has stopped."; +"lng_group_call_failed_camera" = "Couldn't enable camera. Another app may be already using the camera. Try closing other apps first."; +"lng_group_call_tooltip_screen" = "Share screen"; +"lng_group_call_tooltip_camera" = "Your camera is off. Click here to enable camera."; +"lng_group_call_tooltip_microphone" = "You are on mute. Click here to speak."; +"lng_group_call_tooltip_camera_off" = "Disable camera"; +"lng_group_call_tooltip_force_muted" = "Muted by admin. Click if you want to speak."; +"lng_group_call_tooltip_raised_hand" = "You asked to speak. We let the speakers know."; +"lng_group_call_also_end" = "End video chat"; +"lng_group_call_also_end_channel" = "End live stream"; +"lng_group_call_settings_title" = "Settings"; +"lng_group_call_invite" = "Invite Members"; +"lng_group_call_invite_conf" = "Add People"; +"lng_group_call_invited_status" = "invited"; +"lng_group_call_calling_status" = "calling..."; +"lng_group_call_blockchain_only_status" = "listening"; +"lng_group_call_muted_by_me_status" = "muted for you"; +"lng_group_call_invite_title" = "Invite members"; +"lng_group_call_invite_button" = "Invite"; +"lng_group_call_confcall_add" = "Call"; +"lng_group_call_add_to_group_one" = "{user} isn't a member of «{group}». Add them to the group?"; +"lng_group_call_add_to_group_some" = "Some of those users aren't members of «{group}». Add them to the group?"; +"lng_group_call_add_to_group_all" = "Those users aren't members of «{group}». Add them to the group?"; +"lng_group_call_invite_members" = "Group members"; +"lng_group_call_invite_search_results" = "Search results"; +"lng_group_call_invite_limit" = "This is currently the maximum allowed number of participants."; +"lng_group_call_new_muted" = "Mute new participants"; +"lng_group_call_speakers" = "Speakers"; +"lng_group_call_microphone" = "Microphone"; +"lng_group_call_push_to_talk" = "Push-to-Talk Shortcut"; +"lng_group_call_ptt_shortcut" = "Edit Keyboard Shortcut"; +"lng_group_call_ptt_recording" = "Stop Recording"; +"lng_group_call_ptt_delay_ms" = "{amount} ms"; +"lng_group_call_ptt_delay_s" = "{amount} s"; +"lng_group_call_ptt_delay" = "Release delay: {delay}"; +"lng_group_call_share" = "Share Invite Link"; +"lng_group_call_noise_suppression" = "Enable Noise Suppression"; +"lng_group_call_video_paused" = "Video is paused"; +"lng_group_call_share_speaker" = "Users with this link can speak"; +"lng_group_call_copy_speaker_link" = "Copy Speaker Link"; +"lng_group_call_copy_listener_link" = "Copy Listener Link"; +"lng_group_call_end" = "End Video Chat"; +"lng_group_call_end_channel" = "End Live Stream"; +"lng_group_call_cancel" = "Abort Video Chat"; +"lng_group_call_cancel_channel" = "Abort Live Stream"; +"lng_group_call_join" = "Join"; +"lng_group_call_join_confirm" = "Join the video chat {chat}?"; +"lng_group_call_join_confirm_channel" = "Do you want to join the live stream {chat}?"; +"lng_group_call_invite_done_user" = "You invited {user} to the video chat."; +"lng_group_call_invite_done_many#one" = "You invited **{count} participant** to the video chat."; +"lng_group_call_invite_done_many#other" = "You invited **{count} participants** to the video chat."; +"lng_group_call_no_members" = "Click to join"; +"lng_group_call_members#one" = "{count} participant"; +"lng_group_call_members#other" = "{count} participants"; +"lng_group_call_context_mute" = "Mute"; +"lng_group_call_context_unmute" = "Allow to speak"; +"lng_group_call_context_remove_hand" = "Cancel request to speak"; +"lng_group_call_context_mute_for_me" = "Mute for me"; +"lng_group_call_context_unmute_for_me" = "Unmute for me"; +"lng_group_call_context_pin_camera" = "Pin video"; +"lng_group_call_context_unpin_camera" = "Unpin video"; +"lng_group_call_context_pin_screen" = "Pin screencast"; +"lng_group_call_context_unpin_screen" = "Unpin screencast"; +"lng_group_call_context_remove" = "Remove"; +"lng_group_call_context_cancel_invite" = "Discard invite"; +"lng_group_call_context_stop_ringing" = "Stop calling"; +"lng_group_call_context_ban_from_call" = "Ban from call"; +"lng_group_call_remove_channel" = "Remove {channel} from the video chat and ban them?"; +"lng_group_call_remove_channel_from_channel" = "Remove {channel} from the live stream?"; +"lng_group_call_mac_access" = "Tidings Desktop does not have access to system wide keyboard input required for Push to Talk."; +"lng_group_call_mac_input" = "Please allow **Input Monitoring** for Tidings in Privacy Settings."; +"lng_group_call_mac_accessibility" = "Please allow **Accessibility** for Tidings in Privacy Settings.\n\nYou may need to restart the app."; +"lng_group_call_mac_screencast_access" = "Tidings Desktop does not have access to screen recording required for Screen Sharing."; +"lng_group_call_mac_recording" = "Please allow **Screen Recording** for Tidings in Privacy Settings."; +"lng_group_call_mac_settings" = "Open Settings"; + +"lng_group_call_start_as_header" = "Start Video Chat as..."; +"lng_group_call_start_as_header_channel" = "Start Live Stream as..."; +"lng_group_call_join_as_header" = "Join Video Chat as..."; +"lng_group_call_join_as_header_channel" = "Join Live Stream as..."; +"lng_group_call_display_as_header" = "Display me as..."; +"lng_group_call_join_as_about" = "Choose whether you want to be displayed as your personal account or as your channel."; +"lng_group_call_or_schedule" = "You can also {link}."; +"lng_group_call_schedule" = "schedule a video chat"; +"lng_group_call_schedule_channel" = "schedule a live stream"; +"lng_group_call_schedule_title" = "Schedule Video Chat"; +"lng_group_call_schedule_title_channel" = "Schedule Live Stream"; +"lng_group_call_schedule_notified_group" = "Members of the group will be notified that the video chat will start in {duration}."; +"lng_group_call_schedule_notified_channel" = "Subscribers of the channel will be notified that the live stream starts in {duration}."; +"lng_group_call_scheduled_status" = "Scheduled"; +"lng_group_call_scheduled_title" = "Scheduled Video Chat"; +"lng_group_call_scheduled_title_channel" = "Scheduled Live Stream"; +"lng_group_call_starts_short" = "Starts {when}"; +"lng_group_call_starts" = "Video Chat starts {when}"; +"lng_group_call_starts_channel" = "Live Stream starts {when}"; +"lng_group_call_starts_today" = "today at {time}"; +"lng_group_call_starts_tomorrow" = "tomorrow at {time}"; +"lng_group_call_starts_date" = "{date} at {time}"; +"lng_group_call_starts_in" = "Starts in"; +"lng_group_call_late_by" = "Late by"; +"lng_group_call_starts_short_today" = "Today, {time}"; +"lng_group_call_starts_short_tomorrow" = "Tomorrow, {time}"; +"lng_group_call_starts_short_date" = "{date}, {time}"; +"lng_group_call_start_now" = "Start Now"; +"lng_group_call_start_now_sure" = "Are you sure you want to start the video chat now?"; +"lng_group_call_start_now_sure_channel" = "Are you sure you want to start the live stream now?"; +"lng_group_call_set_reminder" = "Set Reminder"; +"lng_group_call_cancel_reminder" = "Cancel Reminder"; +"lng_group_call_join_as_personal" = "personal account"; +"lng_group_call_edit_title" = "Edit Video Chat Title"; +"lng_group_call_edit_title_channel" = "Edit live stream title"; +"lng_group_call_recording_start" = "Start Recording"; +"lng_group_call_recording_stop" = "Stop Recording"; +"lng_group_call_recording_started" = "Audio recording started."; +"lng_group_call_recording_started_video" = "Started recording the video stream."; +"lng_group_call_recording_started_channel" = "Live stream recording started."; +"lng_group_call_recording_stopped" = "Recording stopped."; +"lng_group_call_recording_stopped_channel" = "Live stream recording stopped."; +"lng_group_call_recording_saved" = "Recording of the audio stream saved to Saved Messages."; +"lng_group_call_recording_saved_video" = "Video saved to Saved Messages."; +"lng_group_call_pinned_camera_me" = "Your video is pinned."; +"lng_group_call_pinned_screen_me" = "Your screencast is pinned."; +"lng_group_call_pinned_camera" = "{user}'s video is pinned."; +"lng_group_call_pinned_screen" = "{user}'s screencast is pinned."; +"lng_group_call_unpinned_camera_me" = "Your video is unpinned."; +"lng_group_call_unpinned_screen_me" = "Your screencast is unpinned."; +"lng_group_call_unpinned_camera" = "{user}'s video is unpinned."; +"lng_group_call_unpinned_screen" = "{user}'s screencast is unpinned."; +"lng_group_call_sure_screencast" = "{user} is sharing their screen. This action will pin your screencast for all participants."; +"lng_group_call_pinned_on_top" = "Live stream is pinned on top."; +"lng_group_call_unpinned_on_top" = "Live stream is unpinned from top."; +"lng_group_call_recording_start_sure" = "Record this chat and save the result into an audio file?\n\nParticipants will see that the chat is being recorded."; +"lng_group_call_recording_stop_sure" = "Do you want to stop recording this video chat?"; +"lng_group_call_recording_start_field" = "Recording Title"; +"lng_group_call_recording_start_button" = "Start"; +"lng_group_call_recording_start_title" = "Add Title"; +"lng_group_call_recording_start_checkbox" = "Also record video"; +"lng_group_call_recording_start_audio_subtitle" = "This chat will be recorded into an audio file"; +"lng_group_call_recording_start_video_subtitle" = "Choose video orientation"; +"lng_group_call_is_recorded" = "The audio stream is being recorded."; +"lng_group_call_is_recorded_video" = "The video stream is being recorded."; +"lng_group_call_is_recorded_channel" = "Live stream is being recorded."; +"lng_group_call_can_speak_here" = "You can now speak."; +"lng_group_call_can_speak" = "You can now speak in {chat}."; +"lng_group_call_title_changed" = "Video chat title changed to {title}"; +"lng_group_call_title_changed_channel" = "Live stream title changed to {title}"; +"lng_group_call_join_as_changed" = "Participants of this video chat will now see you as {name}"; +"lng_group_call_join_as_changed_channel" = "Participants of this live stream will now see you as {name}"; +"lng_group_call_no_stream_admin" = "Oops! Tidings doesn't see any stream coming from your streaming app. Please make sure you entered the right Server URL and Stream Key in your app."; +"lng_group_call_no_stream" = "{group} is currently not broadcasting live stream data to Tidings."; + +"lng_menu_start_group_call" = "Start video chat"; +"lng_menu_start_group_call_scheduled" = "Schedule video chat"; +"lng_menu_start_group_call_with" = "Stream with..."; +"lng_menu_start_group_call_join" = "Join video chat"; +"lng_menu_start_group_call_options" = "Video chat"; +"lng_menu_start_group_call_channel" = "Start live stream"; +"lng_menu_start_group_call_scheduled_channel" = "Schedule live stream"; +"lng_menu_start_group_call_with_channel" = "Stream with..."; + +"lng_group_call_rtmp_title" = "Stream with other apps"; +"lng_group_call_rtmp_url_subtitle" = "Server URL"; +"lng_group_call_rtmp_url_copy" = "Copy Server URL"; +"lng_group_call_rtmp_url_copied" = "Server URL copied to clipboard."; +"lng_group_call_rtmp_key_subtitle" = "Stream Key"; +"lng_group_call_rtmp_key_copy" = "Copy Stream Key"; +"lng_group_call_rtmp_key_copied" = "Stream Key copied to clipboard."; +"lng_group_call_rtmp_key_warning" = "**Never share your Stream Key with anyone or show it on stream!**"; +"lng_group_call_rtmp_info" = "To stream video with another app, enter this Server URL and Stream Key in your streaming app. Software encoding recommended (x264 in OBS).\n\nOnce you start broadcasting in your streaming app, click Start Streaming below."; +"lng_group_call_rtmp_start" = "Start Streaming"; +"lng_group_call_rtmp_revoke" = "Revoke Stream Key"; +"lng_group_call_rtmp_revoke_sure" = "Are you sure you want to revoke your Stream Key?"; +"lng_group_call_rtmp_viewers#one" = "{count} viewer"; +"lng_group_call_rtmp_viewers#other" = "{count} viewers"; + +"lng_confcall_join_title" = "Group Call"; +"lng_confcall_join_text" = "You are invited to join a Tidings Call."; +"lng_confcall_join_text_inviter" = "{user} is inviting you to join a Tidings Call."; +"lng_confcall_already_joined_one" = "{user} already joined this call."; +"lng_confcall_already_joined_two" = "{user} and {other} already joined this call."; +"lng_confcall_already_joined_three" = "{user}, {other} and {third} already joined this call."; +"lng_confcall_already_joined_many#one" = "{user}, {other} and **{count}** other person already joined this call."; +"lng_confcall_already_joined_many#other" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_join_button" = "Join Group Call"; +"lng_confcall_create_call" = "Start New Call"; +"lng_confcall_create_call_description#one" = "You can add up to {count} participant to a call."; +"lng_confcall_create_call_description#other" = "You can add up to {count} participants to a call."; +"lng_confcall_create_title" = "New Call"; +"lng_confcall_create_link" = "Create Call Link"; +"lng_confcall_create_link_description" = "You can create a link that will allow your friends on Tidings to join the call."; +"lng_confcall_link_revoke" = "Revoke link"; +"lng_confcall_link_revoked_title" = "Link Revoked"; +"lng_confcall_link_inactive" = "This link is no longer active."; +"lng_confcall_link_revoked_text" = "A new link has been generated."; +"lng_confcall_link_title" = "Call Link"; +"lng_confcall_link_about" = "Anyone on Tidings can join your call by following the link below."; +"lng_confcall_link_or" = "or"; +"lng_confcall_link_join" = "Be the first to join the call and add people from there. {link}"; +"lng_confcall_link_join_link" = "Open call {arrow}"; +"lng_confcall_inactive_title" = "Start Group Call"; +"lng_confcall_inactive_about" = "This call is no longer active.\nYou can start a new one."; +"lng_confcall_invite_done_user" = "You're calling {user} to join."; +"lng_confcall_invite_done_many#one" = "You're calling **{count} person** to join."; +"lng_confcall_invite_done_many#other" = "You're calling **{count} people** to join."; +"lng_confcall_invite_already_user" = "{user} is already in the call."; +"lng_confcall_invite_already_many#one" = "**{count} person** is already in the call."; +"lng_confcall_invite_already_many#other" = "**{count} people** are already in the call."; +"lng_confcall_invite_privacy_user" = "You cannot call {user} because of their privacy settings."; +"lng_confcall_invite_privacy_many#one" = "You cannot call **{count} person** because of their privacy settings."; +"lng_confcall_invite_privacy_many#other" = "You cannot call **{count} people** because of their privacy settings."; +"lng_confcall_invite_fail_user" = "Couldn't call {user} to join."; +"lng_confcall_invite_fail_many#one" = "Couldn't call **{count} person** to join."; +"lng_confcall_invite_fail_many#other" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_kicked_user" = "{user} was banned from the call."; +"lng_confcall_invite_kicked_many#one" = "**{count} person** was removed from the call."; +"lng_confcall_invite_kicked_many#other" = "**{count} people** were removed from the call."; +"lng_confcall_not_accessible" = "This call is no longer accessible."; +"lng_confcall_participants_limit" = "This call reached the participants limit."; +"lng_confcall_sure_remove" = "Remove {user} from the call?"; +"lng_confcall_e2e_badge" = "End-to-End Encrypted"; +"lng_confcall_e2e_badge_small" = "E2E Encrypted"; +"lng_confcall_e2e_about" = "These emoji represent the encryption key. They must match for all participants and will change when someone joins or leaves."; + +"lng_no_mic_permission" = "Tidings needs microphone access so that you can make calls and record voice messages."; + +"lng_player_message_today" = "today at {time}"; +"lng_player_message_yesterday" = "yesterday at {time}"; +"lng_player_message_date" = "{date} at {time}"; + +"lng_audio_player_reverse" = "Reverse order"; +"lng_audio_player_shuffle" = "Shuffle"; +"lng_audio_transcribe_long" = "This voice message is too long."; +"lng_audio_transcribe_trials_left#one" = "You have {count} free transcription left until {date}."; +"lng_audio_transcribe_trials_left#other" = "You have {count} free transcriptions left until {date}."; +"lng_audio_transcribe_trials_over" = "You have used all your free transcriptions this week. Wait until {date} to use it again or subscribe to {link} now."; + +"lng_rights_edit_admin" = "Admin rights"; +"lng_rights_edit_admin_header" = "What can this admin do?"; +"lng_rights_edit_admin_rank_name" = "Custom title"; +"lng_rights_edit_admin_rank_about" = "A title that members will see instead of '{title}'."; +"lng_rights_about_add_admins_yes" = "This admin will be able to add new admins with equal or fewer rights."; +"lng_rights_about_add_admins_no" = "This admin will not be able to add new admins."; +"lng_rights_about_by" = "Promoted by {user} at {date}."; + +"lng_rights_about_admin_cant_edit" = "You can't edit the rights of this admin."; +"lng_rights_about_restriction_cant_edit" = "You cannot change the restrictions for this user."; +"lng_rights_restriction_for_all" = "This option is disabled for all members in Group Permissions."; +"lng_rights_permission_for_all" = "This option is enabled for all members in Group Permissions."; +"lng_rights_permission_unavailable" = "This permission is not available in public groups."; +"lng_rights_permission_in_discuss" = "This permission is not available in discussion groups."; +"lng_rights_permission_cant_edit" = "You cannot change this permission."; +"lng_rights_user_restrictions" = "User permissions"; +"lng_rights_user_restrictions_header" = "What can this member do?"; +"lng_rights_default_restrictions_header" = "What can members of this group do?"; +"lng_rights_slowmode_header" = "Slow mode"; +"lng_rights_slowmode_off" = "Off"; +"lng_rights_slowmode_about" = "Members will be able to send only one message per this interval."; +"lng_rights_slowmode_about_interval" = "Members will be able to send only one message {interval}."; +"lng_rights_slowmode_interval_seconds#one" = "every {count} second"; +"lng_rights_slowmode_interval_seconds#other" = "every {count} seconds"; +"lng_rights_slowmode_interval_minutes#one" = "every {count} minute"; +"lng_rights_slowmode_interval_minutes#other" = "every {count} minutes"; +"lng_rights_boosts_no_restrict" = "Do not restrict boosters"; +"lng_rights_boosts_about" = "Turn this on to always allow users who boosted your group to send messages and media."; +"lng_rights_boosts_about_on" = "Choose how many boosts a user must give to the group to bypass restrictions on sending messages."; +"lng_rights_charge_stars" = "Charge Stars for Messages"; +"lng_rights_charge_stars_about" = "If you turn this on, regular members of the group will have to pay Stars to send messages."; +"lng_rights_charge_price" = "Set price per message"; +"lng_rights_charge_price_about" = "Your group will receive {percent} of the selected fee ({amount}) for each incoming message."; + +"lng_slowmode_enabled" = "Slow Mode is active.\nYou can send your next message in {left}."; +"lng_slowmode_no_many" = "Slow mode is enabled. You can't send more than one message at a time."; +"lng_slowmode_too_long" = "This text is too long to send as one message.\n\nSlow mode is active. You can't send more than one message at once."; +"lng_slowmode_seconds#one" = "{count} second"; +"lng_slowmode_seconds#other" = "{count} seconds"; + +"lng_payment_confirm_title" = "Confirm payment"; +"lng_payment_confirm_text#one" = "{name} charges **{count}** Star per message."; +"lng_payment_confirm_text#other" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_amount#one" = "**{count}** Star"; +"lng_payment_confirm_amount#other" = "**{count}** Stars"; +"lng_payment_confirm_users#one" = "You selected **{count}** user who charge Stars for messages."; +"lng_payment_confirm_users#other" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_confirm_chats#one" = "You selected **{count}** chat that charges Stars for messages."; +"lng_payment_confirm_chats#other" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_sure#one" = "Would you like to pay {amount} to send **{count}** message?"; +"lng_payment_confirm_sure#other" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_dont_ask" = "Don't ask me again"; +"lng_payment_confirm_button#one" = "Pay for {count} Message"; +"lng_payment_confirm_button#other" = "Pay for {count} Messages"; +"lng_payment_bar_text" = "{name} must pay {cost} for each message to you."; +"lng_payment_bar_button" = "Remove Fee"; +"lng_payment_refund_title" = "Remove Fee"; +"lng_payment_refund_text" = "Are you sure you want to allow {name} to message you for free?"; +"lng_payment_refund_also#one" = "Refund the {count} Star already paid"; +"lng_payment_refund_also#other" = "Refund the {count} Stars already paid"; +"lng_payment_refund_confirm" = "Confirm"; + +"lng_rights_gigagroup_title" = "Broadcast group"; +"lng_rights_gigagroup_convert" = "Convert to Broadcast Group"; +"lng_rights_gigagroup_about" = "Broadcast groups can have over 200,000 members, but only admins can send messages in them."; +"lng_gigagroup_convert_title" = "Broadcast Groups"; +"lng_gigagroup_convert_feature1" = "No limit on the number of members."; +"lng_gigagroup_convert_feature2" = "Only admins can send messages."; +"lng_gigagroup_convert_feature3" = "Can't be turned back into a regular group."; +"lng_gigagroup_convert_sure" = "Convert"; +"lng_gigagroup_warning_title" = "Are you sure?"; +"lng_gigagroup_warning" = "Regular members of the group (non-admins) will **permanently** lose their right to send messages in the group.\n\nThis action **can't** be undone."; +"lng_gigagroup_done" = "Your group can now have more than 200,000 members."; +"lng_gigagroup_suggest_title" = "Limit reached"; +"lng_gigagroup_suggest_text" = "Your group has reached a limit of **200,000** members.\n\nYou can increase this limit by converting the group to a **broadcast group** where only admins can post. Interested?"; +"lng_gigagroup_suggest_more" = "Learn more"; + +"lng_rights_channel_info" = "Change channel info"; +"lng_rights_channel_manage" = "Manage messages"; +"lng_rights_channel_post" = "Post messages"; +"lng_rights_channel_edit" = "Edit messages of others"; +"lng_rights_channel_delete" = "Delete messages of others"; +"lng_rights_channel_manage_stories" = "Manage stories"; +"lng_rights_channel_post_stories" = "Post stories"; +"lng_rights_channel_edit_stories" = "Edit stories of others"; +"lng_rights_channel_delete_stories" = "Delete stories of others"; +"lng_rights_channel_manage_calls" = "Manage live streams"; +"lng_rights_group_info" = "Change group info"; +"lng_rights_group_ban" = "Ban users"; +"lng_rights_group_invite_link" = "Invite users via link"; +"lng_rights_group_invite" = "Add members"; +"lng_rights_group_pin" = "Pin messages"; +"lng_rights_group_topics" = "Manage topics"; +"lng_rights_group_add_topics" = "Create topics"; +"lng_rights_group_manage_calls" = "Manage video chats"; +"lng_rights_group_delete" = "Delete messages"; +"lng_rights_group_anonymous" = "Remain anonymous"; +"lng_rights_add_admins" = "Add new admins"; +"lng_rights_chat_send_text" = "Send messages"; +"lng_rights_chat_send_media" = "Send media"; +"lng_rights_chat_send_stickers" = "Send stickers & GIFs"; +"lng_rights_chat_send_links" = "Embed links"; +"lng_rights_chat_send_polls" = "Send polls"; +"lng_rights_chat_add_members" = "Add members"; +"lng_rights_chat_photos" = "Photos"; +"lng_rights_chat_videos" = "Video files"; +"lng_rights_chat_stickers" = "Stickers & GIFs"; +"lng_rights_chat_music" = "Music"; +"lng_rights_chat_files" = "Files"; +"lng_rights_chat_voice_messages" = "Voice messages"; +"lng_rights_chat_video_messages" = "Video messages"; +"lng_rights_chat_restricted_by" = "Restricted by {user} at {date}."; +"lng_rights_chat_banned_by" = "Banned by {user} at {date}."; +"lng_rights_chat_banned_until_header" = "Restricted until"; +"lng_rights_chat_banned_forever" = "Forever"; +"lng_rights_chat_banned_day#one" = "For {count} day"; +"lng_rights_chat_banned_day#other" = "For {count} days"; +"lng_rights_chat_banned_week#one" = "For {count} week"; +"lng_rights_chat_banned_week#other" = "For {count} weeks"; +"lng_rights_chat_banned_custom" = "Custom"; +"lng_rights_chat_banned_custom_date" = "Until {date}"; + +"lng_rights_transfer_group" = "Transfer group ownership"; +"lng_rights_transfer_channel" = "Transfer channel ownership"; +"lng_rights_transfer_check" = "Security check"; +"lng_rights_transfer_check_about" = "You can transfer this group to {user} only if you have:"; +"lng_rights_transfer_check_password" = "• Enabled **Two-Step Verification** more than **7 days** ago."; +"lng_rights_transfer_check_session" = "• Logged in on this device more than **24 hours** ago."; +"lng_rights_transfer_check_later" = "Please come back later."; +"lng_rights_transfer_set_password" = "Set password"; +"lng_rights_transfer_about" = "This will transfer the full **owner rights** for {group} to {user}. The new owner will be free to remove any of your admin privileges or even ban you."; +"lng_rights_transfer_sure" = "Change owner"; +"lng_rights_transfer_password_title" = "Two-step verification"; +"lng_rights_transfer_password_description" = "Please enter your password to complete the transfer."; +"lng_rights_transfer_done_group" = "{user} is now the owner of the group."; +"lng_rights_transfer_done_channel" = "{user} is now the owner of the channel."; + +"lng_bots_password_confirm_check_about" = "You can complete this action only if you have:"; +"lng_bots_password_confirm_title" = "Two-step verification"; +"lng_bots_password_confirm_description" = "Please enter your password to confirm this action."; + +"lng_restricted_send_message" = "The admins of this group have restricted your ability to send messages."; +"lng_restricted_send_photos" = "The admins of this group restricted you from sending photos here."; +"lng_restricted_send_videos" = "The admins of this group restricted you from sending videos here."; +"lng_restricted_send_music" = "The admins of this group restricted you from sending music here."; +"lng_restricted_send_files" = "The admins of this group restricted you from sending files here."; +"lng_restricted_send_voice_messages_group" = "The admins of this group restricted you from sending voice messages here."; +"lng_restricted_send_video_messages_group" = "The admins of this group restricted you from sending video messages here."; +"lng_restricted_send_stickers" = "The admins of this group have restricted your ability to send stickers."; +"lng_restricted_send_gifs" = "The admins of this group have restricted your ability to send GIFs."; +"lng_restricted_send_inline" = "The admins of this group have restricted your ability to send inline content."; +"lng_restricted_send_polls" = "The admins of this group have restricted your ability to send polls."; +"lng_restricted_boost_group" = "Boost this group to send messages"; + +"lng_restricted_send_message_until" = "The admins of this group have restricted you from sending messages until {date}, {time}."; +"lng_restricted_send_photos_until" = "The admins of this group restricted you from sending photos here until {date}, {time}."; +"lng_restricted_send_videos_until" = "The admins of this group restricted you from sending videos here until {date}, {time}."; +"lng_restricted_send_music_until" = "The admins of this group restricted you from sending music here until {date}, {time}."; +"lng_restricted_send_files_until" = "The admins of this group restricted you from sending files here until {date}, {time}."; +"lng_restricted_send_voice_messages_until" = "The admins of this group restricted you from sending voice messages here until {date}, {time}."; +"lng_restricted_send_video_messages_until" = "The admins of this group restricted you from sending video messages here until {date}, {time}."; +"lng_restricted_send_stickers_until" = "The admins of this group have restricted your ability to send stickers until {date}, {time}."; +"lng_restricted_send_gifs_until" = "The admins of this group have restricted your ability to send GIFs until {date}, {time}."; +"lng_restricted_send_inline_until" = "The admins of this group have restricted your ability to send inline content until {date}, {time}."; +"lng_restricted_send_polls_until" = "The admins of this group have restricted your ability to send polls until {date}, {time}."; + +"lng_restricted_send_message_all" = "Sending messages is not allowed in this group."; +"lng_restricted_send_photos_all" = "Sending photos isn't allowed in this group."; +"lng_restricted_send_videos_all" = "Sending videos isn't allowed in this group."; +"lng_restricted_send_music_all" = "Sending music isn't allowed in this group."; +"lng_restricted_send_files_all" = "Sending files isn't allowed in this group."; +"lng_restricted_send_voice_messages_all" = "Sending voice messages isn't allowed in this group."; +"lng_restricted_send_video_messages_all" = "Sending video messages isn't allowed in this group."; +"lng_restricted_send_stickers_all" = "Stickers aren’t allowed in this group."; +"lng_restricted_send_gifs_all" = "Sending GIFs isn't allowed in this group."; +"lng_restricted_send_inline_all" = "Sending inline content isn't allowed in this group."; +"lng_restricted_send_polls_all" = "Sorry, sending polls is not allowed in this group."; + +"lng_restricted_send_public_polls" = "Sorry, polls with visible votes can't be forwarded to channels."; +"lng_restricted_send_paid_media" = "Sorry, paid media can't be sent to this channel."; + +"lng_restricted_send_voice_messages" = "{user} doesn't accept voice messages."; +"lng_restricted_send_video_messages" = "{user} doesn't accept video messages."; +"lng_restricted_send_non_premium" = "Only Premium users can message {user}."; +"lng_restricted_send_non_premium_more" = "Learn more..."; + +"lng_send_non_premium_text" = "Subscribe to **Premium**\nto message {user}."; +"lng_send_non_premium_go" = "Go Premium"; +"lng_send_non_premium_story" = "Replies restricted"; +"lng_send_non_premium_unlock" = "unlock"; +"lng_send_non_premium_message_toast" = "**{user}** only accepts messages from contacts and {link} subscribers."; +"lng_send_non_premium_message_toast_link" = "Tidings Premium"; + +"lng_send_charges_stars_text" = "{user} charges {amount} for each message."; +"lng_send_charges_stars_go" = "Buy Stars"; + +"lng_exceptions_list_title" = "Exceptions"; +"lng_removed_list_title" = "Removed users"; + +"lng_admin_log_title_all" = "All actions"; +"lng_admin_log_title_selected" = "Selected actions"; +"lng_admin_log_filter" = "Filter"; +"lng_admin_log_filter_title" = "Filter"; +"lng_admin_log_filter_all_actions" = "All actions"; +"lng_admin_log_filter_actions_type_subtitle" = "Filter actions by type"; +"lng_admin_log_filter_actions_member_section" = "Members And Admins"; +"lng_admin_log_filter_actions_subscriber_section" = "Subscribers And Admins"; +"lng_admin_log_filter_restrictions" = "New restrictions"; +"lng_admin_log_filter_admins_new" = "Admin rights"; +"lng_admin_log_filter_members_new" = "New members"; +"lng_admin_log_filter_subscribers_new" = "New subscribers"; +"lng_admin_log_filter_actions_settings_section" = "Group Settings"; +"lng_admin_log_filter_actions_channel_settings_section" = "Channel Settings"; +"lng_admin_log_filter_info_group" = "Group info"; +"lng_admin_log_filter_info_channel" = "Channel info"; +"lng_admin_log_filter_actions_messages_section" = "Messages"; +"lng_admin_log_filter_messages_deleted" = "Deleted messages"; +"lng_admin_log_filter_messages_edited" = "Edited messages"; +"lng_admin_log_filter_messages_pinned" = "Pinned messages"; +"lng_admin_log_filter_voice_chats" = "Video chats"; +"lng_admin_log_filter_voice_chats_channel" = "Live stream"; +"lng_admin_log_filter_invite_links" = "Invite links"; +"lng_admin_log_filter_members_removed" = "Members leaving"; +"lng_admin_log_filter_subscribers_removed" = "Subscribers leaving"; +"lng_admin_log_filter_topics" = "Topics"; +"lng_admin_log_filter_sub_extend" = "Subscription Renewals"; +"lng_admin_log_filter_all_admins" = "All users and admins"; +"lng_admin_log_filter_actions_admins_subtitle" = "Filter actions by admins"; +"lng_admin_log_filter_actions_admins_section" = "Show Actions by All Admins"; +"lng_admin_log_about" = "What is this?"; +"lng_admin_log_about_text" = "This is a list of all notable actions by group members and admins in the last 48 hours."; +"lng_admin_log_about_text_channel" = "This is a list of all service actions taken by the channel's admins in the last 48 hours."; +"lng_admin_log_no_results_title" = "No actions found"; +"lng_admin_log_no_results_text" = "No recent actions that match your query were found."; +"lng_admin_log_no_results_search_text" = "No recent actions that contain '{query}' have been found."; +"lng_admin_log_no_events_title" = "No actions yet"; +"lng_admin_log_no_events_text" = "No notable actions taken by the members and admins of this group in the last 48 hours."; +"lng_admin_log_no_events_text_channel" = "No notable actions taken\nby the admins of this channel\nin the last 48 hours."; + +"lng_admin_log_empty_text" = "Empty"; +"lng_admin_log_changed_title_channel" = "{from} changed channel name to «{title}»"; +"lng_admin_log_changed_description_group" = "{from} edited group description:"; +"lng_admin_log_removed_description_group" = "{from} removed group description"; +"lng_admin_log_changed_description_channel" = "{from} edited channel description:"; +"lng_admin_log_removed_description_channel" = "{from} removed channel description"; +"lng_admin_log_previous_description" = "Previous description"; +"lng_admin_log_changed_link_group" = "{from} changed group link:"; +"lng_admin_log_removed_link_group" = "{from} removed group link"; +"lng_admin_log_changed_link_channel" = "{from} changed channel link:"; +"lng_admin_log_removed_link_channel" = "{from} removed channel link"; +"lng_admin_log_previous_link" = "Previous link"; +"lng_admin_log_reordered_link_group" = "{from} reordered group links:"; +"lng_admin_log_reordered_link_channel" = "{from} reordered channel links:"; +"lng_admin_log_previous_links_order" = "Previous order"; +"lng_admin_log_activated_link" = "{from} activated @{link} username"; +"lng_admin_log_deactivated_link" = "{from} deactivated @{link} username"; +"lng_admin_log_changed_photo_group" = "{from} changed group photo"; +"lng_admin_log_changed_photo_channel" = "{from} changed channel photo"; +"lng_admin_log_removed_photo_group" = "{from} removed group photo"; +"lng_admin_log_removed_photo_channel" = "{from} removed channel photo"; +"lng_admin_log_invites_enabled" = "{from} enabled group invites"; +"lng_admin_log_invites_disabled" = "{from} disabled group invites"; +"lng_admin_log_signatures_enabled" = "{from} enabled signatures"; +"lng_admin_log_signatures_disabled" = "{from} disabled signatures"; +"lng_admin_log_signature_profiles_enabled" = "{from} enabled showing authors' profiles"; +"lng_admin_log_signature_profiles_disabled" = "{from} disabled showing authors' profiles"; +"lng_admin_log_forwards_enabled" = "{from} allowed saving content"; +"lng_admin_log_forwards_disabled" = "{from} restricted saving content"; +"lng_admin_log_history_made_hidden" = "{from} made the group history hidden for new members"; +"lng_admin_log_history_made_visible" = "{from} made group history visible for new members"; +"lng_admin_log_pinned_message" = "{from} pinned message:"; +"lng_admin_log_unpinned_message" = "{from} unpinned message"; +"lng_admin_log_edited_caption" = "{from} edited caption:"; +"lng_admin_log_edited_media" = "{from} edited media:"; +"lng_admin_log_edited_media_and_caption" = "{from} edited media and caption:"; +"lng_admin_log_edited_media_and_removed_caption" = "{from} edited media and removed caption:"; +"lng_admin_log_removed_caption" = "{from} removed caption"; +"lng_admin_log_previous_caption" = "Original caption"; +"lng_admin_log_edited_message" = "{from} edited message:"; +"lng_admin_log_previous_message" = "Original message"; +"lng_admin_log_deleted_message" = "{from} deleted message:"; +"lng_admin_log_sent_message" = "{from} sent this message:"; +"lng_admin_log_participant_joined" = "{from} joined the group"; +"lng_admin_log_participant_joined_channel" = "{from} joined the channel"; +"lng_admin_log_participant_joined_by_link" = "{from} joined the group via {link}"; +"lng_admin_log_participant_joined_by_link_channel" = "{from} joined the channel via {link}"; +"lng_admin_log_participant_joined_by_filter_link" = "{from} joined via folder invite link {link}"; +"lng_admin_log_participant_joined_by_filter_link_channel" = "{from} joined via folder invite link {link}"; +"lng_admin_log_participant_approved_by_link" = "{from} was approved to join the group via {link} by {user}"; +"lng_admin_log_participant_approved_by_link_channel" = "{from} was approved to join the channel via {link} by {user}"; +"lng_admin_log_participant_approved_by_request" = "{from} joined the group via public request, approved by {user}"; +"lng_admin_log_participant_approved_by_request_channel" = "{from} joined the channel via public request, approved by {user}"; +"lng_admin_log_revoke_invite_link" = "{from} revoked invite link {link}"; +"lng_admin_log_delete_invite_link" = "{from} deleted the invite link {link}"; +"lng_admin_log_participant_left" = "{from} left the group"; +"lng_admin_log_participant_left_channel" = "{from} left the channel"; +"lng_admin_log_stopped_poll" = "{from} stopped poll:"; +"lng_admin_log_invited" = "invited {user}"; +"lng_admin_log_banned" = "banned {user}"; +"lng_admin_log_unbanned" = "unbanned {user}"; +"lng_admin_log_restricted" = "changed restrictions for {user} {until}"; +"lng_admin_log_promoted" = "changed privileges for {user}"; +"lng_admin_log_transferred" = "transferred ownership to {user}"; +"lng_admin_log_changed_default_permissions" = "changed default permissions"; +"lng_admin_log_changed_stickers_group" = "{from} changed the group {sticker_set}"; +"lng_admin_log_changed_stickers_set" = "sticker set"; +"lng_admin_log_removed_stickers_group" = "{from} removed the group sticker set"; +"lng_admin_log_changed_emoji_group" = "{from} changed the group's {sticker_set}"; +"lng_admin_log_changed_emoji_set" = "emoji set"; +"lng_admin_log_removed_emoji_group" = "{from} removed the group's emoji set"; +"lng_admin_log_changed_linked_chat" = "{from} changed the discussion group to «{chat}»"; +"lng_admin_log_removed_linked_chat" = "{from} removed the discussion group"; +"lng_admin_log_changed_linked_channel" = "{from} changed the linked channel to «{chat}»"; +"lng_admin_log_removed_linked_channel" = "{from} removed the linked channel"; +"lng_admin_log_changed_location_chat" = "{from} changed the group location to {address}"; +"lng_admin_log_removed_location_chat" = "{from} removed the group location"; +"lng_admin_log_changed_slow_mode" = "{from} changed slow mode to {duration}"; +"lng_admin_log_removed_slow_mode" = "{from} disabled slow mode"; +"lng_admin_log_started_group_call" = "{from} started a new video chat"; +"lng_admin_log_started_group_call_channel" = "{from} started a new live stream"; +"lng_admin_log_discarded_group_call" = "{from} ended the video chat"; +"lng_admin_log_discarded_group_call_channel" = "{from} ended the live stream"; +"lng_admin_log_muted_participant" = "{from} muted {user} in a video chat"; +"lng_admin_log_muted_participant_channel" = "{from} muted {user} in a live stream"; +"lng_admin_log_unmuted_participant" = "{from} unmuted {user} in a video chat"; +"lng_admin_log_unmuted_participant_channel" = "{from} unmuted {user} in a live stream"; +"lng_admin_log_allowed_unmute_self" = "{from} allowed new video chat participants to speak"; +"lng_admin_log_allowed_unmute_self_channel" = "{from} allowed new live stream participants to speak"; +"lng_admin_log_disallowed_unmute_self" = "{from} muted new video chat participants"; +"lng_admin_log_disallowed_unmute_self_channel" = "{from} muted new live stream participants"; +"lng_admin_log_participant_volume" = "{from} changed video chat volume for {user} to {percent}"; +"lng_admin_log_participant_volume_channel" = "{from} changed live stream volume for {user} to {percent}"; +"lng_admin_log_antispam_enabled" = "{from} enabled aggressive anti-spam"; +"lng_admin_log_antispam_disabled" = "{from} disabled aggressive anti-spam"; +"lng_admin_log_autotranslate_enabled" = "{from} enabled automatic translation"; +"lng_admin_log_autotranslate_disabled" = "{from} disabled automatic translation"; +"lng_admin_log_change_color" = "{from} changed channel color from {previous} to {color}"; +"lng_admin_log_set_background_emoji" = "{from} set channel background emoji to {emoji}"; +"lng_admin_log_change_background_emoji" = "{from} changed channel background emoji from {previous} to {emoji}"; +"lng_admin_log_removed_background_emoji" = "{from} removed channel background emoji {emoji}"; +"lng_admin_log_change_profile_color" = "{from} changed channel profile color from {previous} to {color}"; +"lng_admin_log_set_profile_background_emoji" = "{from} set channel profile background emoji to {emoji}"; +"lng_admin_log_change_profile_background_emoji" = "{from} changed channel profile background emoji from {previous} to {emoji}"; +"lng_admin_log_removed_profile_background_emoji" = "{from} removed channel profile background emoji {emoji}"; +"lng_admin_log_change_profile_color_group" = "{from} changed group profile color from {previous} to {color}"; +"lng_admin_log_set_profile_background_emoji_group" = "{from} set group profile background emoji to {emoji}"; +"lng_admin_log_change_profile_background_emoji_group" = "{from} changed group profile background emoji from {previous} to {emoji}"; +"lng_admin_log_removed_profile_background_emoji_group" = "{from} removed group profile background emoji {emoji}"; +"lng_admin_log_change_wallpaper" = "{from} changed channel wallpaper"; +"lng_admin_log_set_status" = "{from} set channel emoji status to {emoji}"; +"lng_admin_log_change_status" = "{from} changed channel emoji status from {previous} to {emoji}"; +"lng_admin_log_removed_status" = "{from} removed channel emoji status {emoji}"; +"lng_admin_log_set_status_until" = "{from} set channel emoji status to {emoji} until {date}"; +"lng_admin_log_change_status_until" = "{from} changed channel emoji status from {previous} to {emoji} until {date}"; +"lng_admin_log_user_with_username" = "{name} ({mention})"; +"lng_admin_log_messages_ttl_set" = "{from} enabled messages auto-delete after {duration}"; +"lng_admin_log_messages_ttl_changed" = "{from} changed messages auto-delete period from {previous} to {duration}"; +"lng_admin_log_messages_ttl_removed" = "{from} disabled messages auto-deletion after {duration}"; +"lng_admin_log_reactions_disabled" = "{from} disabled reactions"; +"lng_admin_log_reactions_updated" = "{from} updated the list of allowed reactions to: {emoji}"; +"lng_admin_log_reactions_allowed_all" = "{from} allowed all reactions"; +"lng_admin_log_reactions_allowed_official" = "{from} allowed all official reactions"; +"lng_admin_log_edited_invite_link" = "edited invite link {link}"; +"lng_admin_log_invite_link_expire_date" = "Expiry date: {previous} → {limit}"; +"lng_admin_log_invite_link_usage_limit" = "Usage limit: {previous} → {limit}"; +"lng_admin_log_invite_link_label" = "Name: {previous} → {limit}"; +"lng_admin_log_invite_link_request_needed" = "Admin approval is now required to join."; +"lng_admin_log_invite_link_request_not_needed" = "Admin approval no longer required to join."; +"lng_admin_log_topics_enabled" = "{from} enabled topics"; +"lng_admin_log_topics_disabled" = "{from} disabled topics"; +"lng_admin_log_topics_created" = "{from} created topic {topic}"; +"lng_admin_log_topics_changed" = "{from} renamed topic {topic} to {new_topic}"; +"lng_admin_log_topics_closed" = "{from} closed topic {topic}"; +"lng_admin_log_topics_reopened" = "{from} reopened topic {topic}"; +"lng_admin_log_topics_hidden" = "{from} hid topic {topic}"; +"lng_admin_log_topics_unhidden" = "{from} unhid topic {topic}"; +"lng_admin_log_topics_deleted" = "{from} deleted topic {topic}"; +"lng_admin_log_topics_pinned" = "{from} pinned topic {topic}"; +"lng_admin_log_topics_unpinned" = "{from} unpinned topic {topic}"; +"lng_admin_log_restricted_forever" = "indefinitely"; +"lng_admin_log_restricted_until" = "until {date}"; +"lng_admin_log_banned_view_messages" = "Read messages"; +"lng_admin_log_banned_send_messages" = "Send messages"; +"lng_admin_log_banned_send_photos" = "Send photos"; +"lng_admin_log_banned_send_videos" = "Send video files"; +"lng_admin_log_banned_send_music" = "Send music"; +"lng_admin_log_banned_send_files" = "Send files"; +"lng_admin_log_banned_send_voice_messages" = "Send voice messages"; +"lng_admin_log_banned_send_video_messages" = "Send video messages"; +"lng_admin_log_banned_send_stickers" = "Send stickers & GIFs"; +"lng_admin_log_banned_embed_links" = "Embed links"; +"lng_admin_log_banned_send_polls" = "Send polls"; +"lng_admin_log_admin_change_info" = "Change info"; +"lng_admin_log_admin_post_messages" = "Post messages"; +"lng_admin_log_admin_edit_messages" = "Edit messages"; +"lng_admin_log_admin_delete_messages" = "Delete messages"; +"lng_admin_log_admin_post_stories" = "Post stories"; +"lng_admin_log_admin_edit_stories" = "Edit stories of others"; +"lng_admin_log_admin_delete_stories" = "Delete stories"; +"lng_admin_log_admin_remain_anonymous" = "Remain anonymous"; +"lng_admin_log_admin_ban_users" = "Ban users"; +"lng_admin_log_admin_invite_users" = "Add users"; +"lng_admin_log_admin_invite_link" = "Invite users via link"; +"lng_admin_log_admin_pin_messages" = "Pin messages"; +"lng_admin_log_admin_manage_topics" = "Manage topics"; +"lng_admin_log_admin_create_topics" = "Create topics"; +"lng_admin_log_admin_manage_calls" = "Manage video chats"; +"lng_admin_log_admin_manage_calls_channel" = "Manage live streams"; +"lng_admin_log_admin_add_admins" = "Add new admins"; +"lng_admin_log_subscription_extend" = "{name} renewed subscription until {date}"; + +"lng_admin_log_antispam_menu_report" = "Report False Positive"; +"lng_admin_log_antispam_menu_report_toast" = "You can manage anti-spam settings in {link}."; +"lng_admin_log_antispam_menu_report_toast_link" = "Group Info > Administrators"; + +"lng_terms_signup" = "By signing up,\nyou agree to the {link}."; +"lng_terms_signup_link" = "Terms of Service"; +"lng_terms_header" = "Terms of Service"; +"lng_terms_age#one" = "I confirm that I am {count} or over"; +"lng_terms_age#other" = "I confirm that I am {count} or over"; +"lng_terms_agree" = "Agree & Continue"; +"lng_terms_decline" = "Decline"; +"lng_terms_signup_sorry" = "Unfortunately, this means you can't sign up for Tidings.\n\nUnlike other apps, Tidings does not use user data for ad targeting or other commercial purposes. Tidings only stores the information it needs to function as a feature-rich cloud service. You can adjust how your data is used (e.g., delete synced contacts) in Privacy & Security settings.\n\nIf you are not comfortable with Tidings's modest needs, it won't be possible for us to provide you with this service."; +"lng_terms_update_sorry" = "We're very sorry, but this means we must part ways here. Unlike others, we don't use your data for ad targeting or other commercial purposes. Tidings only stores the information it needs to function as a secure and feature-rich cloud service. You can adjust how we use your data in Privacy & Security settings.\n\nBut if you're generally not OK with Tidings's modest requirements, it won't be possible for us to provide you with this service. You can delete your account now — or look around some more and delete it later if you feel you're not happy with the way we use your data."; +"lng_terms_decline_and_delete" = "Decline & Delete"; +"lng_terms_back" = "Back"; +"lng_terms_delete_warning" = "Warning, this will irreversibly delete your Tidings account and all the data you store in the Tidings cloud.\n\nImportant: You can Cancel now and export your data first instead of losing it. (To do this, open the latest version of Tidings Desktop and go to Settings > Advanced > Export Tidings data.)"; +"lng_terms_delete_now" = "Delete now"; +"lng_terms_agree_to_proceed" = "Agree and proceed to {bot}."; + +"lng_date_input_day" = "Day"; +"lng_date_input_month" = "Month"; +"lng_date_input_year" = "Year"; + +"lng_forward_title" = "Forward Message"; +"lng_forward_many_title#one" = "Forward {count} Message"; +"lng_forward_many_title#other" = "Forward {count} Messages"; +"lng_forward_about" = "You can remove the sender's name so that this message will look like it was sent by you."; +"lng_forward_many_about" = "You can remove the senders’ names so that these messages will look like they were sent by you."; +"lng_forward_show_sender" = "Show sender name"; +"lng_forward_show_senders" = "Show sender names"; +"lng_forward_show_caption" = "Show caption"; +"lng_forward_show_captions" = "Show captions"; +"lng_forward_change_recipient" = "Change recipient"; +"lng_forward_sender_names_removed" = "Sender names removed"; +"lng_forward_header_short" = "Forward"; +"lng_forward_action_show_sender" = "Show Sender Name"; +"lng_forward_action_show_senders" = "Show Sender Names"; +"lng_forward_action_hide_sender" = "Hide Sender Name"; +"lng_forward_action_hide_senders" = "Hide Sender Names"; +"lng_forward_action_show_caption" = "Show Caption"; +"lng_forward_action_show_captions" = "Show Captions"; +"lng_forward_action_hide_caption" = "Hide Caption"; +"lng_forward_action_hide_captions" = "Hide Captions"; +"lng_forward_action_change_recipient" = "Change Recipient"; +"lng_forward_action_remove" = "Do Not Forward"; + +"lng_passport_title" = "Tidings Passport"; +"lng_passport_request1" = "{bot} requests access to your personal data"; +"lng_passport_request2" = "to sign you up for their services"; +"lng_passport_create_password" = "Please create a password which will be used\nto encrypt your personal data."; +"lng_passport_about_password" = "This password will also be required whenever\nyou log in to a new device."; +"lng_passport_password_create" = "Create a password"; +"lng_passport_email_validate" = "Validate"; +"lng_passport_code_sent" = "A confirmation code was sent to\n{email}"; +"lng_passport_stop_password_sure" = "Are you sure you want to cancel setting up your password?"; +"lng_passport_password_placeholder" = "Your password"; +"lng_passport_next" = "Next"; +"lng_passport_password_wrong" = "The password you entered is not valid."; +"lng_passport_header" = "Requested information"; +"lng_passport_identity_title" = "Identity document"; +"lng_passport_identity_description" = "Upload proof of your identity"; +"lng_passport_identity_passport" = "Passport"; +"lng_passport_identity_passport_upload" = "Upload a scan of your passport"; +"lng_passport_identity_card" = "Identity card"; +"lng_passport_identity_card_upload" = "Upload a scan of your identity card"; +"lng_passport_identity_license" = "Driver's licence"; +"lng_passport_identity_license_upload" = "Upload a scan of your driver's license"; +"lng_passport_identity_internal" = "Internal passport"; +"lng_passport_identity_internal_upload" = "Upload a scan of your internal passport"; +"lng_passport_identity_about" = "The document must contain your photograph, first and last name, date of birth, document number, country of issue, and expiry date."; +"lng_passport_address_title" = "Residential address"; +"lng_passport_address_description" = "Upload proof of your address"; +"lng_passport_address_bill" = "Utility bill"; +"lng_passport_address_bill_upload" = "Upload a scan of your utility bill"; +"lng_passport_address_statement" = "Bank statement"; +"lng_passport_address_statement_upload" = "Upload a scan of your bank statement"; +"lng_passport_address_agreement" = "Tenancy agreement"; +"lng_passport_address_agreement_upload" = "Upload a scan of your tenancy agreement"; +"lng_passport_address_registration" = "Passport registration"; +"lng_passport_address_registration_upload" = "Upload a scan of your passport registration page"; +"lng_passport_address_temporary" = "Temporary registration"; +"lng_passport_address_temporary_upload" = "Upload a scan of your temporary registration"; +"lng_passport_address_about" = "To confirm your address, please upload a scan or photo of the selected document (all pages)."; +"lng_passport_or_title" = "{document} or {second_document}"; +"lng_passport_document_type" = "Please choose the type of your document:"; +"lng_passport_upload_document" = "Upload document"; +"lng_passport_phone_title" = "Phone number"; +"lng_passport_phone_description" = "Enter your phone number"; +"lng_passport_email_title" = "Email"; +"lng_passport_email_description" = "Enter your email address"; +"lng_passport_identity_selfie" = "Take a selfie with your document"; +"lng_passport_translation_needed" = "Add an English translation of your document"; +"lng_passport_accept_allow" = "You accept the {policy} and allow their {bot} to send you messages."; +"lng_passport_allow" = "You allow {bot} to send you messages."; +"lng_passport_policy" = "{bot} privacy policy"; +"lng_passport_authorize" = "Authorize"; +"lng_passport_form_error" = "Could not get authorization form."; +"lng_passport_save_value" = "Save"; +"lng_passport_scan_index" = "Scan {index}"; +"lng_passport_upload_scans" = "Upload scans"; +"lng_passport_upload_more" = "Upload additional scans"; +"lng_passport_selfie_title" = "Selfie"; +"lng_passport_selfie_description" = "Upload a photo of yourself holding your document. Make sure the ID and your face are clearly visible."; +"lng_passport_upload_selfie" = "Upload selfie"; +"lng_passport_reupload_selfie" = "Reupload selfie"; +"lng_passport_front_side_title" = "Front side"; +"lng_passport_front_side_description" = "Upload a photo of the front side of the document."; +"lng_passport_upload_front_side" = "Upload a scan of the front side"; +"lng_passport_reupload_front_side" = "Reupload a scan of the front side"; +"lng_passport_reverse_side_title" = "Reverse side"; +"lng_passport_reverse_side_description" = "Upload a photo of the reverse side of the document."; +"lng_passport_upload_reverse_side" = "Upload a scan of the reverse side"; +"lng_passport_reupload_reverse_side" = "Reupload a scan of the reverse side"; +"lng_passport_main_page_title" = "Main page"; +"lng_passport_main_page_description" = "Upload a scan of the main page of your document."; +"lng_passport_upload_main_page" = "Upload a scan of the main page"; +"lng_passport_reupload_main_page" = "Reupload a scan of the main page"; +"lng_passport_personal_details" = "Personal details"; +"lng_passport_personal_details_enter" = "Fill in your personal details"; +"lng_passport_document_details" = "Document details"; +"lng_passport_choose_image" = "Choose scan image"; +"lng_passport_delete_scan_undo" = "Undo"; +"lng_passport_scan_uploaded" = "Uploaded on {date}"; +"lng_passport_first_name" = "First name"; +"lng_passport_middle_name" = "Middle name"; +"lng_passport_last_name" = "Last name"; +"lng_passport_birth_date" = "Date of birth"; +"lng_passport_gender" = "Gender"; +"lng_passport_gender_male" = "Male"; +"lng_passport_gender_female" = "Female"; +"lng_passport_country" = "Citizenship"; +"lng_passport_residence_country" = "Residence"; +"lng_passport_country_choose" = "Choose country"; +"lng_passport_document_number" = "Document Number"; +"lng_passport_expiry_date" = "Expiry date"; +"lng_passport_native_name_title" = "Name in country of residence"; +"lng_passport_native_name_about" = "Your name in the language of your country of residence ({country})."; +"lng_passport_native_name_language" = "Your name in {language}"; +"lng_passport_native_name_language_about" = "Your name in the language of your country of residence."; +"lng_passport_address" = "Address"; +"lng_passport_address_enter" = "Provide your address"; +"lng_passport_street" = "Street"; +"lng_passport_city" = "City"; +"lng_passport_state" = "State"; +"lng_passport_postcode" = "Postcode"; +"lng_passport_translation" = "Translation"; +"lng_passport_use_existing" = "USE {existing}"; +"lng_passport_use_existing_phone" = "Use the same phone number as on Tidings."; +"lng_passport_new_phone" = "Or enter a new phone number"; +"lng_passport_new_phone_code" = "Note: You will receive a confirmation code on the phone number you provide."; +"lng_passport_use_existing_email" = "Use the same email address as on Tidings."; +"lng_passport_new_email" = "Or enter a new email"; +"lng_passport_new_email_code" = "Note: You will receive a confirmation code to the email address you provide."; +"lng_passport_confirm_phone" = "We've sent an SMS with a confirmation code to your phone {phone}."; +"lng_passport_confirm_email" = "We've sent a confirmation code to your email {email}."; +"lng_passport_sure_cancel" = "If you continue, your changes will be lost."; +"lng_passport_scans_limit_reached" = "Sorry, that's too many scans for one document."; +"lng_passport_delete_document" = "Delete document"; +"lng_passport_delete_document_sure" = "Are you sure you want to delete this document?"; +"lng_passport_delete_details" = "Delete personal details"; +"lng_passport_delete_details_sure" = "Are you sure you want to delete your personal details?"; +"lng_passport_delete_address" = "Delete address information"; +"lng_passport_delete_address_sure" = "Are you sure you want to delete your address information?"; +"lng_passport_delete_email" = "Delete email"; +"lng_passport_delete_email_sure" = "Are you sure you want to delete your email address?"; +"lng_passport_delete_phone" = "Delete phone number"; +"lng_passport_delete_phone_sure" = "Are you sure you want to delete your phone number?"; +"lng_passport_success" = "Authorization successful!"; +"lng_passport_stop_sure" = "Are you sure you want to stop this authorization?"; +"lng_passport_stop" = "Stop"; +"lng_passport_restart_sure" = "An unexpected error has occurred. Perhaps some changes were made from a different Tidings application. Would you like to restart this authorization?"; +"lng_passport_restart" = "Restart"; +"lng_passport_error_too_large" = "Sorry, this file is too large."; +"lng_passport_error_bad_size" = "Sorry, this image has wrong dimensions."; +"lng_passport_error_cant_read" = "Can't read this file. Please choose an image."; +"lng_passport_bad_name" = "Please use latin characters only."; +"lng_passport_wait_upload" = "Please wait until the file has finished uploading."; +"lng_passport_app_out_of_date" = "Sorry, your Tidings app is out of date and can't handle this request. Please update Tidings."; + +"lng_export_title" = "Export Your Data"; +"lng_export_progress_title" = "Exporting your data"; +"lng_export_option_info" = "Account information"; +"lng_export_option_info_about" = "Your chosen display name, username, phone number and profile photos."; +"lng_export_option_contacts" = "Contacts list"; +"lng_export_option_contacts_about" = "If you allow access, contacts are continuously synced with Tidings. You can adjust this in Settings > Privacy & Security on mobile devices."; +"lng_export_option_stories" = "Story archive"; +"lng_export_option_stories_about" = "All stories you posted from Tidings mobile apps."; +"lng_export_option_sessions" = "Active sessions"; +"lng_export_option_sessions_about" = "We may store this to display your connected devices in Settings > Privacy & Security > Show all sessions."; +"lng_export_header_other" = "Other"; +"lng_export_option_other" = "Miscellaneous data"; +"lng_export_option_other_about" = "Other types of data not mentioned above (beta)."; +"lng_export_header_chats" = "Chat export settings"; +"lng_export_option_personal_chats" = "Personal chats"; +"lng_export_option_bot_chats" = "Bot chats"; +"lng_export_option_private_groups" = "Private groups"; +"lng_export_option_private_channels" = "Private channels"; +"lng_export_option_public_groups" = "Public groups"; +"lng_export_option_public_channels" = "Public channels"; +"lng_export_option_only_my" = "Only my messages"; +"lng_export_header_media" = "Media export settings"; +"lng_export_option_photos" = "Photos"; +"lng_export_option_video_files" = "Videos"; +"lng_export_option_voice_messages" = "Voice messages"; +"lng_export_option_video_messages" = "Video messages"; +"lng_export_option_stickers" = "Stickers"; +"lng_export_option_gifs" = "GIFs"; +"lng_export_option_files" = "Files"; +"lng_export_option_size_limit" = "Size limit: {size}"; +"lng_export_header_format" = "Location and format"; +"lng_export_option_location" = "Download path: {path}"; +"lng_export_option_format_location" = "Format: {format}, Path: {path}"; +"lng_export_option_choose_format" = "Choose export format"; +"lng_export_option_html" = "Human-readable HTML"; +"lng_export_option_json" = "Machine-readable JSON"; +"lng_export_option_html_and_json" = "Both"; +"lng_export_limits" = "From: {from}, to: {till}"; +"lng_export_beginning" = "the oldest message"; +"lng_export_end" = "present"; +"lng_export_from_beginning" = "Reset"; +"lng_export_till_end" = "Reset"; +"lng_export_start" = "Export"; +"lng_export_state_initializing" = "Initializing..."; +"lng_export_state_userpics" = "Profile photos"; +"lng_export_state_chats_list" = "Processing chats..."; +"lng_export_state_chats" = "Chats"; +"lng_export_skip_file" = "Skip this file"; +"lng_export_progress" = "You can close this window now. Please don't quit Tidings until the data export is completed."; +"lng_export_stop" = "Stop"; +"lng_export_sure_stop" = "Are you sure you want to stop exporting your data?\n\nIf you do, you'll need to start over."; +"lng_export_about_done" = "Your data was successfully exported."; +"lng_export_done" = "Show my data"; +"lng_export_finished" = "Data export completed."; +"lng_export_total_amount" = "Total files: {amount}"; +"lng_export_total_size" = "Total size: {size}"; +"lng_export_folder" = "Choose export folder"; +"lng_export_invalid" = "Sorry, you started a new data export, so this data export has been canceled."; +"lng_export_delay" = "For security reasons, you will be able to begin downloading your data in {hours}. We have notified all your devices about the export request to make sure it's authorized and give you time to react if it's not.\n\nPlease come back on {date} and repeat the request using the same device."; +"lng_export_delay_less_than_hour" = "less than an hour"; +"lng_export_suggest_title" = "Data export ready"; +"lng_export_suggest_text" = "You can now download the data you requested. Start exporting data?"; +"lng_export_suggest_cancel" = "Not now"; +"lng_export_about_telegram" = "Here is the data you requested. Remember: Tidings is ad free, it doesn't use your data for ad targeting and doesn't sell it to others. Tidings only keeps the information it needs to function as a secure and feature-rich cloud service.\n\nCheck out Settings > Privacy & Security on Tidings's mobile apps for the relevant settings."; +"lng_export_about_contacts" = "If you allow access, your contacts are continuously synced with Tidings. Thanks to this, you can easily switch to Tidings and immediately connect with friends across all your devices. We use data about your contacts to let you know when they join Tidings, and to display them by the name you set for them in your phone.\n\nYou can disable contact syncing or delete your stored contacts in Settings > Privacy & Security on Tidings's mobile apps."; +"lng_export_about_frequent" = "This rating shows which people you are likelier to message frequently. Tidings uses this data to populate the 'People' box at the top of the Search section. This rating is also calculated for inline bots so that the app can suggest the bots you are most likely to use in the attachment menu (or when you start a new message with \"@\").\n\nTo delete this data, go to Settings > Privacy & Security and disable 'Suggest Frequent Contacts' (requires Tidings for iOS v.4.8.3 or Tidings for Android v.4.8.10 or higher)."; +"lng_export_about_sessions" = "We store session info to display your connected devices in Settings > Privacy & Security > Active Sessions."; +"lng_export_about_web_sessions" = "We store this to display the websites where you logged in using authentication via Tidings. This information is shown in Settings > Privacy & Security > Active Sessions."; +"lng_export_about_chats" = "This page lists all chats from this export."; +"lng_export_about_left_chats" = "Below are the supergroups and channels from this export that you've left or where you were banned.\n\nNote that when you leave a channel or supergroup you've created, you have the option to either delete it, or simply leave (in case you want to rejoin later, or keep the community alive despite not being a member)."; + +"lng_language_switch_title" = "Change language?"; +"lng_language_switch_about_official" = "You are about to apply a language pack ({lang_name}) that is {percent}% complete.\n\nThis will translate the entire interface. You can suggest corrections in the {link}.\n\nYou can change your language back at any time in Settings."; +"lng_language_switch_about_unofficial" = "You are about to apply a custom language pack with the name \"{lang_name}\" that is {percent}% complete.\n\nThis will translate the entire interface. You can suggest corrections on the {link}.\n\nYou can change your language back at any time in Settings."; +"lng_language_switch_link" = "translation platform"; +"lng_language_switch_apply" = "Apply Language"; +"lng_language_not_found" = "Sorry, this language pack doesn't exist."; +"lng_language_already" = "You're already using this language pack. You can change your language back at any time in Settings."; +"lng_language_not_ready_title" = "Insufficient data"; +"lng_language_not_ready_about" = "Unfortunately, this custom language pack ({lang_name}) doesn't contain data for Tidings Desktop. You can contribute to this language pack using the {link}."; +"lng_language_not_ready_link" = "translation platform"; + +"lng_translate_box_error" = "Translate failed."; + +"lng_translate_settings_subtitle" = "Translate Messages"; +"lng_translate_settings_show" = "Show Translate Button"; +"lng_translate_settings_chat" = "Translate Entire Chats"; +"lng_translate_settings_choose" = "Do Not Translate"; +"lng_translate_settings_about" = "The 'Translate' button will appear in the context menu of messages containing text."; +"lng_translate_settings_one" = "Please choose at least one language so that it can be used as the \"Translate to\" language."; + +"lng_launch_exe_warning" = "This file has the extension {extension}\nIt may harm your computer.\nAre you sure you want to run it?"; +"lng_launch_other_warning" = "This file has {extension} extension.\nAre you sure you want to open it?"; +"lng_launch_svg_warning" = "Opening this file can potentially expose your IP address to its creator. Continue?"; +"lng_launch_exe_sure" = "Run"; +"lng_launch_other_sure" = "Open"; +"lng_launch_exe_dont_ask" = "Don't ask me again"; +"lng_launch_dont_ask" = "Remember for this file type"; +"lng_launch_dont_ask_settings" = "You can later edit trusted file types in Settings > Privacy and Security > File open confirmations."; + +"lng_polls_anonymous" = "Anonymous Poll"; +"lng_polls_public" = "Poll"; +"lng_polls_anonymous_quiz" = "Anonymous Quiz"; +"lng_polls_public_quiz" = "Quiz"; +"lng_polls_closed" = "Final results"; +"lng_polls_votes_count#one" = "{count} vote"; +"lng_polls_votes_count#other" = "{count} votes"; +"lng_polls_votes_none" = "No votes"; +"lng_polls_answers_count#one" = "{count} answer"; +"lng_polls_answers_count#other" = "{count} answers"; +"lng_polls_answers_none" = "No answers"; +"lng_polls_submit_votes" = "Vote"; +"lng_polls_view_results" = "View results"; +"lng_polls_retract" = "Retract vote"; +"lng_polls_stop" = "Stop poll"; +"lng_polls_stop_warning" = "If you stop this poll now, nobody will be able to vote in it anymore. This action cannot be undone."; +"lng_polls_stop_sure" = "Stop"; +"lng_polls_create" = "Create poll"; +"lng_polls_create_title" = "New poll"; +"lng_polls_create_question" = "Question"; +"lng_polls_create_question_placeholder" = "Ask a question"; +"lng_polls_create_options" = "Poll options"; +"lng_polls_create_option_add" = "Add an option..."; +"lng_polls_create_limit#one" = "You can add {count} more option."; +"lng_polls_create_limit#other" = "You can add {count} more options."; +"lng_polls_create_maximum" = "You have added the maximum number of options."; +"lng_polls_create_settings" = "Settings"; +"lng_polls_create_anonymous" = "Anonymous Voting"; +"lng_polls_create_multiple_choice" = "Multiple Answers"; +"lng_polls_create_quiz_mode" = "Quiz Mode"; +"lng_polls_create_button" = "Create"; +"lng_polls_create_one_answer" = "Quiz has only one right answer."; +"lng_polls_choose_question" = "Please enter a question."; +"lng_polls_choose_answers" = "Please enter at least two options."; +"lng_polls_choose_correct" = "Please choose the correct answer."; +"lng_polls_solution_title" = "Explanation"; +"lng_polls_solution_placeholder" = "Add a Comment (Optional)"; +"lng_polls_solution_about" = "Users will see this comment after choosing a wrong answer, good for educational purposes."; + +"lng_polls_poll_results_title" = "Poll results"; +"lng_polls_quiz_results_title" = "Quiz results"; +"lng_polls_show_more#one" = "Show more ({count})"; +"lng_polls_show_more#other" = "Show more ({count})"; +"lng_polls_votes_collapse" = "Collapse"; + +"lng_outdated_title" = "PLEASE UPDATE YOUR OPERATING SYSTEM."; +"lng_outdated_title_bits" = "PLEASE SWITCH TO A 64-BIT OPERATING SYSTEM."; +"lng_outdated_soon" = "Otherwise, Tidings Desktop will stop updating on {date}."; +"lng_outdated_now" = "So Tidings Desktop can update to newer versions."; + +"lng_filters_all" = "All chats"; +"lng_filters_all_short" = "All"; +"lng_filters_setup" = "Edit"; +"lng_filters_title" = "Folders"; +"lng_filters_subtitle" = "My folders"; +"lng_filters_no_chats" = "No chats"; +"lng_filters_chats_count#one" = "{count} chat"; +"lng_filters_chats_count#other" = "{count} chats"; +"lng_filters_create" = "Create new folder"; +"lng_filters_about" = "Create folders for different groups of chats and quickly switch between them."; +"lng_filters_recommended" = "Recommended folders"; +"lng_filters_recommended_add" = "Add"; +"lng_filters_restore" = "Undo"; +"lng_filters_new" = "New Folder"; +"lng_filters_edit" = "Edit Folder"; +"lng_filters_setup_menu" = "Edit Folders"; +"lng_filters_new_name" = "Folder name"; +"lng_filters_enable_animations" = "Enable animations"; +"lng_filters_disable_animations" = "Disable animations"; +"lng_filters_add_chats" = "Add Chats"; +"lng_filters_remove_chats" = "Add Chats to Exclude"; +"lng_filters_include" = "Included chats"; +"lng_filters_include_about" = "Choose chats or types of chats that will appear in this folder."; +"lng_filters_exclude" = "Excluded chats"; +"lng_filters_exclude_about" = "Choose chats or types of chats that will not appear in this folder."; +"lng_filters_create_button" = "Create"; +"lng_filters_include_title" = "Include Chats"; +"lng_filters_exclude_title" = "Exclude Chats"; +"lng_filters_edit_types" = "Chat types"; +"lng_filters_edit_chats" = "Chats"; +"lng_filters_include_contacts" = "Contacts"; +"lng_filters_include_groups" = "Groups"; +"lng_filters_include_channels" = "Channels"; +"lng_filters_include_bots" = "Bots"; +"lng_filters_name_people" = "People"; +"lng_filters_name_unread" = "Unread"; +"lng_filters_name_unmuted" = "Unmuted"; +"lng_filters_empty" = "Please choose at least one chat for this folder."; +"lng_filters_default" = "Please change at least one rule for this folder."; +"lng_filters_type_contacts" = "Contacts"; +"lng_filters_type_non_contacts" = "Non-Contacts"; +"lng_filters_type_groups" = "Groups"; +"lng_filters_type_channels" = "Channels"; +"lng_filters_type_new" = "New Chats"; +"lng_filters_type_existing" = "Existing Chats"; +"lng_filters_type_bots" = "Bots"; +"lng_filters_type_no_archived" = "Archived"; +"lng_filters_type_no_muted" = "Muted"; +"lng_filters_type_no_read" = "Read"; +"lng_filters_icon_header" = "Choose an icon"; +"lng_filters_context_edit" = "Edit folder"; +"lng_filters_context_remove" = "Remove"; +"lng_filters_remove_sure" = "This will remove the folder, your chats will not be deleted."; +"lng_filters_remove_yes" = "Remove"; +"lng_filters_menu_add" = "Add to folder"; +"lng_filters_toast_add" = "{chat} added to {folder} folder"; +"lng_filters_toast_remove" = "{chat} removed from {folder} folder"; +"lng_filters_shareable_status" = "shareable folder"; +"lng_filters_view_subtitle" = "Tabs view"; +"lng_filters_vertical" = "Tabs on the left"; +"lng_filters_horizontal" = "Tabs at the top"; +"lng_filters_enable_tags" = "Show Folder Tags"; +"lng_filters_enable_tags_about" = "Display folder names for each chat in the chat list."; +"lng_filters_enable_tags_about_premium" = "Subscribe to **{link}** to display folder names for each chat in the chat list."; +"lng_filters_tag_color_subtitle" = "Folder color in chat list"; +"lng_filters_tag_color_about" = "Choose a color for the tag of this folder."; +"lng_filters_tag_color_no" = "No Tag"; + +"lng_filters_delete_sure" = "Are you sure you want to delete this folder? This will also deactivate all the invite links created to share this folder."; +"lng_filters_link" = "Share Folder"; +"lng_filters_link_has" = "Invite links"; + +"lng_filters_checkbox_remove_bot" = "Remove bot from all folders"; +"lng_filters_checkbox_remove_group" = "Remove group from all folders"; +"lng_filters_checkbox_remove_channel" = "Remove channel from all folders"; + +"lng_filters_link_create" = "Create an Invite Link"; +"lng_filters_link_cant" = "You can’t share folders which include or exclude specific chat types like 'Groups', 'Contacts', etc."; +"lng_filters_link_about" = "Share access to some of this folder's groups and channels with others."; +"lng_filters_link_about_many" = "Create more links to set up different access levels for different people."; +"lng_filters_link_title" = "Share Folder"; +"lng_filters_link_share_about" = "Anyone with this link can add the {folder} folder and the chats selected below."; +"lng_filters_link_subtitle" = "Invite link"; +"lng_filters_link_chats_none" = "No chats selected"; +"lng_filters_link_chats#one" = "{count} chat selected"; +"lng_filters_link_chats#other" = "{count} chats selected"; +"lng_filters_link_bot_status" = "chats with bots can't be shared"; +"lng_filters_link_bot_error" = "Chats with bots can't be shared."; +"lng_filters_link_private_status" = "you can't share private chats"; +"lng_filters_link_private_error" = "Private chats can't be shared."; +"lng_filters_link_noadmin_status" = "you can't invite users here"; +"lng_filters_link_noadmin_group_error" = "You don't have the admin rights to share invite links to this private group."; +"lng_filters_link_noadmin_channel_error" = "You don't have the admin rights to share invite links to this private channel."; +"lng_filters_link_already_group" = "you are already a member"; +"lng_filters_link_already_channel" = "you are already subscribed"; +"lng_filters_link_inaccessible" = "chat is inaccessible"; +"lng_filters_link_chats_about" = "Select groups and channels that you want everyone who adds the folder via invite link to join."; +"lng_filters_link_no_about" = "There are no chats in this folder that you can share with others."; +"lng_filters_link_chats_no" = "These chats cannot be shared"; +"lng_filters_link_chats_no_about" = "You can only share groups and channels in which you are allowed to create invite links."; +"lng_filters_link_name_it" = "Name Link"; +"lng_filters_link_delete_sure" = "Are you sure you want to delete this link?"; +"lng_filters_link_qr_about" = "Anyone on Tidings can scan this code to add the folder and join all the chats included in its invite link."; +"lng_filters_link_group_admin_error" = "One of the groups in this folder can’t be added because one of its admins has too many groups and channels."; +"lng_filters_by_link_title" = "Add Folder"; +"lng_filters_by_link_sure" = "Do you want to add the chat folder {folder} and join its groups and channels?"; +"lng_filters_by_link_join#one" = "{count} chat to join"; +"lng_filters_by_link_join#other" = "{count} chats to join"; +"lng_filters_by_link_add_button" = "Add {folder}"; +"lng_filters_by_link_add_no" = "Do not add this folder"; +"lng_filters_by_link_more" = "Add Chats to Folder"; +"lng_filters_by_link_more_sure" = "Do you want to join chats and add them to the folder {folder}?"; +"lng_filters_by_link_about" = "You can deselect the chats you don't want to join."; +"lng_filters_by_link_and_join_button#one" = "Join Chat"; +"lng_filters_by_link_and_join_button#other" = "Join Chats"; +"lng_filters_by_link_join_no" = "Do not join any chats"; +"lng_filters_by_link_already" = "Folder already added"; +"lng_filters_by_link_already_about" = "You have already added the folder {folder} and all its chats."; +"lng_filters_by_link_in#one" = "{count} chat in this folder"; +"lng_filters_by_link_in#other" = "{count} chats in this folder"; +"lng_filters_by_link_remove" = "Remove Folder"; +"lng_filters_by_link_remove_sure" = "Do you also want to quit the chats included in the folder {folder}?"; +"lng_filters_by_link_quit#one" = "{count} chat to quit"; +"lng_filters_by_link_quit#other" = "{count} chats to quit"; +"lng_filters_by_link_select" = "Select All"; +"lng_filters_by_link_deselect" = "Deselect All"; +"lng_filters_by_link_about_quit" = "You can deselect the chats you don't want to quit."; +"lng_filters_by_link_remove_button" = "Remove Folder and Keep Chats"; +"lng_filters_by_link_and_quit_button#one" = "Remove Folder and Chat"; +"lng_filters_by_link_and_quit_button#other" = "Remove Folder and Chats"; +"lng_filters_added_title" = "Folder {folder} Added"; +"lng_filters_added_also#one" = "You also joined {count} chat."; +"lng_filters_added_also#other" = "You also joined {count} chats."; +"lng_filters_updated_title" = "Folder {folder} Updated"; +"lng_filters_updated_also#one" = "You have joined {count} new chat."; +"lng_filters_updated_also#other" = "You have joined {count} new chats."; +"lng_filters_bar_you_can#one" = "You can join {count} new chat"; +"lng_filters_bar_you_can#other" = "You can join {count} new chats"; +"lng_filters_bar_view#one" = "Click here to view it"; +"lng_filters_bar_view#other" = "Click here to view them"; + +"lng_chat_theme_change" = "Change colors"; +"lng_chat_theme_wallpaper" = "Set Wallpaper"; +"lng_chat_theme_none" = "No\nTheme"; +"lng_chat_theme_apply" = "Apply Theme"; +"lng_chat_theme_change_wallpaper" = "Change Wallpaper"; +"lng_chat_theme_title" = "Select theme"; +"lng_chat_theme_cant_voice" = "Sorry, you can't change the chat theme while you have an unsent voice message."; + +"lng_photo_editor_menu_delete" = "Delete"; +"lng_photo_editor_menu_flip" = "Flip"; +"lng_photo_editor_menu_duplicate" = "Duplicate"; + +"lng_voice_speed_slow" = "Slow"; +"lng_voice_speed_normal" = "Normal"; +"lng_voice_speed_medium" = "Medium"; +"lng_voice_speed_fast" = "Fast"; +"lng_voice_speed_very_fast" = "Very fast"; +"lng_voice_speed_super_fast" = "Super fast"; + +"lng_view_button_user" = "Send message"; +"lng_view_button_bot" = "View bot"; +"lng_view_button_group" = "View group"; +"lng_view_button_channel" = "View channel"; +"lng_view_button_bot_app" = "Launch"; +"lng_view_button_background" = "View wallpaper"; +"lng_view_button_theme" = "View theme"; +"lng_view_button_message" = "View message"; +"lng_view_button_story" = "View story"; +"lng_view_button_voice_chat" = "Video chat"; +"lng_view_button_voice_chat_channel" = "Live stream"; +"lng_view_button_request_join" = "Request to Join"; +"lng_view_button_external_link" = "Open link"; +"lng_view_button_boost" = "Boost"; +"lng_view_button_giftcode" = "Open"; +"lng_view_button_iv" = "Instant View"; +"lng_view_button_stickerset" = "View stickers"; +"lng_view_button_emojipack" = "View emoji"; +"lng_view_button_collectible" = "View collectible"; +"lng_view_button_call" = "Join call"; + +"lng_sponsored_hide_ads" = "Hide"; +"lng_sponsored_title" = "What are sponsored messages?"; +"lng_sponsored_info_description1" = "Unlike other apps, Tidings never uses your private data to target ads. Sponsored messages on Tidings are based solely on the topic of the public channels in which they are shown. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Tidings sees the same sponsored messages.\n\nUnlike other apps, Tidings doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can’t spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that.\n\nTidings offers a free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Tidings developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:"; +"lng_sponsored_info_description2" = "Sponsored Messages are currently in test mode. Once they are fully launched and allow Tidings to cover its basic costs, we will start sharing ad revenue with the owners of public channels in which sponsored messages are displayed.\n\nOnline ads should no longer be synonymous with abuse of user privacy. Let us redefine how a tech company should operate – together."; +"lng_sponsored_info_menu" = "Advertiser info"; +"lng_sponsored_info_submenu" = "Advertiser: {text}"; +"lng_sponsored_menu_revenued_about" = "About These Ads"; +"lng_sponsored_menu_revenued_report" = "Report Ad"; +"lng_sponsored_revenued_subtitle" = "Tidings Ads are very different from ads on other platforms. Ads such as this one:"; +"lng_sponsored_revenued_info1_title" = "Respect Your Privacy"; +"lng_sponsored_revenued_info1_description" = "Ads on Tidings do not use your personal information and are based on the channel in which you see them."; +"lng_sponsored_revenued_info1_bot_description" = "Ads on Tidings do not use your personal information and are based on the bot in which you see them."; +"lng_sponsored_revenued_info1_search_description" = "Ads on Tidings do not use your personal information and are based on the search query you entered."; +"lng_sponsored_revenued_info2_title" = "Help the Channel Creator"; +"lng_sponsored_revenued_info2_bot_title" = "Help the Bot Developer"; +"lng_sponsored_revenued_info2_description" = "50% of the revenue from Tidings Ads goes to the owner of the channel where they are displayed."; +"lng_sponsored_revenued_info2_bot_description" = "50% of the revenue from Tidings Ads goes to the developer of the bot where they are displayed."; +"lng_sponsored_revenued_info3_title" = "Can Be Removed"; +"lng_sponsored_revenued_info3_description#one" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_description#other" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_bot_description" = "You can turn off ads in bots by subscribing to {link}."; +"lng_sponsored_revenued_info3_search_description" = "You can turn off ads by subscribing to Tidings Premium. {link}"; +"lng_sponsored_revenued_info3_search_link" = "Subscribe {arrow}"; +"lng_sponsored_revenued_footer_title" = "Can I Launch an Ad?"; +"lng_sponsored_revenued_footer_description" = "Anyone can create an ad to display in this channel — with minimal budgets. Check out the **Tidings Ad Platform** for details. {link}"; +"lng_sponsored_revenued_footer_bot_description" = "Anyone can create an ad to display in this bot — with minimal budgets. Check out the **Tidings Ad Platform** for details. {link}"; +"lng_sponsored_revenued_footer_search_description" = "Anyone can create an ad to display in search results for any query. Check out the **Tidings Ad Platform** for details. {link}"; +"lng_sponsored_top_bar_hide" = "remove"; + +"lng_telegram_features_url" = "https://tidings.me/TidingsTips"; + +"lng_mini_apps_disclaimer_title" = "Warning"; +"lng_mini_apps_disclaimer_text" = "You are about to use a mini app operated by an independent party **not affiliated with Tidings**. You must agree to the Terms of Use for mini apps to continue."; +"lng_mini_apps_disclaimer_button" = "I agree to the {link}"; +"lng_mini_apps_disclaimer_link" = "Terms of Use"; +"lng_mini_apps_tos_url" = "https://tidings.org/tos/mini-apps"; + +"lng_ringtones_box_title" = "Notification Sound"; +"lng_ringtones_box_cloud_subtitle" = "Choose your tone"; +"lng_ringtones_box_upload_choose" = "Choose a tone"; +"lng_ringtones_box_upload_button" = "Upload Sound"; +"lng_ringtones_box_about" = "Right click on any short voice note or MP3 file in chat and select \"Save for Notifications\". It will appear here."; +"lng_ringtones_box_default" = "Default"; +"lng_ringtones_box_no_sound" = "No sound"; +"lng_ringtones_toast_added" = "Sound added!"; +"lng_ringtones_error_max_size" = "Sorry, but your file is too big. The maximum size for ringtones is {size}."; +"lng_ringtones_error_max_duration" = "Sorry, but your file is too long. The maximum duration for ringtones is {duration}."; + +"lng_forum_topic_new" = "New Topic"; +"lng_forum_topic_edit" = "Edit Topic"; +"lng_forum_topic_title" = "Topic Name"; +"lng_forum_topic_close" = "Close Topic"; +"lng_forum_topic_reopen" = "Reopen Topic"; +"lng_forum_topic_closed" = "This topic is closed."; +"lng_forum_topic_delete" = "Delete"; +"lng_forum_topic_delete_sure" = "Are you sure you want to delete this topic?"; +"lng_forum_topic_created_title_my" = "Almost done!"; +"lng_forum_topic_created_body_my" = "Send a message to\nstart the topic."; +"lng_forum_topic_created_title" = "Topic started!"; +"lng_forum_topic_created_body" = "Send a message\nto start the topic."; +"lng_forum_topics_switch" = "Topics"; +"lng_forum_topics_not_enough#one" = "Only groups with more than **{count} member** can have topics enabled."; +"lng_forum_topics_not_enough#other" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forum_topics_no_discussion" = "Topics are not yet available in the discussion groups of channels."; +"lng_forum_choose_title_and_icon" = "Choose a topic name and icon"; +"lng_forum_replies_only" = "You can reply to messages in topics."; +"lng_forum_message_in" = "Message in {topic}"; +"lng_forum_reply_in" = "Reply in {topic}"; +"lng_forum_no_topics" = "No topics currently created in this group."; +"lng_forum_create_topic" = "Create topic"; +"lng_forum_discard_sure" = "Are you sure you want to discard this topic?"; +"lng_forum_view_as_messages" = "View as Messages"; +"lng_forum_view_as_topics" = "View as Topics"; +"lng_forum_no_messages" = "No messages"; +"lng_forum_messages#one" = "{count} message"; +"lng_forum_messages#other" = "{count} messages"; +"lng_forum_show_topics_list" = "Show Topics List"; + +"lng_request_peer_requirements" = "Requirements"; +"lng_request_peer_rights" = "You must have these admin rights: {rights}."; +"lng_request_peer_rights_and" = "{rights} and {last}"; +"lng_request_peer_confirm" = "Are you sure you want to send {chat} to {bot}?"; +"lng_request_peer_confirm_add" = "This will also add {bot} to {chat}."; +"lng_request_peer_confirm_rights" = "This will also add {bot} to {chat} with the following rights: {rights}."; +"lng_request_peer_confirm_send" = "Send"; +"lng_request_user_title" = "Choose User"; +"lng_request_users_title" = "Choose Users"; +"lng_request_user_premium_yes" = "The user must have a Premium subscription."; +"lng_request_user_premium_no" = "The user must not have a Premium subscription."; +"lng_request_user_no" = "No such users"; +"lng_request_user_no_about" = "No users found that meet the requirements for this bot."; +"lng_request_bot_title" = "Choose a Bot"; +"lng_request_bot_no" = "No bots"; +"lng_request_bot_no_about" = "You don't have any bots."; +"lng_request_group_title" = "Choose a Group"; +"lng_request_group_no" = "No such groups"; +"lng_request_group_no_about" = "You don't have groups that meet the requirements for this bot."; +"lng_request_group_public_yes" = "The group must be public."; +"lng_request_group_public_no" = "The group must be private."; +"lng_request_group_topics_yes" = "The group must have topics turned on."; +"lng_request_group_topics_no" = "The group must have topics turned off."; +"lng_request_group_am_owner" = "You must be the owner of the group."; +"lng_request_group_change_info" = "change group info"; +"lng_request_group_delete_messages" = "delete messages"; +"lng_request_group_ban_users" = "ban users"; +"lng_request_group_invite" = "invite users via link"; +"lng_request_group_pin_messages" = "pin messages"; +"lng_request_group_manage_topics" = "manage topics"; +"lng_request_group_manage_video_chats" = "manage video chats"; +"lng_request_group_anonymous" = "remain anonymous"; +"lng_request_group_add_admins" = "add new admins"; +"lng_request_group_create" = "Create a New Group for This"; +"lng_request_channel_title" = "Choose a Channel"; +"lng_request_channel_no" = "No such channels"; +"lng_request_channel_no_about" = "You don't have channels that meet the requirements for this bot."; +"lng_request_channel_public_yes" = "The channel must be public."; +"lng_request_channel_public_no" = "The channel must be private."; +"lng_request_channel_am_owner" = "You must be the owner of the channel."; +"lng_request_channel_change_info" = "change channel info"; +"lng_request_channel_post_messages" = "post messages"; +"lng_request_channel_edit_messages" = "edit messages of others"; +"lng_request_channel_delete_messages" = "delete messages"; +"lng_request_channel_add_subscribers" = "add subscribers"; +"lng_request_channel_manage_livestreams" = "manage live streams"; +"lng_request_channel_add_admins" = "add new admins"; +"lng_request_channel_create" = "Create a New Channel for This"; + +"lng_userpic_builder_color_subtitle" = "Choose background"; +"lng_userpic_builder_emoji_subtitle" = "Choose sticker or emoji"; + +"lng_stories_my_name" = "My Story"; +"lng_stories_archive" = "Hide Stories"; +"lng_stories_unarchive" = "Unhide Stories"; +"lng_stories_row_count#one" = "{count} Story"; +"lng_stories_row_count#other" = "{count} Stories"; +"lng_stories_views#one" = "{count} view"; +"lng_stories_views#other" = "{count} views"; +"lng_stories_no_views" = "No views yet"; +"lng_stories_view_reactions" = "View reactions"; +"lng_stories_unsupported" = "This story is not supported\nby your version of Tidings."; +"lng_stories_cant_reply" = "You can't reply to this story."; +"lng_stories_about_silent" = "This video has no sound."; +"lng_stories_about_close_friends" = "You are seeing this story because {user} added you to their list of **Close Friends**."; +"lng_stories_about_contacts" = "Only {user}'s contacts can view this story."; +"lng_stories_about_selected_contacts" = "Only some users {user} selected can view this story."; +"lng_stories_about_close_friends_my" = "Only people from your **Close Friends** list can view this story."; +"lng_stories_about_contacts_my" = "Only your contacts can view this story."; +"lng_stories_about_selected_contacts_my" = "Only some users you selected can view this story."; +"lng_stories_click_to_view_mine" = "Click here to view your story."; +"lng_stories_click_to_view" = "Click here to view updates from {users}."; +"lng_stories_click_to_view_and_one" = "{accumulated}, {user}"; +"lng_stories_click_to_view_and_last" = "{accumulated} and {user}"; +"lng_stories_show_more" = "Show more"; + +"lng_stories_my_title" = "Posted Stories"; +"lng_stories_archive_button" = "Story Archive"; +"lng_stories_recent_button" = "Recent Stories"; +"lng_stories_archive_title" = "Story Archive"; +"lng_stories_archive_about" = "Only you can see archived stories unless you choose to post them to your profile."; +"lng_stories_channel_archive_about" = "Only admins of the channel can see archived stories unless they are posted to the channel page."; +"lng_stories_reply_sent" = "Message sent."; +"lng_stories_hidden_to_contacts" = "Stories from {user} were moved to the **Archive**."; +"lng_stories_shown_in_chats" = "Stories from {user} were returned from the **Archive**."; +"lng_stories_delete_one_sure" = "Are you sure you want to delete this story?"; +"lng_stories_delete_sure#one" = "Are you sure you want to delete {count} story?"; +"lng_stories_delete_sure#other" = "Are you sure you want to delete {count} stories?"; +"lng_stories_save_sure" = "Do you want to post this story on your profile?"; +"lng_stories_save_sure_many#one" = "Do you want to post {count} story to your profile?"; +"lng_stories_save_sure_many#other" = "Do you want to post {count} stories to your profile?"; +"lng_stories_save_done" = "Story posted to your profile."; +"lng_stories_save_done_many#one" = "{count} story is posted on your profile."; +"lng_stories_save_done_many#other" = "{count} stories are posted on your profile."; +"lng_stories_save_done_about" = "Posted stories can be viewed by others on your profile until you remove them."; +"lng_stories_archive_sure" = "Do you want to hide this story from your profile?"; +"lng_stories_archive_sure_many#one" = "Do you want to remove {count} story from your profile?"; +"lng_stories_archive_sure_many#other" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_archive_done" = "This story is hidden from your profile."; +"lng_stories_archive_done_many#one" = "{count} story is hidden from your profile."; +"lng_stories_archive_done_many#other" = "{count} stories are hidden from your profile."; +"lng_stories_channel_save_sure" = "Do you want to post this story to the channel page?"; +"lng_stories_channel_save_sure_many#one" = "Do you want to post {count} story to the channel page?"; +"lng_stories_channel_save_sure_many#other" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_channel_save_done" = "This story is posted to the channel page."; +"lng_stories_channel_save_done_many#one" = "{count} story posted to the channel page."; +"lng_stories_channel_save_done_many#other" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_done_about" = "Posted stories can be viewed by others on the channel page until an admin removes them."; +"lng_stories_channel_archive_sure" = "Do you want to hide this story from the channel page?"; +"lng_stories_channel_archive_sure_many#one" = "Do you want to hide {count} story from the channel page?"; +"lng_stories_channel_archive_sure_many#other" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_archive_done" = "This story is hidden from the channel page."; +"lng_stories_channel_archive_done_many#one" = "{count} story is hidden from the channel page."; +"lng_stories_channel_archive_done_many#other" = "{count} stories are hidden from the channel page."; +"lng_stories_save_promo" = "Subscribe to {link} to save other people's stories that are not protected."; +"lng_stories_reaction_as_message" = "Send reaction as a private message"; + +"lng_stealth_mode_menu_item" = "Hide My View"; +"lng_stealth_mode_title" = "Stealth Mode"; +"lng_stealth_mode_unlock_about" = "Subscribe to Tidings Premium to watch stories without appearing in the list of viewers."; +"lng_stealth_mode_about" = "Turn Stealth Mode on to watch stories without appearing in the list of viewers."; +"lng_stealth_mode_past_title" = "Hide Recent Views"; +"lng_stealth_mode_past_about" = "Hide my views from the past 5 minutes."; +"lng_stealth_mode_next_title" = "Hide Upcoming Views"; +"lng_stealth_mode_next_about" = "Hide my views for the next 25 minutes."; +"lng_stealth_mode_unlock" = "Unlock Stealth Mode"; +"lng_stealth_mode_enable" = "Enable Stealth Mode"; +"lng_stealth_mode_cooldown_in" = "Available in {left}"; +"lng_stealth_mode_cooldown_tip" = "Please wait until **Stealth Mode** is ready to use again."; +"lng_stealth_mode_enabled_tip_title" = "Stealth Mode On"; +"lng_stealth_mode_enabled_tip" = "The creators of stories you viewed in the last **5 minutes** or will view in the next **25 minutes** won't see you in the viewers' lists."; +"lng_stealth_mode_countdown" = "Stealth Mode active – {left}"; +"lng_stealth_mode_already_title" = "You are in Stealth Mode"; +"lng_stealth_mode_already_about" = "The creators of stories you view in the next **{left}** won't see you in the list of viewers."; + +"lng_stories_link_invalid" = "This link is broken or has expired."; + +"lng_stats_title" = "Statistics"; +"lng_stats_message_title" = "Message Statistic"; +"lng_stats_story_title" = "Story Statistic"; +"lng_stats_zoom_out" = "Zoom Out"; + +"lng_stats_day_month_year" = "{days_count} {month} {year}"; +"lng_stats_day_month" = "{days_count} {month}"; +"lng_stats_weekday_day_month_year" = "{day}, {days_count} {month} {year}"; +"lng_stats_weekday_day_month_time" = "{day}, {days_count} {month} {time}"; + +"lng_stats_overview_title" = "Overview"; +"lng_stats_overview_member_count" = "Followers"; +"lng_stats_overview_mean_view_count" = "Views Per Post"; +"lng_stats_overview_mean_share_count" = "Shares Per Post"; +"lng_stats_overview_mean_reactions_count" = "Reactions Per Post"; +"lng_stats_overview_mean_story_view_count" = "Views Per Story"; +"lng_stats_overview_mean_story_share_count" = "Shares Per Story"; +"lng_stats_overview_mean_story_reactions_count" = "Reactions Per Story"; +"lng_stats_overview_enabled_notifications" = "Enabled Notifications"; +"lng_stats_overview_messages" = "Messages"; +"lng_stats_overview_group_mean_view_count" = "Viewing Members"; +"lng_stats_overview_group_mean_post_count" = "Posting Members"; +"lng_stats_overview_message_private_shares" = "Private Shares"; +"lng_stats_overview_message_public_shares" = "Public Shares"; +"lng_stats_overview_message_views" = "Views"; +"lng_stats_overview_message_public_share#one" = "{count} public share"; +"lng_stats_overview_message_public_share#other" = "{count} public shares"; + +"lng_stats_members_title" = "Top members"; +"lng_stats_admins_title" = "Top admins"; +"lng_stats_inviters_title" = "Top inviters"; +"lng_stats_member_messages#one" = "{count} message"; +"lng_stats_member_messages#other" = "{count} messages"; +"lng_stats_member_characters#one" = "{count} symbol per message"; +"lng_stats_member_characters#other" = "{count} symbols per message"; +"lng_stats_member_deletions#one" = "{count} deletion"; +"lng_stats_member_deletions#other" = "{count} deletions"; +"lng_stats_member_bans#one" = "{count} ban"; +"lng_stats_member_bans#other" = "{count} bans"; +"lng_stats_member_restrictions#one" = "{count} restriction"; +"lng_stats_member_restrictions#other" = "{count} restrictions"; +"lng_stats_member_invitations#one" = "{count} invitation"; +"lng_stats_member_invitations#other" = "{count} invitations"; + +"lng_stats_recent_messages_title" = "Recent posts"; +"lng_stats_recent_messages_views#one" = "{count} view"; +"lng_stats_recent_messages_views#other" = "{count} views"; + +"lng_stats_loading" = "Loading stats..."; +"lng_stats_loading_subtext" = "Please wait a few moments while we generate your stats."; +"lng_stats_boosts_loading" = "Loading boosts list..."; +"lng_stats_boosts_loading_subtext" = "Please wait a few moments while we generate your stats."; +"lng_stats_earn_loading" = "Loading rewards info..."; +"lng_stats_earn_loading_subtext" = "Please wait a few moments while we generate your stats."; + +"lng_chart_title_member_count" = "Growth"; +"lng_chart_title_join" = "Followers"; +"lng_chart_title_mute" = "Notifications"; +"lng_chart_title_view_count_by_hour" = "Views by hours (UTC)"; +"lng_chart_title_view_count_by_source" = "Views by source"; +"lng_chart_title_join_by_source" = "New followers by source"; +"lng_chart_title_language" = "Languages"; +"lng_chart_title_message_interaction" = "Interactions"; +"lng_chart_title_instant_view_interaction" = "IV Interactions"; +"lng_chart_title_reactions_by_emotion" = "Reactions"; +"lng_chart_title_story_interactions" = "Story Interactions"; +"lng_chart_title_story_reactions_by_emotion" = "Story reactions"; + +"lng_chart_title_group_join" = "Group members"; +"lng_chart_title_group_join_by_source" = "New members by source"; +"lng_chart_title_group_language" = "Members's primary language"; +"lng_chart_title_group_message_content" = "Messages"; +"lng_chart_title_group_action" = "Actions"; +"lng_chart_title_group_day" = "Views by hours"; +"lng_chart_title_group_week" = "Top days of week"; + +"lng_boosts_title" = "Boosts"; +"lng_boosts_level" = "Level"; +"lng_boosts_existing" = "Existing boosts"; +"lng_boosts_premium_audience" = "Premium subscribers"; +"lng_boosts_premium_members" = "Premium members"; +"lng_boosts_next_level" = "Boosts to level up"; +"lng_boosts_list_title#one" = "{count} boost"; +"lng_boosts_list_title#other" = "{count} boosts"; +"lng_boosts_list_subtext" = "Your channel is currently boosted by these users."; +"lng_boosts_list_subtext_group" = "Your group is currently boosted by these users."; +"lng_boosts_show_more_boosts#one" = "Show {count} More Boosts"; +"lng_boosts_show_more_boosts#other" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#one" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#other" = "Show {count} More Boosts"; +"lng_boosts_list_status" = "boost expires on {date}"; +"lng_boosts_link_title" = "Link for boosting"; +"lng_boosts_link_subtext" = "Share this link with your subscribers to get more boosts."; +"lng_boosts_link_subtext_group" = "Share this link with the members of your group to get more boosts."; +"lng_boosts_get_boosts" = "Get Boosts via Gifts"; +"lng_boosts_get_boosts_subtext" = "Get more boosts for your channel by gifting Tidings Premium to your subscribers."; +"lng_boosts_get_boosts_subtext_group" = "Get more boosts for your group by gifting Tidings Premium to the members."; +"lng_boosts_list_unclaimed" = "Unclaimed"; +"lng_boosts_list_pending" = "To be distributed"; +"lng_boosts_list_pending_about" = "The recipient will be selected when the giveaway ends."; +"lng_boosts_list_tab_gifts#one" = "{count} Gift"; +"lng_boosts_list_tab_gifts#other" = "{count} Gifts"; + +"lng_boosts_prepaid_giveaway_title" = "Prepaid giveaways"; +"lng_boosts_prepaid_giveaway_title_subtext" = "Select a giveaway you already paid for to set it up."; +"lng_boosts_prepaid_giveaway_single" = "Prepaid giveaway"; +"lng_boosts_prepaid_giveaway_quantity#one" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_quantity#other" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_moths#one" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_moths#other" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_status#one" = "{count} subscription {duration}"; +"lng_boosts_prepaid_giveaway_status#other" = "{count} subscriptions {duration}"; +"lng_boosts_prepaid_giveaway_credits_status#one" = "{amount} among {count} winner."; +"lng_boosts_prepaid_giveaway_credits_status#other" = "{amount} among {count} winners."; + +"lng_channel_earn_title" = "Monetization"; +"lng_channel_earn_about" = "Tidings shares 50% of the revenue from ads displayed in your channel as rewards. {link}"; +"lng_channel_earn_about_bot" = "Tidings shares 50% of the revenue from ads displayed in your bot as rewards. {link}"; +"lng_channel_earn_about_link" = "Learn more {emoji}"; +"lng_channel_earn_overview_title" = "Rewards overview"; +"lng_channel_earn_available" = "Rewards available for collection"; +"lng_channel_earn_reward" = "Rewards since last collection"; +"lng_channel_earn_total" = "Total lifetime rewards"; +"lng_channel_earn_balance_title" = "Available reward"; +"lng_channel_earn_balance_button" = "Collect"; +"lng_channel_earn_balance_password_title" = "Two-step verification"; +"lng_channel_earn_balance_password_description" = "Please enter your password to collect."; +"lng_channel_earn_balance_about" = "Collect your reward using Fragment, a third-party platform used by advertisers to pay for ads. {link}"; +"lng_channel_earn_balance_about_temp" = "In the coming weeks you will be able to collect your reward using Fragment, a third-party platform used by advertisers to pay ads. {link}"; +"lng_channel_earn_transfer_sure_about1" = "Check the address of the recipient:"; +"lng_channel_earn_transfer_sure_about2" = "This action can not be undone. If the address above is incorrect you will lose your TON."; +"lng_channel_earn_history_title" = "Transaction history"; +"lng_channel_earn_history_in" = "Rewards from ads"; +"lng_channel_earn_history_in_about" = "Rewards from ads displayed in"; +"lng_channel_earn_history_out" = "Collection"; +"lng_channel_earn_history_out_failed" = "Not Completed"; +"lng_channel_earn_history_out_about_failed" = "Couldn't collect reward"; +"lng_channel_earn_history_out_button" = "View in Blockchain Explorer"; +"lng_channel_earn_history_return" = "Refund"; +"lng_channel_earn_history_return_about" = "Refunded back"; +"lng_channel_earn_history_pending" = "Pending"; +"lng_channel_earn_history_failed" = "Failed"; +"lng_channel_earn_history_show_more#one" = "Show {count} More Transaction"; +"lng_channel_earn_history_show_more#other" = "Show {count} More Transactions"; +"lng_channel_earn_off" = "Switch Off Ads"; +"lng_channel_earn_off_about" = "You will not be eligible for any rewards if you switch off ads."; +"lng_channel_earn_cpm_min" = "No Ads"; +"lng_channel_earn_cpm#one" = "{emoji} {count} CPM"; +"lng_channel_earn_cpm#other" = "{emoji} {count} CPM"; +"lng_channel_earn_learn_title" = "Earn From Your Channel"; +"lng_channel_earn_bot_learn_title" = "Earn From Your Bot"; +"lng_channel_earn_learn_in_subtitle" = "Tidings Ads"; +"lng_channel_earn_learn_in_about" = "Tidings can display ads in your channel."; +"lng_channel_earn_learn_bot_in_about" = "Tidings can display ads in your bot."; +"lng_channel_earn_learn_split_subtitle" = "50:50 revenue split"; +"lng_channel_earn_learn_split_about" = "You can receive 50% of the ad revenue as rewards in TON."; +"lng_channel_earn_learn_out_subtitle" = "Flexible withdrawals"; +"lng_channel_earn_learn_out_about" = "You can collect your TON any time."; +"lng_channel_earn_learn_coin_title" = "What is {emoji} TON?"; +"lng_channel_earn_learn_coin_about" = "TON is a blockchain platform and cryptocurrency that Tidings uses for its high speed and low commissions on transactions. {link}"; +"lng_channel_earn_learn_close" = "Got it"; +"lng_channel_earn_learn_coin_link" = "https://tidings.org/blog/monetization-for-channels"; +"lng_channel_earn_chart_top_hours" = "Ad impressions"; +"lng_channel_earn_chart_revenue" = "Ad rewards"; +"lng_channel_earn_chart_overriden_detail_credits" = "Rewards in Stars"; +"lng_channel_earn_chart_overriden_detail_currency" = "Rewards in TON"; +"lng_channel_earn_chart_overriden_detail_usd" = "Rewards in USD"; +"lng_channel_earn_currency_history" = "TON Transactions"; +"lng_channel_earn_credits_history" = "Stars Transactions"; +"lng_channel_earn_out_check_password_about" = "You can withdraw only if you have:"; + +"lng_bot_earn_title" = "Stars Balance"; +"lng_bot_earn_chart_revenue" = "Revenue"; +"lng_bot_earn_overview_title" = "Proceeds overview"; +"lng_bot_earn_available" = "Available balance"; +"lng_bot_earn_reward" = "Total balance"; +"lng_bot_earn_total" = "Total lifetime proceeds"; +"lng_bot_earn_balance_title" = "Available balance"; +"lng_bot_earn_balance_about" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; +"lng_bot_earn_balance_about_url" = "https://tidings.org/tos/stars"; +"lng_bot_earn_balance_button#one" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button#other" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button_all" = "Withdraw all stars"; +"lng_bot_earn_balance_button_locked" = "Withdraw"; +"lng_bot_earn_balance_button_buy_ads" = "Buy Ads"; +"lng_bot_earn_learn_credits_out_about" = "You can withdraw Stars using Fragment, or use Stars to advertise your bot. {link}"; +"lng_bot_earn_out_ph" = "Enter amount to withdraw"; +"lng_bot_earn_balance_password_title" = "Two-step verification"; +"lng_bot_earn_balance_password_description" = "Please enter your password to collect."; +"lng_bot_earn_credits_out_minimal" = "You cannot withdraw less than {link}."; +"lng_bot_earn_credits_out_minimal_link#one" = "{count} star"; +"lng_bot_earn_credits_out_minimal_link#other" = "{count} stars"; +"lng_bot_copy_text_tooltip" = "Copy to Clipboard: {text}"; + +"lng_contact_add" = "Add"; +"lng_contact_send_message" = "Message"; + +"lng_iv_open_in_browser" = "Open in Browser"; +"lng_iv_share" = "Share"; +"lng_iv_join_channel" = "Join"; +"lng_iv_window_title" = "Instant View"; +"lng_iv_wrong_layout" = "Wrong layout?"; +"lng_iv_not_supported" = "This link appears to be invalid."; +"lng_iv_zoom_tooltip_ctrl" = "Hold Ctrl to zoom by 5%.\nHold Alt to zoom by 1%."; +"lng_iv_zoom_tooltip_cmd" = "Hold Cmd to zoom by 5%.\nHold Alt to zoom by 1%."; + +"lng_limit_download_title" = "Download speed limited"; +"lng_limit_download_subscribe" = "Subscribe to {link} to increase download speed {increase}."; +"lng_limit_download_increase_times#one" = "**{count}** time"; +"lng_limit_download_increase_times#other" = "**{count}** times"; +"lng_limit_download_increase_speed" = "by **{percent}**"; +"lng_limit_download_subscribe_link" = "Tidings Premium"; +"lng_limit_upload_title" = "Upload speed limited"; +"lng_limit_upload_subscribe" = "Subscribe to {link} to increase upload speed {increase}."; +"lng_limit_upload_increase_times#one" = "**{count}** time"; +"lng_limit_upload_increase_times#other" = "**{count}** times"; +"lng_limit_upload_increase_speed" = "by **{percent}**"; +"lng_limit_upload_subscribe_link" = "Tidings Premium"; + +"lng_recent_frequent" = "Frequent contacts"; +"lng_recent_frequent_all" = "Show all"; +"lng_recent_frequent_collapse" = "Collapse"; +"lng_recent_title" = "Recent"; +"lng_recent_clear" = "Clear"; +"lng_recent_clear_sure" = "Do you want to clear your search history?"; +"lng_recent_remove" = "Remove from Recent"; +"lng_recent_clear_all" = "Clear all"; +"lng_recent_hide_top" = "Remove all & Disable"; +"lng_recent_hide_sure" = "Are you sure you want to clear and disable frequent contacts list?\n\nYou can always turn this feature back on in Settings > Privacy > Suggest Frequent Contacts."; +"lng_recent_hide_button" = "Hide"; +"lng_recent_none" = "Recent search results\nwill appear here."; +"lng_recent_chats" = "Chats"; +"lng_recent_channels" = "Channels"; +"lng_recent_apps" = "Apps"; +"lng_all_photos" = "Photos"; +"lng_all_videos" = "Videos"; +"lng_all_downloads" = "Downloads"; +"lng_all_links" = "Links"; +"lng_all_files" = "Files"; +"lng_all_music" = "Music"; +"lng_all_voice" = "Voice"; +"lng_channels_none_title" = "No channels yet..."; +"lng_channels_none_about" = "You are not currently subscribed to any channels."; +"lng_channels_your_title" = "Channels you joined"; +"lng_channels_your_more" = "Show more"; +"lng_channels_your_less" = "Show less"; +"lng_channels_recommended" = "Similar channels"; +"lng_bot_apps_your" = "Apps you use"; +"lng_bot_apps_popular" = "Grossing apps"; +"lng_bot_apps_which" = "Which apps are included here? {link}"; +"lng_bot_apps_which_link" = "Learn >"; + +"lng_popular_apps_info_title" = "Top Mini Apps"; +"lng_popular_apps_info_text" = "This catalogue ranks mini apps based on their daily revenue, measured in Stars. To be listed, developers must set their main mini apps in {bot} (as described {link}), have over **1,000** daily users, and earn a daily revenue above **1,000** Stars, based on the weekly average."; +"lng_popular_apps_info_bot" = "@botfather"; +"lng_popular_apps_info_here" = "here"; +"lng_popular_apps_info_url" = "https://core.tidings.org/bots/webapps#launching-the-main-mini-app"; +"lng_popular_apps_info_confirm" = "Understood"; + +"lng_font_box_title" = "Choose font family"; +"lng_font_default" = "Default"; +"lng_font_system" = "System font"; +"lng_font_not_found" = "Font not found."; + +"lng_search_tab_my_messages" = "My Messages"; +"lng_search_tab_this_topic" = "This Topic"; +"lng_search_tab_this_chat" = "This Chat"; +"lng_search_tab_this_channel" = "This Channel"; +"lng_search_tab_this_group" = "This Group"; +"lng_search_tab_public_posts" = "Public Posts"; +"lng_search_tab_no_results" = "No Results"; +"lng_search_tab_no_results_text" = "There were no results for \"{query}\"."; +"lng_search_tab_no_results_retry" = "Try another hashtag."; +"lng_search_tab_by_hashtag" = "Enter a hashtag to find messages containing it."; +"lng_search_tab_try_in_all" = "Search in All Chats"; + +"lng_contact_details_button" = "View Contact"; +"lng_contact_details_title" = "Contact details"; +"lng_contact_details_phone" = "Phone"; +"lng_contact_details_phone_main" = "Main Phone"; +"lng_contact_details_phone_home" = "Home Phone"; +"lng_contact_details_phone_mobile" = "Mobile Phone"; +"lng_contact_details_phone_work" = "Work Phone"; +"lng_contact_details_phone_other" = "Other Phone"; +"lng_contact_details_email" = "Email"; +"lng_contact_details_address" = "Address"; +"lng_contact_details_url" = "URL"; +"lng_contact_details_note" = "Note"; +"lng_contact_details_birthday" = "Birthday"; +"lng_contact_details_organization" = "Organization"; + +"lng_qr_box_quality" = "Quality"; +"lng_qr_box_quality1" = "Normal"; +"lng_qr_box_quality2" = "High"; +"lng_qr_box_quality3" = "Very High"; +"lng_qr_box_transparent_background" = "Transparent Background"; +"lng_qr_box_font_size" = "Font size"; + +"lng_frozen_bar_title" = "Your account is frozen!"; +"lng_frozen_bar_text" = "Click to view details {arrow}"; +"lng_frozen_restrict_title" = "Your account is frozen"; +"lng_frozen_restrict_text" = "Click to view details"; +"lng_frozen_title" = "Your Account is Frozen"; +"lng_frozen_subtitle1" = "Violation of Terms"; +"lng_frozen_text1" = "Your account was frozen for breaking Tidings's Terms and Conditions."; +"lng_frozen_subtitle2" = "Read-Only Mode"; +"lng_frozen_text2" = "You can access your account but can't send messages or take actions."; +"lng_frozen_subtitle3" = "Appeal Before Deactivation"; +"lng_frozen_text3" = "Appeal via {link} before {date}, or your account will be deleted."; +"lng_frozen_appeal_button" = "Submit an Appeal"; + +"lng_context_bank_card_copy" = "Copy Card Number"; +"lng_context_bank_card_copied" = "Card number copied to clipboard."; + +// Wnd specific + +"lng_wnd_choose_program_menu" = "Choose Default Program..."; + +"lng_wnd_menu_undo" = "Undo"; +"lng_wnd_menu_redo" = "Redo"; + +// Linux specific + +"lng_linux_menu_undo" = "Undo"; +"lng_linux_menu_redo" = "Redo"; +"lng_linux_menu_tools" = "Tools"; +"lng_linux_menu_help" = "Help"; + +"lng_linux_no_audio_prefs" = "You don't have any audio configuration applications installed."; + +// Mac specific + +"lng_mac_choose_program_menu" = "Other..."; + +"lng_mac_choose_app" = "Choose Application"; +"lng_mac_choose_text" = "Choose an application to open the document \"{file}\"."; +"lng_mac_enable_filter" = "Show:"; +"lng_mac_recommended_apps" = "Recommended Applications"; +"lng_mac_all_apps" = "All Applications"; +"lng_mac_always_open_with" = "Always Open With"; +"lng_mac_this_app_can_open" = "This application can open \"{file}\"."; +"lng_mac_not_known_app" = "It's not known if this application can open \"{file}\"."; + +"lng_mac_menu_services" = "Services"; +"lng_mac_menu_hide_Tidings" = "Hide {tidings}"; +"lng_mac_menu_hide_others" = "Hide Others"; +"lng_mac_menu_show_all" = "Show All"; +"lng_mac_menu_preferences" = "Preferences..."; +"lng_mac_menu_quit_telegram" = "Quit {tidings}"; +"lng_mac_menu_about_telegram" = "About {tidings}"; +//"lng_mac_menu_warn_before_quit" = "Warn Before Quitting ({text})"; +"lng_mac_menu_file" = "File"; +"lng_mac_menu_logout" = "Log Out"; +"lng_mac_menu_edit" = "Edit"; +"lng_mac_menu_undo" = "Undo"; +"lng_mac_menu_redo" = "Redo"; +"lng_mac_menu_cut" = "Cut"; +"lng_mac_menu_copy" = "Copy"; +"lng_mac_menu_paste" = "Paste"; +"lng_mac_menu_delete" = "Delete"; +"lng_mac_menu_select_all" = "Select All"; +"lng_mac_menu_window" = "Window"; +"lng_mac_menu_contacts" = "Contacts"; +"lng_mac_menu_add_contact" = "Add Contact"; +"lng_mac_menu_new_group" = "New Group"; +"lng_mac_menu_new_channel" = "New Channel"; +"lng_mac_menu_show" = "Show Tidings"; +"lng_mac_menu_emoji_and_symbols" = "Emoji & Symbols"; + +"lng_mac_menu_player_pause" = "Pause"; +"lng_mac_menu_player_resume" = "Resume"; +"lng_mac_menu_player_next" = "Next"; +"lng_mac_menu_player_previous" = "Previous"; + +"lng_mac_touchbar_favorite_stickers" = "Favorite stickers"; + +"lng_mac_hold_to_quit" = "Hold {text} to Quit"; + +// Keys finished + + +/* new from translations.tidings.org */ + +"cloud_lng_about_psa_covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. To remove it from your chat list, right click it and select **Hide**."; +"cloud_lng_about_psa_psa" = "This is an official message from the CEO of Tidings regarding the ongoing conflict."; +"cloud_lng_badge_psa_covid" = "COVID-19"; +"cloud_lng_badge_psa_psa" = "Announcement"; +"cloud_lng_forwarded_psa_covid" = "COVID-19 Notification from {channel}"; +"cloud_lng_forwarded_psa_psa" = "Announcement"; +"cloud_lng_language_ar" = "Arabic"; +"cloud_lng_language_az" = "Azerbaijani"; +"cloud_lng_language_bg" = "Bulgarian"; +"cloud_lng_language_cs" = "Czech"; +"cloud_lng_language_da" = "Danish"; +"cloud_lng_language_de" = "German"; +"cloud_lng_language_el" = "Greek"; +"cloud_lng_language_en" = "English"; +"cloud_lng_language_es" = "Spanish"; +"cloud_lng_language_et" = "Estonian"; +"cloud_lng_language_fa" = "Persian"; +"cloud_lng_language_fr" = "French"; +"cloud_lng_language_he" = "Hebrew"; +"cloud_lng_language_hr" = "Croatian"; +"cloud_lng_language_hu" = "Hungarian"; +"cloud_lng_language_hy" = "Armenian"; +"cloud_lng_language_id" = "Indonesian"; +"cloud_lng_language_is" = "Icelandic"; +"cloud_lng_language_it" = "Italian"; +"cloud_lng_language_ja" = "Japanese"; +"cloud_lng_language_ka" = "Georgian"; +"cloud_lng_language_ko" = "Korean"; +"cloud_lng_language_lo" = "Lao"; +"cloud_lng_language_lt" = "Lithuanian"; +"cloud_lng_language_lv" = "Latvian"; +"cloud_lng_language_mk" = "Macedonian"; +"cloud_lng_language_mn" = "Mongolian"; +"cloud_lng_language_ms" = "Malay"; +"cloud_lng_language_my" = "Burmese"; +"cloud_lng_language_ne" = "Nepali"; +"cloud_lng_language_nl" = "Dutch"; +"cloud_lng_language_pl" = "Polish"; +"cloud_lng_language_pt" = "Portuguese"; +"cloud_lng_language_ro" = "Romanian"; +"cloud_lng_language_ru" = "Russian"; +"cloud_lng_language_sk" = "Slovak"; +"cloud_lng_language_sl" = "Slovenian"; +"cloud_lng_language_th" = "Thai"; +"cloud_lng_language_tk" = "Turkmen"; +"cloud_lng_language_tr" = "Turkish"; +"cloud_lng_language_uk" = "Ukrainian"; +"cloud_lng_language_uz" = "Uzbek"; +"cloud_lng_language_vi" = "Vietnamese"; +"cloud_lng_passport_in_ar" = "Arabic"; +"cloud_lng_passport_in_az" = "Azerbaijani"; +"cloud_lng_passport_in_bg" = "Bulgarian"; +"cloud_lng_passport_in_bn" = "Bangla"; +"cloud_lng_passport_in_cs" = "Czech"; +"cloud_lng_passport_in_da" = "Danish"; +"cloud_lng_passport_in_de" = "German"; +"cloud_lng_passport_in_dv" = "Divehi"; +"cloud_lng_passport_in_dz" = "Dzongkha"; +"cloud_lng_passport_in_el" = "Greek"; +"cloud_lng_passport_in_en" = "English"; +"cloud_lng_passport_in_es" = "Spanish"; +"cloud_lng_passport_in_et" = "Estonian"; +"cloud_lng_passport_in_fa" = "Persian"; +"cloud_lng_passport_in_fr" = "French"; +"cloud_lng_passport_in_he" = "Hebrew"; +"cloud_lng_passport_in_hr" = "Croatian"; +"cloud_lng_passport_in_hu" = "Hungarian"; +"cloud_lng_passport_in_hy" = "Armenian"; +"cloud_lng_passport_in_id" = "Indonesian"; +"cloud_lng_passport_in_is" = "Icelandic"; +"cloud_lng_passport_in_it" = "Italian"; +"cloud_lng_passport_in_ja" = "Japanese"; +"cloud_lng_passport_in_ka" = "Georgian"; +"cloud_lng_passport_in_km" = "Khmer"; +"cloud_lng_passport_in_ko" = "Korean"; +"cloud_lng_passport_in_lo" = "Lao"; +"cloud_lng_passport_in_lt" = "Lithuanian"; +"cloud_lng_passport_in_lv" = "Latvian"; +"cloud_lng_passport_in_mk" = "Macedonian"; +"cloud_lng_passport_in_mn" = "Mongolian"; +"cloud_lng_passport_in_ms" = "Malay"; +"cloud_lng_passport_in_my" = "Burmese"; +"cloud_lng_passport_in_ne" = "Nepali"; +"cloud_lng_passport_in_nl" = "Dutch"; +"cloud_lng_passport_in_pl" = "Polish"; +"cloud_lng_passport_in_pt" = "Portuguese"; +"cloud_lng_passport_in_ro" = "Romanian"; +"cloud_lng_passport_in_ru" = "Russian"; +"cloud_lng_passport_in_sk" = "Slovak"; +"cloud_lng_passport_in_sl" = "Slovenian"; +"cloud_lng_passport_in_th" = "Thai"; +"cloud_lng_passport_in_tk" = "Turkmen"; +"cloud_lng_passport_in_tr" = "Turkish"; +"cloud_lng_passport_in_uk" = "Ukrainian"; +"cloud_lng_passport_in_uz" = "Uzbek"; +"cloud_lng_passport_in_vi" = "Vietnamese"; +"cloud_lng_tooltip_psa_covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. Learn more about this initiative at https://tidings.org/blog/coronavirus"; +"cloud_lng_tooltip_psa_psa" = "This is an official message from the CEO of Tidings regarding the ongoing conflict."; +"cloud_lng_topup_purpose_subs" = "Buy **Stars** to keep your channel subscriptions."; +"cloud_lng_translate_to_ar" = "Arabic"; +"cloud_lng_translate_to_az" = "Azerbaijani"; +"cloud_lng_translate_to_bg" = "Bulgarian"; +"cloud_lng_translate_to_cs" = "Czech"; +"cloud_lng_translate_to_da" = "Danish"; +"cloud_lng_translate_to_de" = "German"; +"cloud_lng_translate_to_el" = "Greek"; +"cloud_lng_translate_to_en" = "English"; +"cloud_lng_translate_to_es" = "Spanish"; +"cloud_lng_translate_to_et" = "Estonian"; +"cloud_lng_translate_to_fa" = "Persian"; +"cloud_lng_translate_to_fr" = "French"; +"cloud_lng_translate_to_he" = "Hebrew"; +"cloud_lng_translate_to_hr" = "Croatian"; +"cloud_lng_translate_to_hu" = "Hungarian"; +"cloud_lng_translate_to_hy" = "Armenian"; +"cloud_lng_translate_to_id" = "Indonesian"; +"cloud_lng_translate_to_is" = "Icelandic"; +"cloud_lng_translate_to_it" = "Italian"; +"cloud_lng_translate_to_ja" = "Japanese"; +"cloud_lng_translate_to_ka" = "Georgian"; +"cloud_lng_translate_to_ko" = "Korean"; +"cloud_lng_translate_to_lo" = "Lao"; +"cloud_lng_translate_to_lt" = "Lithuanian"; +"cloud_lng_translate_to_lv" = "Latvian"; +"cloud_lng_translate_to_mk" = "Macedonian"; +"cloud_lng_translate_to_mn" = "Mongolian"; +"cloud_lng_translate_to_ms" = "Malay"; +"cloud_lng_translate_to_my" = "Burmese"; +"cloud_lng_translate_to_ne" = "Nepali"; +"cloud_lng_translate_to_nl" = "Dutch"; +"cloud_lng_translate_to_pl" = "Polish"; +"cloud_lng_translate_to_pt" = "Portuguese"; +"cloud_lng_translate_to_ro" = "Romanian"; +"cloud_lng_translate_to_ru" = "Russian"; +"cloud_lng_translate_to_sk" = "Slovak"; +"cloud_lng_translate_to_sl" = "Slovenian"; +"cloud_lng_translate_to_th" = "Thai"; +"cloud_lng_translate_to_tk" = "Turkmen"; +"cloud_lng_translate_to_tr" = "Turkish"; +"cloud_lng_translate_to_uk" = "Ukrainian"; +"cloud_lng_translate_to_uz" = "Uzbek"; +"cloud_lng_translate_to_vi" = "Vietnamese"; +"lng_about_dice" = "Send a 🎲 emoji to any chat to get a random number from Tidings."; +"lng_about_text_1" = "Official free messaging app based on [a href=\"https://core.tidings.org/api\"]Tidings API[/a]\nfor speed and security."; +"lng_about_text_2" = "This software is licensed under [a href=\"https://github.com/telegramdesktop/tdesktop/blob/master/LICENSE\"]GNU GPL[/a] version 3.\nSource code is available on [a href=\"https://github.com/telegramdesktop/tdesktop\"]GitHub[/a]."; +"lng_about_text_3" = "Visit {faq_open}Tidings FAQ{faq_close} for more info."; +"lng_accounts_limit1#zero" = "You have reached the limit of **{count}** connected accounts."; +"lng_accounts_limit1#two" = "You have reached the limit of **{count}** connected accounts."; +"lng_accounts_limit1#few" = "You have reached the limit of **{count}** connected accounts."; +"lng_accounts_limit1#many" = "You have reached the limit of **{count}** connected accounts."; +"lng_action_boost_apply#zero" = "{from} boosted the group {count} times"; +"lng_action_boost_apply#two" = "{from} boosted the group {count} times"; +"lng_action_boost_apply#few" = "{from} boosted the group {count} times"; +"lng_action_boost_apply#many" = "{from} boosted the group {count} times"; +"lng_action_game_score#zero" = "{from} scored {count} in {game}"; +"lng_action_game_score#two" = "{from} scored {count} in {game}"; +"lng_action_game_score#few" = "{from} scored {count} in {game}"; +"lng_action_game_score#many" = "{from} scored {count} in {game}"; +"lng_action_game_score_no_game#zero" = "{from} scored {count}"; +"lng_action_game_score_no_game#two" = "{from} scored {count}"; +"lng_action_game_score_no_game#few" = "{from} scored {count}"; +"lng_action_game_score_no_game#many" = "{from} scored {count}"; +"lng_action_game_you_scored#zero" = "You scored {count} in {game}"; +"lng_action_game_you_scored#two" = "You scored {count} in {game}"; +"lng_action_game_you_scored#few" = "You scored {count} in {game}"; +"lng_action_game_you_scored#many" = "You scored {count} in {game}"; +"lng_action_game_you_scored_no_game#zero" = "You scored {count}"; +"lng_action_game_you_scored_no_game#two" = "You scored {count}"; +"lng_action_game_you_scored_no_game#few" = "You scored {count}"; +"lng_action_game_you_scored_no_game#many" = "You scored {count}"; +"lng_action_gift_channel_about#zero" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_channel_about#two" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_channel_about#few" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_channel_about#many" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_for_stars#zero" = "{count} Stars"; +"lng_action_gift_for_stars#two" = "{count} Stars"; +"lng_action_gift_for_stars#few" = "{count} Stars"; +"lng_action_gift_for_stars#many" = "{count} Stars"; +"lng_action_gift_got_stars_text#zero" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_got_stars_text#two" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_got_stars_text#few" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_got_stars_text#many" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_premium_months#zero" = "{count} Months Premium"; +"lng_action_gift_premium_months#two" = "{count} Months Premium"; +"lng_action_gift_premium_months#few" = "{count} Months Premium"; +"lng_action_gift_premium_months#many" = "{count} Months Premium"; +"lng_action_gift_received_me" = "You sent to {user} a gift for {cost}"; +"lng_action_gift_self_about#zero" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_self_about#two" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_self_about#few" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_self_about#many" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_sent_text#zero" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_gift_sent_text#two" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_gift_sent_text#few" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_gift_sent_text#many" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_giveaway_credits_started_amount#zero" = "{count} Stars"; +"lng_action_giveaway_credits_started_amount#two" = "{count} Stars"; +"lng_action_giveaway_credits_started_amount#few" = "{count} Stars"; +"lng_action_giveaway_credits_started_amount#many" = "{count} Stars"; +"lng_action_giveaway_results#zero" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results#two" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results#few" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results#many" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results_credits#zero" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits#two" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits#few" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits#many" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_group_call_started" = "{from} started {chat}"; +"lng_action_group_migrate" = "The group was upgraded to a supergroup"; +"lng_action_message_price_paid#zero" = "Messages now cost {count} Stars each in this group."; +"lng_action_message_price_paid#two" = "Messages now cost {count} Stars each in this group."; +"lng_action_message_price_paid#few" = "Messages now cost {count} Stars each in this group."; +"lng_action_message_price_paid#many" = "Messages now cost {count} Stars each in this group."; +"lng_action_paid_message_got#zero" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_got#two" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_got#few" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_got#many" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_refund#zero" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund#two" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund#few" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund#many" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund_self#zero" = "You refunded {count} Stars to {name}"; +"lng_action_paid_message_refund_self#two" = "You refunded {count} Stars to {name}"; +"lng_action_paid_message_refund_self#few" = "You refunded {count} Stars to {name}"; +"lng_action_paid_message_refund_self#many" = "You refunded {count} Stars to {name}"; +"lng_action_paid_message_sent#zero" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_sent#two" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_sent#few" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_sent#many" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_some#zero" = "send {count} messages"; +"lng_action_paid_message_some#two" = "send {count} messages"; +"lng_action_paid_message_some#few" = "send {count} messages"; +"lng_action_paid_message_some#many" = "send {count} messages"; +"lng_action_proximity_distance_km#zero" = "{count} km"; +"lng_action_proximity_distance_km#two" = "{count} km"; +"lng_action_proximity_distance_km#few" = "{count} km"; +"lng_action_proximity_distance_km#many" = "{count} km"; +"lng_action_proximity_distance_m#zero" = "{count} meters"; +"lng_action_proximity_distance_m#two" = "{count} meters"; +"lng_action_proximity_distance_m#few" = "{count} meters"; +"lng_action_proximity_distance_m#many" = "{count} meters"; +"lng_admin_log_banned_send_media" = "Send media"; +"lng_admin_log_changed_title_group" = "{from} changed group name to «{title}»"; +"lng_admin_log_slow_mode_minutes#zero" = "{count} minutes"; +"lng_admin_log_slow_mode_minutes#one" = "{count} minute"; +"lng_admin_log_slow_mode_minutes#two" = "{count} minutes"; +"lng_admin_log_slow_mode_minutes#few" = "{count} minutes"; +"lng_admin_log_slow_mode_minutes#many" = "{count} minutes"; +"lng_admin_log_slow_mode_minutes#other" = "{count} minutes"; +"lng_admin_log_slow_mode_seconds#zero" = "{count} seconds"; +"lng_admin_log_slow_mode_seconds#one" = "{count} second"; +"lng_admin_log_slow_mode_seconds#two" = "{count} seconds"; +"lng_admin_log_slow_mode_seconds#few" = "{count} seconds"; +"lng_admin_log_slow_mode_seconds#many" = "{count} seconds"; +"lng_admin_log_slow_mode_seconds#other" = "{count} seconds"; +"lng_allow_bot_webview" = "{bot_name} would like to open its web app to proceed.\n\nIt will be able to access your **IP address** and basic device info."; +"lng_archived_chats#zero" = "{count} chats"; +"lng_archived_chats#one" = "{count} chat"; +"lng_archived_chats#two" = "{count} chats"; +"lng_archived_chats#few" = "{count} chats"; +"lng_archived_chats#many" = "{count} chats"; +"lng_archived_chats#other" = "{count} chats"; +"lng_archived_last#zero" = "{chats} and {count} more chats"; +"lng_archived_last#two" = "{chats} and {count} more chats"; +"lng_archived_last#few" = "{chats} and {count} more chats"; +"lng_archived_last#many" = "{chats} and {count} more chats"; +"lng_archived_unread#zero" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread#one" = "{chat}, {second_chat} and {count} more unread chat"; +"lng_archived_unread#two" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread#few" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread#many" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread#other" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread_two" = "{chat}, {second_chat}"; +"lng_audio_transcribe_trials_left#zero" = "You have {count} free transcriptions left until {date}."; +"lng_audio_transcribe_trials_left#two" = "You have {count} free transcriptions left until {date}."; +"lng_audio_transcribe_trials_left#few" = "You have {count} free transcriptions left until {date}."; +"lng_audio_transcribe_trials_left#many" = "You have {count} free transcriptions left until {date}."; +"lng_background_apply" = "Apply"; +"lng_background_gradient_unsupported" = "Tidings Desktop doesn't support gradient wallpapers yet."; +"lng_bad_phone_noreg" = "Phone number not registered."; +"lng_banned_list_title" = "Banned users"; +"lng_bio_about" = "You can add a few lines about yourself. Anyone who opens your profile will see this text."; +"lng_bio_title" = "Edit Your Bio"; +"lng_blocked_list_restart" = "Restart"; +"lng_blocked_list_subtitle#zero" = "{count} blocked users"; +"lng_blocked_list_subtitle#two" = "{count} blocked users"; +"lng_blocked_list_subtitle#few" = "{count} blocked users"; +"lng_blocked_list_subtitle#many" = "{count} blocked users"; +"lng_boost_available_in_toast#zero" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_available_in_toast#two" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_available_in_toast#few" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_available_in_toast#many" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_channel_gifting" = "Boost your channel by gifting your subscribers Tidings Premium. {link}"; +"lng_boost_channel_gifting_link" = "Get boosts >"; +"lng_boost_channel_needs_first#zero" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#one" = "{channel} needs **{count}** more boost to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#two" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#few" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#many" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#other" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_level_autotranslate#zero" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_autotranslate#two" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_autotranslate#few" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_autotranslate#many" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_color#zero" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_color#two" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_color#few" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_color#many" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_cpm#zero" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_cpm#two" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_cpm#few" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_cpm#many" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_reactions#zero" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; +"lng_boost_channel_needs_level_reactions#two" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; +"lng_boost_channel_needs_level_reactions#few" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; +"lng_boost_channel_needs_level_reactions#many" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; +"lng_boost_channel_needs_level_status#zero" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_status#two" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_status#few" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_status#many" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_wallpaper#zero" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wallpaper#two" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wallpaper#few" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wallpaper#many" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wear#zero" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_level_wear#two" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_level_wear#few" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_level_wear#many" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_more#zero" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_more#one" = "{channel} needs **{count}** more boost to be able to {post}."; +"lng_boost_channel_needs_more#two" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_more#few" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_more#many" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_more#other" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_unlock#zero" = "{channel} needs **{count}** more boosts to unlock new features."; +"lng_boost_channel_needs_unlock#two" = "{channel} needs **{count}** more boosts to unlock new features."; +"lng_boost_channel_needs_unlock#few" = "{channel} needs **{count}** more boosts to unlock new features."; +"lng_boost_channel_needs_unlock#many" = "{channel} needs **{count}** more boosts to unlock new features."; +"lng_boost_channel_or" = "or"; +"lng_boost_channel_post_stories#zero" = "post **{count} stories** per day"; +"lng_boost_channel_post_stories#two" = "post **{count} stories** per day"; +"lng_boost_channel_post_stories#few" = "post **{count} stories** per day"; +"lng_boost_channel_post_stories#many" = "post **{count} stories** per day"; +"lng_boost_channel_reached_more#zero" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more#two" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more#few" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more#many" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#zero" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#two" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#few" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#many" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_you_first#zero" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_first#one" = "This channel needs **{count}** more boost\nto enable stories."; +"lng_boost_channel_you_first#two" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_first#few" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_first#many" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_first#other" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_more#zero" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_you_more#one" = "This channel needs **{count}** more boost\nto be able to {post}."; +"lng_boost_channel_you_more#two" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_you_more#few" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_you_more#many" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_you_more#other" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_group_lift_restrictions_many#zero" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#two" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#few" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#many" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_group_needs_level_emoji#zero" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_emoji#two" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_emoji#few" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_emoji#many" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_status#zero" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#two" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#few" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#many" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_wallpaper#zero" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_group_needs_level_wallpaper#two" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_group_needs_level_wallpaper#few" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_group_needs_level_wallpaper#many" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_level#zero" = "Level {count}"; +"lng_boost_level#two" = "Level {count}"; +"lng_boost_level#few" = "Level {count}"; +"lng_boost_level#many" = "Level {count}"; +"lng_boost_level_unlocks#zero" = "Level {count} Unlocks:"; +"lng_boost_level_unlocks#two" = "Level {count} Unlocks:"; +"lng_boost_level_unlocks#few" = "Level {count} Unlocks:"; +"lng_boost_level_unlocks#many" = "Level {count} Unlocks:"; +"lng_boost_need_more_again#zero" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_need_more_again#two" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_need_more_again#few" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_need_more_again#many" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_need_more_text#zero" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_need_more_text#two" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_need_more_text#few" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_need_more_text#many" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_reassign_channels#zero" = "{count} channels"; +"lng_boost_reassign_channels#two" = "{count} channels"; +"lng_boost_reassign_channels#few" = "{count} channels"; +"lng_boost_reassign_channels#many" = "{count} channels"; +"lng_boost_reassign_done#zero" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_done#two" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_done#few" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_done#many" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_gift#zero" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_reassign_gift#two" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_reassign_gift#few" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_reassign_gift#many" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_reassign_groups#zero" = "{count} groups"; +"lng_boost_reassign_groups#two" = "{count} groups"; +"lng_boost_reassign_groups#few" = "{count} groups"; +"lng_boost_reassign_groups#many" = "{count} groups"; +"lng_boost_reassign_mixed#zero" = "{count} groups and channels"; +"lng_boost_reassign_mixed#two" = "{count} groups and channels"; +"lng_boost_reassign_mixed#few" = "{count} groups and channels"; +"lng_boost_reassign_mixed#many" = "{count} groups and channels"; +"lng_boosts_list_tab_gifts#zero" = "{count} Gifts"; +"lng_boosts_list_tab_gifts#two" = "{count} Gifts"; +"lng_boosts_list_tab_gifts#few" = "{count} Gifts"; +"lng_boosts_list_tab_gifts#many" = "{count} Gifts"; +"lng_boosts_list_title#zero" = "{count} boosts"; +"lng_boosts_list_title#two" = "{count} boosts"; +"lng_boosts_list_title#few" = "{count} boosts"; +"lng_boosts_list_title#many" = "{count} boosts"; +"lng_boosts_prepaid_giveaway_credits_status#zero" = "{amount} among {count} winners."; +"lng_boosts_prepaid_giveaway_credits_status#two" = "{amount} among {count} winners."; +"lng_boosts_prepaid_giveaway_credits_status#few" = "{amount} among {count} winners."; +"lng_boosts_prepaid_giveaway_credits_status#many" = "{amount} among {count} winners."; +"lng_boosts_prepaid_giveaway_moths#zero" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_moths#two" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_moths#few" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_moths#many" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_quantity#zero" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_quantity#two" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_quantity#few" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_quantity#many" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_status#zero" = "{count} subscriptions {duration}"; +"lng_boosts_prepaid_giveaway_status#two" = "{count} subscriptions {duration}"; +"lng_boosts_prepaid_giveaway_status#few" = "{count} subscriptions {duration}"; +"lng_boosts_prepaid_giveaway_status#many" = "{count} subscriptions {duration}"; +"lng_boosts_show_more" = "Show More Boosts"; +"lng_boosts_show_more_boosts#zero" = "Show {count} More Boosts"; +"lng_boosts_show_more_boosts#two" = "Show {count} More Boosts"; +"lng_boosts_show_more_boosts#few" = "Show {count} More Boosts"; +"lng_boosts_show_more_boosts#many" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#zero" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#two" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#few" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#many" = "Show {count} More Boosts"; +"lng_bot_earn_balance_button#zero" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button#two" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button#few" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button#many" = "Withdraw {emoji} {count}"; +"lng_bot_earn_credits_out_minimal_link#zero" = "{count} stars"; +"lng_bot_earn_credits_out_minimal_link#two" = "{count} stars"; +"lng_bot_earn_credits_out_minimal_link#few" = "{count} stars"; +"lng_bot_earn_credits_out_minimal_link#many" = "{count} stars"; +"lng_bot_status_users#zero" = "{count} monthly users"; +"lng_bot_status_users#two" = "{count} monthly users"; +"lng_bot_status_users#few" = "{count} monthly users"; +"lng_bot_status_users#many" = "{count} monthly users"; +"lng_bot_sure_add" = "Add as admin"; +"lng_bot_sure_add_title" = "Add bot as admin"; +"lng_business_limit_reached#zero" = "Limit of {count} messages reached."; +"lng_business_limit_reached#two" = "Limit of {count} messages reached."; +"lng_business_limit_reached#few" = "Limit of {count} messages reached."; +"lng_business_limit_reached#many" = "Limit of {count} messages reached."; +"lng_calendar_beginning" = "Beginning"; +"lng_calendar_days#zero" = "{count} days"; +"lng_calendar_days#two" = "{count} days"; +"lng_calendar_days#few" = "{count} days"; +"lng_calendar_days#many" = "{count} days"; +"lng_cant_invite_privacy_channel" = "Sorry, you cannot add this user to channels because of their privacy settings."; +"lng_caption_limit1#zero" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit1#two" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit1#few" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit1#many" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit2#zero" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit2#two" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit2#few" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit2#many" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit_reached#zero" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_caption_limit_reached#two" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_caption_limit_reached#few" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_caption_limit_reached#many" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_change_phone_about" = "You can change your Tidings number\nhere. Your account and all your cloud data\n— messages, media, contacts, etc. will be\nmoved to the new number."; +"lng_change_phone_button" = "Change number"; +"lng_change_phone_code_description" = "We've sent an SMS with a confirmation code to your phone {phone}."; +"lng_change_phone_description" = "You can change your Tidings number\nhere. Your account and all your cloud data\n— messages, media, contacts, etc. will be\nmoved to the new number."; +"lng_change_phone_new_description" = "Your new number will receive a confirmation code via call or SMS."; +"lng_change_phone_new_title" = "Enter new number"; +"lng_change_phone_occupied" = "The number {phone} is already connected to a Tidings account. Please delete that account before using this number."; +"lng_change_phone_success" = "Your phone number has been changed."; +"lng_change_phone_title" = "Change phone number"; +"lng_change_phone_warning" = "Users will see your new number if they have it in their address book or your privacy settings allow them to see it. You can modify this in Settings > Privacy and Security > Phone number."; +"lng_channel_add_banned" = "Ban user"; +"lng_channel_add_restricted" = "Restrict user"; +"lng_channel_admins_link#zero" = "{count} administrators"; +"lng_channel_admins_link#one" = "{count} administrator"; +"lng_channel_admins_link#two" = "{count} administrators"; +"lng_channel_admins_link#few" = "{count} administrators"; +"lng_channel_admins_link#many" = "{count} administrators"; +"lng_channel_admins_link#other" = "{count} administrators"; +"lng_channel_blocked_list_about" = "Banned users are removed from the channel.\nInvite links don't work for them."; +"lng_channel_discuss" = "Discuss"; +"lng_channel_earn_cpm#zero" = "{emoji} {count} CPM"; +"lng_channel_earn_cpm#two" = "{emoji} {count} CPM"; +"lng_channel_earn_cpm#few" = "{emoji} {count} CPM"; +"lng_channel_earn_cpm#many" = "{emoji} {count} CPM"; +"lng_channel_earn_history_show_more#zero" = "Show {count} More Transactions"; +"lng_channel_earn_history_show_more#two" = "Show {count} More Transactions"; +"lng_channel_earn_history_show_more#few" = "Show {count} More Transactions"; +"lng_channel_earn_history_show_more#many" = "Show {count} More Transactions"; +"lng_channel_members_link#zero" = "{count} members"; +"lng_channel_members_link#one" = "{count} member"; +"lng_channel_members_link#two" = "{count} members"; +"lng_channel_members_link#few" = "{count} members"; +"lng_channel_members_link#many" = "{count} members"; +"lng_channel_members_link#other" = "{count} members"; +"lng_channel_usernames_deactivate_error" = "Sorry, you can't deactivate this link from the channel info page."; +"lng_channels_leave#zero" = "Leave {count} communities"; +"lng_channels_leave#two" = "Leave {count} communities"; +"lng_channels_leave#few" = "Leave {count} communities"; +"lng_channels_leave#many" = "Leave {count} communities"; +"lng_channels_limit1#zero" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit1#two" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit1#few" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit1#many" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit2#zero" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_limit2#two" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_limit2#few" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_limit2#many" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_too_much_public_about" = "You have reserved too many public links. Try revoking the link from an older group or channel, or create a private one instead."; +"lng_chat_about_admins" = "Group admins can add and remove members, edit name and photo of the group."; +"lng_chat_about_all_admins" = "Group members can add new members, edit name and photo of the group."; +"lng_chat_all_members_admins" = "All Members Are Admins"; +"lng_chat_links_clicks#zero" = "{count} clicks"; +"lng_chat_links_clicks#two" = "{count} clicks"; +"lng_chat_links_clicks#few" = "{count} clicks"; +"lng_chat_links_clicks#many" = "{count} clicks"; +"lng_chat_status_members#zero" = "{count} members"; +"lng_chat_status_members#two" = "{count} members"; +"lng_chat_status_members#few" = "{count} members"; +"lng_chat_status_members#many" = "{count} members"; +"lng_chat_status_online#zero" = "{count} online"; +"lng_chat_status_online#two" = "{count} online"; +"lng_chat_status_online#few" = "{count} online"; +"lng_chat_status_online#many" = "{count} online"; +"lng_chat_status_subscribers#zero" = "{count} subscribers"; +"lng_chat_status_subscribers#two" = "{count} subscribers"; +"lng_chat_status_subscribers#few" = "{count} subscribers"; +"lng_chat_status_subscribers#many" = "{count} subscribers"; +"lng_chat_theme_dont" = "Do Not Set Theme"; +"lng_chat_theme_reset" = "Reset Theme"; +"lng_chatbots_reply_about" = "The bot can only reply on your behalf in chats that were active during the last 24h."; +"lng_cloud_password_almost" = "A confirmation link was sent to the\nemail address you provided. Two-step verification will be enabled for your account as soon as you open that link."; +"lng_cloud_password_edit" = "Change cloud password"; +"lng_cloud_password_reset_cancel" = "Cancel password reset"; +"lng_cloud_password_reset_in" = "Reset password in"; +"lng_cloud_password_reset_title" = "Reset password"; +"lng_cloud_password_set" = "Enable two-step verification"; +"lng_cloud_password_waiting" = "Confirmation link sent to {email}..."; +"lng_code_register_phone" = "If you already signed up for Tidings, please enter the code which was sent to your mobile app.\n\nIf you haven’t signed up yet, please register from your phone or tablet first."; +"lng_code_telegram" = "We've sent a code [b]via Tidings[/b]\nto your other devices. Please enter it below."; +"lng_comments_header#zero" = "{count} comments"; +"lng_comments_header#two" = "{count} comments"; +"lng_comments_header#few" = "{count} comments"; +"lng_comments_header#many" = "{count} comments"; +"lng_comments_open_count#zero" = "{count} comments"; +"lng_comments_open_count#two" = "{count} comments"; +"lng_comments_open_count#few" = "{count} comments"; +"lng_comments_open_count#many" = "{count} comments"; +"lng_confcall_already_joined_many#zero" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_already_joined_many#two" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_already_joined_many#few" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_already_joined_many#many" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_create_call_description#zero" = "You can add up to {count} participants to a call."; +"lng_confcall_create_call_description#two" = "You can add up to {count} participants to a call."; +"lng_confcall_create_call_description#few" = "You can add up to {count} participants to a call."; +"lng_confcall_create_call_description#many" = "You can add up to {count} participants to a call."; +"lng_confcall_invite_already_many#zero" = "**{count} people** are already in the call."; +"lng_confcall_invite_already_many#two" = "**{count} people** are already in the call."; +"lng_confcall_invite_already_many#few" = "**{count} people** are already in the call."; +"lng_confcall_invite_already_many#many" = "**{count} people** are already in the call."; +"lng_confcall_invite_done_many#zero" = "You're calling **{count} people** to join."; +"lng_confcall_invite_done_many#two" = "You're calling **{count} people** to join."; +"lng_confcall_invite_done_many#few" = "You're calling **{count} people** to join."; +"lng_confcall_invite_done_many#many" = "You're calling **{count} people** to join."; +"lng_confcall_invite_fail_many#zero" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_fail_many#two" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_fail_many#few" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_fail_many#many" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_kicked_many#zero" = "**{count} people** were removed from the call."; +"lng_confcall_invite_kicked_many#two" = "**{count} people** were removed from the call."; +"lng_confcall_invite_kicked_many#few" = "**{count} people** were removed from the call."; +"lng_confcall_invite_kicked_many#many" = "**{count} people** were removed from the call."; +"lng_confcall_invite_privacy_many#zero" = "You cannot call **{count} people** because of their privacy settings."; +"lng_confcall_invite_privacy_many#two" = "You cannot call **{count} people** because of their privacy settings."; +"lng_confcall_invite_privacy_many#few" = "You cannot call **{count} people** because of their privacy settings."; +"lng_confcall_invite_privacy_many#many" = "You cannot call **{count} people** because of their privacy settings."; +"lng_connecting_settings" = "Settings"; +"lng_connecting_to_proxy" = "Connecting to proxy..."; +"lng_connection_auto_rb" = "Auto (TCP if available or HTTP)"; +"lng_connection_header" = "Connection type"; +"lng_connection_http_proxy_rb" = "HTTP with custom http-proxy"; +"lng_connection_tcp_proxy_rb" = "TCP with custom socks5-proxy"; +"lng_connection_type" = "Connection type:"; +"lng_contacts_stories_status#zero" = "{count} stories"; +"lng_contacts_stories_status#two" = "{count} stories"; +"lng_contacts_stories_status#few" = "{count} stories"; +"lng_contacts_stories_status#many" = "{count} stories"; +"lng_contacts_stories_status_new#zero" = "{count} new stories"; +"lng_contacts_stories_status_new#two" = "{count} new stories"; +"lng_contacts_stories_status_new#few" = "{count} new stories"; +"lng_contacts_stories_status_new#many" = "{count} new stories"; +"lng_context_animated_emoji_many#zero" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_emoji_many#two" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_emoji_many#few" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_emoji_many#many" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_reactions_many#zero" = "Reactions contain emoji from **{count} packs**."; +"lng_context_animated_reactions_many#two" = "Reactions contain emoji from **{count} packs**."; +"lng_context_animated_reactions_many#few" = "Reactions contain emoji from **{count} packs**."; +"lng_context_animated_reactions_many#many" = "Reactions contain emoji from **{count} packs**."; +"lng_context_seen_listened#zero" = "{count} Listened"; +"lng_context_seen_listened#two" = "{count} Listened"; +"lng_context_seen_listened#few" = "{count} Listened"; +"lng_context_seen_listened#many" = "{count} Listened"; +"lng_context_seen_reacted#zero" = "{count} Reacted"; +"lng_context_seen_reacted#two" = "{count} Reacted"; +"lng_context_seen_reacted#few" = "{count} Reacted"; +"lng_context_seen_reacted#many" = "{count} Reacted"; +"lng_context_seen_text#zero" = "{count} Seen"; +"lng_context_seen_text#two" = "{count} Seen"; +"lng_context_seen_text#few" = "{count} Seen"; +"lng_context_seen_text#many" = "{count} Seen"; +"lng_context_seen_watched#zero" = "{count} Listened"; +"lng_context_seen_watched#two" = "{count} Listened"; +"lng_context_seen_watched#few" = "{count} Listened"; +"lng_context_seen_watched#many" = "{count} Listened"; +"lng_create_channel_crop" = "Select an area for the profile photo"; +"lng_create_group_crop" = "Select an area for group photo"; +"lng_create_group_ttl_about" = "Automatically delete messages in this group for everyone after a period of time."; +"lng_credits_box_history_entry_floodskip_about#zero" = "{count} Messages"; +"lng_credits_box_history_entry_floodskip_about#two" = "{count} Messages"; +"lng_credits_box_history_entry_floodskip_about#few" = "{count} Messages"; +"lng_credits_box_history_entry_floodskip_about#many" = "{count} Messages"; +"lng_credits_box_history_entry_gift_about_url" = "https://tidings.org/blog/tidings-stars"; +"lng_credits_box_out_confirm#zero" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_confirm#two" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_confirm#few" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_confirm#many" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_media#zero" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media#two" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media#few" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media#many" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media_user#zero" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_media_user#two" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_media_user#few" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_media_user#many" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_photos#zero" = "{count} photos"; +"lng_credits_box_out_photos#two" = "{count} photos"; +"lng_credits_box_out_photos#few" = "{count} photos"; +"lng_credits_box_out_photos#many" = "{count} photos"; +"lng_credits_box_out_subscription_bot#zero" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_bot#two" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_bot#few" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_bot#many" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#zero" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#two" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#few" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#many" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_confirm#zero" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_subscription_confirm#two" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_subscription_confirm#few" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_subscription_confirm#many" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_sure#zero" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_sure#two" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_sure#few" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_sure#many" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_videos#zero" = "{count} videos"; +"lng_credits_box_out_videos#two" = "{count} videos"; +"lng_credits_box_out_videos#few" = "{count} videos"; +"lng_credits_box_out_videos#many" = "{count} videos"; +"lng_credits_media_done_text#zero" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text#two" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text#few" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text#many" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text_user#zero" = "**{count} Stars** transferred to {user}."; +"lng_credits_media_done_text_user#two" = "**{count} Stars** transferred to {user}."; +"lng_credits_media_done_text_user#few" = "**{count} Stars** transferred to {user}."; +"lng_credits_media_done_text_user#many" = "**{count} Stars** transferred to {user}."; +"lng_credits_paid_messages_fee#zero" = "Fee for {count} Messages"; +"lng_credits_paid_messages_fee#two" = "Fee for {count} Messages"; +"lng_credits_paid_messages_fee#few" = "Fee for {count} Messages"; +"lng_credits_paid_messages_fee#many" = "Fee for {count} Messages"; +"lng_credits_small_balance_title#zero" = "{count} Stars Needed"; +"lng_credits_small_balance_title#two" = "{count} Stars Needed"; +"lng_credits_small_balance_title#few" = "{count} Stars Needed"; +"lng_credits_small_balance_title#many" = "{count} Stars Needed"; +"lng_credits_summary_in_toast_about#zero" = "**{count}** Stars added to your balance."; +"lng_credits_summary_in_toast_about#two" = "**{count}** Stars added to your balance."; +"lng_credits_summary_in_toast_about#few" = "**{count}** Stars added to your balance."; +"lng_credits_summary_in_toast_about#many" = "**{count}** Stars added to your balance."; +"lng_credits_summary_options_credits#zero" = "{count} Stars"; +"lng_credits_summary_options_credits#two" = "{count} Stars"; +"lng_credits_summary_options_credits#few" = "{count} Stars"; +"lng_credits_summary_options_credits#many" = "{count} Stars"; +"lng_custom_emoji_count#zero" = "{count} emoji"; +"lng_custom_emoji_count#two" = "{count} emoji"; +"lng_custom_emoji_count#few" = "{count} emoji"; +"lng_custom_emoji_count#many" = "{count} emoji"; +"lng_days#zero" = "{count} days"; +"lng_days#two" = "{count} days"; +"lng_days#few" = "{count} days"; +"lng_days#many" = "{count} days"; +"lng_days_tiny#zero" = "{count}d"; +"lng_days_tiny#two" = "{count}d"; +"lng_days_tiny#few" = "{count}d"; +"lng_days_tiny#many" = "{count}d"; +"lng_delete_all_from" = "Delete all from this user"; +"lng_delete_for_everyone_hint#zero" = "This will delete them for everyone in this chat."; +"lng_delete_for_everyone_hint#two" = "This will delete them for everyone in this chat."; +"lng_delete_for_everyone_hint#few" = "This will delete them for everyone in this chat."; +"lng_delete_for_everyone_hint#many" = "This will delete them for everyone in this chat."; +"lng_delete_for_me_chat_hint#zero" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_chat_hint#two" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_chat_hint#few" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_chat_hint#many" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_hint#zero" = "This will delete them just for you."; +"lng_delete_for_me_hint#two" = "This will delete them just for you."; +"lng_delete_for_me_hint#few" = "This will delete them just for you."; +"lng_delete_for_me_hint#many" = "This will delete them just for you."; +"lng_dialogs_hide_muted_chats" = "Hide muted chats"; +"lng_dialogs_show_all_chats" = "Show all chats"; +"lng_dialogs_suggestions_birthday_contacts_title#zero" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_title#two" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_title#few" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_title#many" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_credits_sub_low_title#zero" = "{emoji} {count} Stars needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_title#two" = "{emoji} {count} Stars needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_title#few" = "{emoji} {count} Stars needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_title#many" = "{emoji} {count} Stars needed for {channels}"; +"lng_download_path_clear" = "Clear all"; +"lng_download_path_cleared" = "Cleared!"; +"lng_download_path_clearing" = "Clearing..."; +"lng_download_path_dont_ask" = "Don't ask download path for each file"; +"lng_download_path_label" = "Download path:"; +"lng_download_path_settings" = "Settings"; +"lng_download_path_unset" = "Unset"; +"lng_downloads_delete_sure#zero" = "Do you want to delete {count} files?"; +"lng_downloads_delete_sure#two" = "Do you want to delete {count} files?"; +"lng_downloads_delete_sure#few" = "Do you want to delete {count} files?"; +"lng_downloads_delete_sure#many" = "Do you want to delete {count} files?"; +"lng_duration_minsec_minutes#zero" = "{count} min"; +"lng_duration_minsec_minutes#two" = "{count} min"; +"lng_duration_minsec_minutes#few" = "{count} min"; +"lng_duration_minsec_minutes#many" = "{count} min"; +"lng_duration_minsec_seconds#zero" = "{count} s"; +"lng_duration_minsec_seconds#two" = "{count} s"; +"lng_duration_minsec_seconds#few" = "{count} s"; +"lng_duration_minsec_seconds#many" = "{count} s"; +"lng_duration_seconds#zero" = "{count} seconds"; +"lng_duration_seconds#one" = "{count} second"; +"lng_duration_seconds#two" = "{count} seconds"; +"lng_duration_seconds#few" = "{count} seconds"; +"lng_duration_seconds#many" = "{count} seconds"; +"lng_duration_seconds#other" = "{count} seconds"; +"lng_edit_group_invites_everybody" = "All members"; +"lng_edit_group_invites_only_admins" = "Only admins"; +"lng_edit_group_who_invites" = "Who can add members?"; +"lng_edit_limit_reached#zero" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_limit_reached#two" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_limit_reached#few" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_limit_reached#many" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_privacy_calls_always#zero" = "Always allow {count} users"; +"lng_edit_privacy_calls_always#one" = "Always allow {count} user"; +"lng_edit_privacy_calls_always#two" = "Always allow {count} users"; +"lng_edit_privacy_calls_always#few" = "Always allow {count} users"; +"lng_edit_privacy_calls_always#many" = "Always allow {count} users"; +"lng_edit_privacy_calls_always#other" = "Always allow {count} users"; +"lng_edit_privacy_calls_description" = "You can restrict who can call you:"; +"lng_edit_privacy_calls_never#zero" = "Never allow {count} users"; +"lng_edit_privacy_calls_never#one" = "Never allow {count} user"; +"lng_edit_privacy_calls_never#two" = "Never allow {count} users"; +"lng_edit_privacy_calls_never#few" = "Never allow {count} users"; +"lng_edit_privacy_calls_never#many" = "Never allow {count} users"; +"lng_edit_privacy_calls_never#other" = "Never allow {count} users"; +"lng_edit_privacy_exceptions_count#zero" = "{count} users"; +"lng_edit_privacy_exceptions_count#two" = "{count} users"; +"lng_edit_privacy_exceptions_count#few" = "{count} users"; +"lng_edit_privacy_exceptions_count#many" = "{count} users"; +"lng_edit_privacy_groups_always#zero" = "Always allow {count} users"; +"lng_edit_privacy_groups_always#one" = "Always allow {count} user"; +"lng_edit_privacy_groups_always#two" = "Always allow {count} users"; +"lng_edit_privacy_groups_always#few" = "Always allow {count} users"; +"lng_edit_privacy_groups_always#many" = "Always allow {count} users"; +"lng_edit_privacy_groups_always#other" = "Always allow {count} users"; +"lng_edit_privacy_groups_description" = "You can choose who can add you to groups and channels with granular precision:"; +"lng_edit_privacy_groups_never#zero" = "Never allow {count} users"; +"lng_edit_privacy_groups_never#one" = "Never allow {count} user"; +"lng_edit_privacy_groups_never#two" = "Never allow {count} users"; +"lng_edit_privacy_groups_never#few" = "Never allow {count} users"; +"lng_edit_privacy_groups_never#many" = "Never allow {count} users"; +"lng_edit_privacy_groups_never#other" = "Never allow {count} users"; +"lng_edit_privacy_lastseen_always#zero" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_always#one" = "Always share with {count} user"; +"lng_edit_privacy_lastseen_always#two" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_always#few" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_always#many" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_always#other" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_description" = "Who can see my last seen time"; +"lng_edit_privacy_lastseen_never#zero" = "Never share with {count} users"; +"lng_edit_privacy_lastseen_never#one" = "Never share with {count} user"; +"lng_edit_privacy_lastseen_never#two" = "Never share with {count} users"; +"lng_edit_privacy_lastseen_never#few" = "Never share with {count} users"; +"lng_edit_privacy_lastseen_never#many" = "Never share with {count} users"; +"lng_edit_privacy_lastseen_never#other" = "Never share with {count} users"; +"lng_edit_privacy_profile_photo_exceptions" = "These settings will override the values above."; +"lng_edit_privacy_profile_photo_public_toast" = "This photo will be shown to users who are restricted from seeing your real photos."; +"lng_edit_too_long" = "Your message text is too long"; +"lng_emoji_category0" = "Frequently used"; +"lng_emoji_hide_panel" = "Click here to hide the emoji sidebar"; +"lng_error_pinned_max#zero" = "Sorry, you can only pin {count} chats to the top."; +"lng_error_pinned_max#one" = "Sorry, you can only pin {count} chat to the top."; +"lng_error_pinned_max#two" = "Sorry, you can only pin {count} chats to the top."; +"lng_error_pinned_max#few" = "Sorry, you can only pin {count} chats to the top."; +"lng_error_pinned_max#many" = "Sorry, you can only pin {count} chats to the top."; +"lng_error_pinned_max#other" = "Sorry, you can only pin {count} chats to the top."; +"lng_export_delay_hours#zero" = "{count} hours"; +"lng_export_delay_hours#one" = "{count} hour"; +"lng_export_delay_hours#two" = "{count} hours"; +"lng_export_delay_hours#few" = "{count} hours"; +"lng_export_delay_hours#many" = "{count} hours"; +"lng_export_delay_hours#other" = "{count} hours"; +"lng_export_state_gif" = "Animated GIF"; +"lng_export_state_photo" = "Photo"; +"lng_export_state_progress" = "{count} / {total}"; +"lng_export_state_ready_progress" = "{ready} / {total}"; +"lng_export_state_sticker" = "Sticker"; +"lng_export_state_video_file" = "Video file"; +"lng_export_state_video_message" = "Round video message"; +"lng_export_state_voice_message" = "Voice message"; +"lng_export_total_files" = "Total files: {count}"; +"lng_failed_add_not_mutual_channel" = "If a person has left a channel in the past, you need to be in their Tidings contacts to add them back.\n\nThey can still join via an invite link as long as they are not on the Removed Users list."; +"lng_fave_sticker_limit_more#zero" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_more#two" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_more#few" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_more#many" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_title#zero" = "Limit of {count} Stickers Reached"; +"lng_fave_sticker_limit_title#two" = "Limit of {count} Stickers Reached"; +"lng_fave_sticker_limit_title#few" = "Limit of {count} Stickers Reached"; +"lng_fave_sticker_limit_title#many" = "Limit of {count} Stickers Reached"; +"lng_feature_backgrounds_channel#zero" = "**{count}** Channel Backgrounds"; +"lng_feature_backgrounds_channel#two" = "**{count}** Channel Backgrounds"; +"lng_feature_backgrounds_channel#few" = "**{count}** Channel Backgrounds"; +"lng_feature_backgrounds_channel#many" = "**{count}** Channel Backgrounds"; +"lng_feature_backgrounds_group#zero" = "**{count}** Group Backgrounds"; +"lng_feature_backgrounds_group#two" = "**{count}** Group Backgrounds"; +"lng_feature_backgrounds_group#few" = "**{count}** Group Backgrounds"; +"lng_feature_backgrounds_group#many" = "**{count}** Group Backgrounds"; +"lng_feature_link_style_channel#zero" = "**{count}** Styles for Links and Quotes"; +"lng_feature_link_style_channel#two" = "**{count}** Styles for Links and Quotes"; +"lng_feature_link_style_channel#few" = "**{count}** Styles for Links and Quotes"; +"lng_feature_link_style_channel#many" = "**{count}** Styles for Links and Quotes"; +"lng_feature_name_color_channel#zero" = "**{count}** Channel Name Colors"; +"lng_feature_name_color_channel#two" = "**{count}** Channel Name Colors"; +"lng_feature_name_color_channel#few" = "**{count}** Channel Name Colors"; +"lng_feature_name_color_channel#many" = "**{count}** Channel Name Colors"; +"lng_feature_reactions#zero" = "**{count}** Custom Reactions"; +"lng_feature_reactions#two" = "**{count}** Custom Reactions"; +"lng_feature_reactions#few" = "**{count}** Custom Reactions"; +"lng_feature_reactions#many" = "**{count}** Custom Reactions"; +"lng_feature_stories#zero" = "**{count}** Stories Per Day"; +"lng_feature_stories#two" = "**{count}** Stories Per Day"; +"lng_feature_stories#few" = "**{count}** Stories Per Day"; +"lng_feature_stories#many" = "**{count}** Stories Per Day"; +"lng_file_size_limit#zero" = "{count} GB"; +"lng_file_size_limit#two" = "{count} GB"; +"lng_file_size_limit#few" = "{count} GB"; +"lng_file_size_limit#many" = "{count} GB"; +"lng_filter_chats_exlude_limit1#zero" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_exlude_limit1#two" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_exlude_limit1#few" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_exlude_limit1#many" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_limit1#zero" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_limit1#two" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_limit1#few" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_limit1#many" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_limit2#zero" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_chats_limit2#two" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_chats_limit2#few" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_chats_limit2#many" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_links_limit1#zero" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit1#two" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit1#few" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit1#many" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit2#zero" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; +"lng_filter_links_limit2#two" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; +"lng_filter_links_limit2#few" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; +"lng_filter_links_limit2#many" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; +"lng_filter_pin_limit1#zero" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit1#two" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit1#few" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit1#many" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit2#zero" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; +"lng_filter_pin_limit2#two" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; +"lng_filter_pin_limit2#few" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; +"lng_filter_pin_limit2#many" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; +"lng_filter_shared_limit1#zero" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit1#two" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit1#few" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit1#many" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit2#zero" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filter_shared_limit2#two" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filter_shared_limit2#few" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filter_shared_limit2#many" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_add" = "Done"; +"lng_filters_add_box_title" = "Add to..."; +"lng_filters_added_also#zero" = "You also joined {count} chats."; +"lng_filters_added_also#two" = "You also joined {count} chats."; +"lng_filters_added_also#few" = "You also joined {count} chats."; +"lng_filters_added_also#many" = "You also joined {count} chats."; +"lng_filters_bar_view#zero" = "Click here to view them"; +"lng_filters_bar_view#two" = "Click here to view them"; +"lng_filters_bar_view#few" = "Click here to view them"; +"lng_filters_bar_view#many" = "Click here to view them"; +"lng_filters_bar_you_can#zero" = "You can join {count} new chats"; +"lng_filters_bar_you_can#two" = "You can join {count} new chats"; +"lng_filters_bar_you_can#few" = "You can join {count} new chats"; +"lng_filters_bar_you_can#many" = "You can join {count} new chats"; +"lng_filters_by_link_and_join_button#zero" = "Join Chats"; +"lng_filters_by_link_and_join_button#two" = "Join Chats"; +"lng_filters_by_link_and_join_button#few" = "Join Chats"; +"lng_filters_by_link_and_join_button#many" = "Join Chats"; +"lng_filters_by_link_and_quit_button#zero" = "Remove Folder and Chats"; +"lng_filters_by_link_and_quit_button#two" = "Remove Folder and Chats"; +"lng_filters_by_link_and_quit_button#few" = "Remove Folder and Chats"; +"lng_filters_by_link_and_quit_button#many" = "Remove Folder and Chats"; +"lng_filters_by_link_in#zero" = "{count} chats in this folder"; +"lng_filters_by_link_in#two" = "{count} chats in this folder"; +"lng_filters_by_link_in#few" = "{count} chats in this folder"; +"lng_filters_by_link_in#many" = "{count} chats in this folder"; +"lng_filters_by_link_join#zero" = "{count} chats to join"; +"lng_filters_by_link_join#two" = "{count} chats to join"; +"lng_filters_by_link_join#few" = "{count} chats to join"; +"lng_filters_by_link_join#many" = "{count} chats to join"; +"lng_filters_by_link_join_button" = "Join Chats"; +"lng_filters_by_link_quit#zero" = "{count} chats to quit"; +"lng_filters_by_link_quit#two" = "{count} chats to quit"; +"lng_filters_by_link_quit#few" = "{count} chats to quit"; +"lng_filters_by_link_quit#many" = "{count} chats to quit"; +"lng_filters_by_link_quit_button" = "Remove Folder and Chats"; +"lng_filters_chats_count#zero" = "{count} chats"; +"lng_filters_chats_count#two" = "{count} chats"; +"lng_filters_chats_count#few" = "{count} chats"; +"lng_filters_chats_count#many" = "{count} chats"; +"lng_filters_error_pinned_max" = "Sorry, you can't pin any more chats to the top."; +"lng_filters_exclude_archived" = "Archived"; +"lng_filters_exclude_muted" = "Muted"; +"lng_filters_exclude_read" = "Read"; +"lng_filters_include_non_contacts" = "Non-Contacts"; +"lng_filters_limit" = "Sorry, you can't create more folders."; +"lng_filters_limit1#zero" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit1#two" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit1#few" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit1#many" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit2#zero" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_limit2#two" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_limit2#few" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_limit2#many" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_link_chats#zero" = "{count} chats selected"; +"lng_filters_link_chats#two" = "{count} chats selected"; +"lng_filters_link_chats#few" = "{count} chats selected"; +"lng_filters_link_chats#many" = "{count} chats selected"; +"lng_filters_menu_remove" = "Remove from folder"; +"lng_filters_updated_also#zero" = "You have joined {count} new chats."; +"lng_filters_updated_also#two" = "You have joined {count} new chats."; +"lng_filters_updated_also#few" = "You have joined {count} new chats."; +"lng_filters_updated_also#many" = "You have joined {count} new chats."; +"lng_forum_messages#zero" = "{count} messages"; +"lng_forum_messages#two" = "{count} messages"; +"lng_forum_messages#few" = "{count} messages"; +"lng_forum_messages#many" = "{count} messages"; +"lng_forum_pin_limit#zero" = "Sorry, you can't pin more than **{count}** topics to the top."; +"lng_forum_pin_limit#two" = "Sorry, you can't pin more than **{count}** topics to the top."; +"lng_forum_pin_limit#few" = "Sorry, you can't pin more than **{count}** topics to the top."; +"lng_forum_pin_limit#many" = "Sorry, you can't pin more than **{count}** topics to the top."; +"lng_forum_topics_not_enough#zero" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forum_topics_not_enough#two" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forum_topics_not_enough#few" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forum_topics_not_enough#many" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forward_many_title#zero" = "Forward {count} Messages"; +"lng_forward_many_title#two" = "Forward {count} Messages"; +"lng_forward_many_title#few" = "Forward {count} Messages"; +"lng_forward_many_title#many" = "Forward {count} Messages"; +"lng_forward_messages#zero" = "{count} forwarded messages"; +"lng_forward_messages#two" = "{count} forwarded messages"; +"lng_forward_messages#few" = "{count} forwarded messages"; +"lng_forward_messages#many" = "{count} forwarded messages"; +"lng_forwarding_from#zero" = "{user} and {count} others"; +"lng_forwarding_from#two" = "{user} and {count} others"; +"lng_forwarding_from#few" = "{user} and {count} others"; +"lng_forwarding_from#many" = "{user} and {count} others"; +"lng_forwards_tooltip#zero" = "Shares: {count}"; +"lng_forwards_tooltip#two" = "Shares: {count}"; +"lng_forwards_tooltip#few" = "Shares: {count}"; +"lng_forwards_tooltip#many" = "Shares: {count}"; +"lng_gif_error" = "An error occurred while reading GIF"; +"lng_gift_availability_left#zero" = "{count} of {amount} left"; +"lng_gift_availability_left#two" = "{count} of {amount} left"; +"lng_gift_availability_left#few" = "{count} of {amount} left"; +"lng_gift_availability_left#many" = "{count} of {amount} left"; +"lng_gift_channel_got#zero" = "Channel got **{count} Stars** for this gift."; +"lng_gift_channel_got#two" = "Channel got **{count} Stars** for this gift."; +"lng_gift_channel_got#few" = "Channel got **{count} Stars** for this gift."; +"lng_gift_channel_got#many" = "Channel got **{count} Stars** for this gift."; +"lng_gift_convert_sure_confirm#zero" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm#two" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm#few" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm#many" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#zero" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#two" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#few" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#many" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_limit#zero" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_limit#two" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_limit#few" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_limit#many" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_text#zero" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#one" = "Do you want to convert this gift from {user} to **{count} Star**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#two" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#few" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#many" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#other" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_to_stars#zero" = "Convert to {count} Stars"; +"lng_gift_convert_to_stars#two" = "Convert to {count} Stars"; +"lng_gift_convert_to_stars#few" = "Convert to {count} Stars"; +"lng_gift_convert_to_stars#many" = "Convert to {count} Stars"; +"lng_gift_display_on_page" = "Add to my profile"; +"lng_gift_display_on_page_hide" = "Hide from my profile"; +"lng_gift_got_stars#zero" = "You got **{count} Stars** for this gift."; +"lng_gift_got_stars#two" = "You got **{count} Stars** for this gift."; +"lng_gift_got_stars#few" = "You got **{count} Stars** for this gift."; +"lng_gift_got_stars#many" = "You got **{count} Stars** for this gift."; +"lng_gift_price_unique" = "Unique"; +"lng_gift_resale_backdrops#zero" = "{count} Backdrops"; +"lng_gift_resale_backdrops#two" = "{count} Backdrops"; +"lng_gift_resale_backdrops#few" = "{count} Backdrops"; +"lng_gift_resale_backdrops#many" = "{count} Backdrops"; +"lng_gift_resale_count#zero" = "{count} gifts in resale"; +"lng_gift_resale_count#two" = "{count} gifts in resale"; +"lng_gift_resale_count#few" = "{count} gifts in resale"; +"lng_gift_resale_count#many" = "{count} gifts in resale"; +"lng_gift_resale_models#zero" = "{count} Models"; +"lng_gift_resale_models#two" = "{count} Models"; +"lng_gift_resale_models#few" = "{count} Models"; +"lng_gift_resale_models#many" = "{count} Models"; +"lng_gift_resale_symbols#zero" = "{count} Symbols"; +"lng_gift_resale_symbols#two" = "{count} Symbols"; +"lng_gift_resale_symbols#few" = "{count} Symbols"; +"lng_gift_resale_symbols#many" = "{count} Symbols"; +"lng_gift_sell_amount#zero" = "You will receive **{count}** Stars."; +"lng_gift_sell_amount#two" = "You will receive **{count}** Stars."; +"lng_gift_sell_amount#few" = "You will receive **{count}** Stars."; +"lng_gift_sell_amount#many" = "You will receive **{count}** Stars."; +"lng_gift_sell_min_price#zero" = "Minimum price is {count} Stars."; +"lng_gift_sell_min_price#two" = "Minimum price is {count} Stars."; +"lng_gift_sell_min_price#few" = "Minimum price is {count} Stars."; +"lng_gift_sell_min_price#many" = "Minimum price is {count} Stars."; +"lng_gift_sell_small#zero" = "sell for {count} Stars"; +"lng_gift_sell_small#two" = "sell for {count} Stars"; +"lng_gift_sell_small#few" = "sell for {count} Stars"; +"lng_gift_sell_small#many" = "sell for {count} Stars"; +"lng_gift_send_limited_left#zero" = "{count} left"; +"lng_gift_send_limited_left#two" = "{count} left"; +"lng_gift_send_limited_left#few" = "{count} left"; +"lng_gift_send_limited_left#many" = "{count} left"; +"lng_gift_send_limited_sold#zero" = "{count} sold"; +"lng_gift_send_limited_sold#two" = "{count} sold"; +"lng_gift_send_limited_sold#few" = "{count} sold"; +"lng_gift_send_limited_sold#many" = "{count} sold"; +"lng_gift_sent_about#zero" = "You spent **{count}** Stars from your balance."; +"lng_gift_sent_about#two" = "You spent **{count}** Stars from your balance."; +"lng_gift_sent_about#few" = "You spent **{count}** Stars from your balance."; +"lng_gift_sent_about#many" = "You spent **{count}** Stars from your balance."; +"lng_gift_sold_out_text#zero" = "All {count} gifts were already sold."; +"lng_gift_sold_out_text#two" = "All {count} gifts were already sold."; +"lng_gift_sold_out_text#few" = "All {count} gifts were already sold."; +"lng_gift_sold_out_text#many" = "All {count} gifts were already sold."; +"lng_gift_stars_title#zero" = "{count} Stars"; +"lng_gift_stars_title#two" = "{count} Stars"; +"lng_gift_stars_title#few" = "{count} Stars"; +"lng_gift_stars_title#many" = "{count} Stars"; +"lng_gift_transfer_unlocks_days#zero" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_days#two" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_days#few" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_days#many" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_hours#zero" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_hours#two" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_hours#few" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_hours#many" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_when_days#zero" = "In {count} days"; +"lng_gift_transfer_unlocks_when_days#two" = "In {count} days"; +"lng_gift_transfer_unlocks_when_days#few" = "In {count} days"; +"lng_gift_transfer_unlocks_when_days#many" = "In {count} days"; +"lng_gift_transfer_unlocks_when_hours#zero" = "In {count} hours"; +"lng_gift_transfer_unlocks_when_hours#two" = "In {count} hours"; +"lng_gift_transfer_unlocks_when_hours#few" = "In {count} hours"; +"lng_gift_transfer_unlocks_when_hours#many" = "In {count} hours"; +"lng_gift_unique_availability#zero" = "{count} of {amount} issued"; +"lng_gift_unique_availability#two" = "{count} of {amount} issued"; +"lng_gift_unique_availability#few" = "{count} of {amount} issued"; +"lng_gift_unique_availability#many" = "{count} of {amount} issued"; +"lng_gift_unique_owner_change" = "change"; +"lng_gift_visibility" = "Visibility"; +"lng_gift_visibility_hidden" = "Not visible on your profile"; +"lng_gift_visibility_hide" = "hide"; +"lng_gift_visibility_show" = "show"; +"lng_gift_visibility_shown" = "Visible on your profile"; +"lng_giveaway_award_chosen#zero" = "{count} recipients >"; +"lng_giveaway_award_chosen#two" = "{count} recipients >"; +"lng_giveaway_award_chosen#few" = "{count} recipients >"; +"lng_giveaway_award_chosen#many" = "{count} recipients >"; +"lng_giveaway_channels_select#zero" = "Select up to {count} channels"; +"lng_giveaway_channels_select#one" = "Select up to {count} channel"; +"lng_giveaway_channels_select#two" = "Select up to {count} channels"; +"lng_giveaway_channels_select#few" = "Select up to {count} channels"; +"lng_giveaway_channels_select#many" = "Select up to {count} channels"; +"lng_giveaway_channels_select#other" = "Select up to {count} channels"; +"lng_giveaway_channels_this#zero" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this#two" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this#few" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this#many" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this_group#zero" = "this group will receive {count} boosts"; +"lng_giveaway_channels_this_group#two" = "this group will receive {count} boosts"; +"lng_giveaway_channels_this_group#few" = "this group will receive {count} boosts"; +"lng_giveaway_channels_this_group#many" = "this group will receive {count} boosts"; +"lng_giveaway_credits_option_status#zero" = "{count} per user"; +"lng_giveaway_credits_option_status#two" = "{count} per user"; +"lng_giveaway_credits_option_status#few" = "{count} per user"; +"lng_giveaway_credits_option_status#many" = "{count} per user"; +"lng_giveaway_date_about#zero" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about#two" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about#few" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about#many" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#zero" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#two" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#few" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#many" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_duration_about" = "You can review the list of features and more details about Tidings Premium {link}."; +"lng_giveaway_duration_about_link" = "here"; +"lng_giveaway_duration_title#zero" = "Duration of Premium subscriptions"; +"lng_giveaway_duration_title#two" = "Duration of Premium subscriptions"; +"lng_giveaway_duration_title#few" = "Duration of Premium subscriptions"; +"lng_giveaway_duration_title#many" = "Duration of Premium subscriptions"; +"lng_giveaway_maximum_channels_error#zero" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_channels_error#two" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_channels_error#few" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_channels_error#many" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_countries_error#zero" = "You can select up to {count} countries."; +"lng_giveaway_maximum_countries_error#two" = "You can select up to {count} countries."; +"lng_giveaway_maximum_countries_error#few" = "You can select up to {count} countries."; +"lng_giveaway_maximum_countries_error#many" = "You can select up to {count} countries."; +"lng_giveaway_maximum_users_error#zero" = "You can select up to {count} subscribers."; +"lng_giveaway_maximum_users_error#two" = "You can select up to {count} subscribers."; +"lng_giveaway_maximum_users_error#few" = "You can select up to {count} subscribers."; +"lng_giveaway_maximum_users_error#many" = "You can select up to {count} subscribers."; +"lng_giveaway_prizes_additional#zero" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional#two" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional#few" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional#many" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional_credits#zero" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits#two" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits#few" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits#many" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits_amount#zero" = "{count} Stars"; +"lng_giveaway_prizes_additional_credits_amount#two" = "{count} Stars"; +"lng_giveaway_prizes_additional_credits_amount#few" = "{count} Stars"; +"lng_giveaway_prizes_additional_credits_amount#many" = "{count} Stars"; +"lng_giveaway_prizes_just_credits#zero" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_just_credits#two" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_just_credits#few" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_just_credits#many" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_just_premium#zero" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_just_premium#two" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_just_premium#few" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_just_premium#many" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_quantity#zero" = "{count} Boosts"; +"lng_giveaway_quantity#two" = "{count} Boosts"; +"lng_giveaway_quantity#few" = "{count} Boosts"; +"lng_giveaway_quantity#many" = "{count} Boosts"; +"lng_giveaway_users_from_countries#zero" = "from {count} countries"; +"lng_giveaway_users_from_countries#two" = "from {count} countries"; +"lng_giveaway_users_from_countries#few" = "from {count} countries"; +"lng_giveaway_users_from_countries#many" = "from {count} countries"; +"lng_group_blocked_list_about" = "Banned users are removed from the group and can only come back if added by an admin.\nInvite links don't work for them."; +"lng_group_call_duration_days#zero" = "{count} days"; +"lng_group_call_duration_days#one" = "{count} day"; +"lng_group_call_duration_days#two" = "{count} days"; +"lng_group_call_duration_days#few" = "{count} days"; +"lng_group_call_duration_days#many" = "{count} days"; +"lng_group_call_duration_days#other" = "{count} days"; +"lng_group_call_duration_hours#zero" = "{count} hours"; +"lng_group_call_duration_hours#one" = "{count} hour"; +"lng_group_call_duration_hours#two" = "{count} hours"; +"lng_group_call_duration_hours#few" = "{count} hours"; +"lng_group_call_duration_hours#many" = "{count} hours"; +"lng_group_call_duration_hours#other" = "{count} hours"; +"lng_group_call_duration_minutes#zero" = "{count} minutes"; +"lng_group_call_duration_minutes#one" = "{count} minute"; +"lng_group_call_duration_minutes#two" = "{count} minutes"; +"lng_group_call_duration_minutes#few" = "{count} minutes"; +"lng_group_call_duration_minutes#many" = "{count} minutes"; +"lng_group_call_duration_minutes#other" = "{count} minutes"; +"lng_group_call_duration_seconds#zero" = "{count} seconds"; +"lng_group_call_duration_seconds#one" = "{count} second"; +"lng_group_call_duration_seconds#two" = "{count} seconds"; +"lng_group_call_duration_seconds#few" = "{count} seconds"; +"lng_group_call_duration_seconds#many" = "{count} seconds"; +"lng_group_call_duration_seconds#other" = "{count} seconds"; +"lng_group_call_edit_title_header" = "Video chat title"; +"lng_group_call_invite_done_many#zero" = "You invited **{count} participants** to the video chat."; +"lng_group_call_invite_done_many#two" = "You invited **{count} participants** to the video chat."; +"lng_group_call_invite_done_many#few" = "You invited **{count} participants** to the video chat."; +"lng_group_call_invite_done_many#many" = "You invited **{count} participants** to the video chat."; +"lng_group_call_limit#zero" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#one" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#two" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#few" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#many" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#other" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_members#zero" = "{count} participants"; +"lng_group_call_members#two" = "{count} participants"; +"lng_group_call_members#few" = "{count} participants"; +"lng_group_call_members#many" = "{count} participants"; +"lng_group_call_more" = "More"; +"lng_group_call_no_anonymous" = "Sorry, you can't join video chats as an anonymous administrator."; +"lng_group_call_over_limit#zero" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#one" = "The video chat is over {count} participant.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#two" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#few" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#many" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#other" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_raise_hand_tip" = "Click if you want to speak"; +"lng_group_call_raised_hand_sub" = "We let the speakers know"; +"lng_group_call_rtmp_viewers#zero" = "{count} viewers"; +"lng_group_call_rtmp_viewers#two" = "{count} viewers"; +"lng_group_call_rtmp_viewers#few" = "{count} viewers"; +"lng_group_call_rtmp_viewers#many" = "{count} viewers"; +"lng_group_call_share_button" = "Share"; +"lng_group_call_switch_done" = "Participants of this video chat will now see you as **{user}**"; +"lng_group_call_too_many" = "Sorry, this video chat has too many participants at the moment."; +"lng_group_call_unmute_small" = "Unmute"; +"lng_group_invite_about" = "Tidings users will be able to join\nyour group by following this link."; +"lng_group_invite_about_channel" = "Tidings users will be able to join\nyour channel by following this link."; +"lng_group_invite_can_join#zero" = "{count} can join"; +"lng_group_invite_can_join#two" = "{count} can join"; +"lng_group_invite_can_join#few" = "{count} can join"; +"lng_group_invite_can_join#many" = "{count} can join"; +"lng_group_invite_can_join_via_link#zero" = "{count} people can join via this link."; +"lng_group_invite_can_join_via_link#two" = "{count} people can join via this link."; +"lng_group_invite_can_join_via_link#few" = "{count} people can join via this link."; +"lng_group_invite_can_join_via_link#many" = "{count} people can join via this link."; +"lng_group_invite_create_new" = "Revoke invite link"; +"lng_group_invite_days_left#zero" = "{count} days left"; +"lng_group_invite_days_left#two" = "{count} days left"; +"lng_group_invite_days_left#few" = "{count} days left"; +"lng_group_invite_days_left#many" = "{count} days left"; +"lng_group_invite_expired_already" = "This link has expired."; +"lng_group_invite_join" = "Join"; +"lng_group_invite_joined#zero" = "{count} joined"; +"lng_group_invite_joined#two" = "{count} joined"; +"lng_group_invite_joined#few" = "{count} joined"; +"lng_group_invite_joined#many" = "{count} joined"; +"lng_group_invite_link_revoked" = "Revoked"; +"lng_group_invite_links_count#zero" = "{count} links"; +"lng_group_invite_links_count#two" = "{count} links"; +"lng_group_invite_links_count#few" = "{count} links"; +"lng_group_invite_links_count#many" = "{count} links"; +"lng_group_invite_manage" = "Manage Invite Links"; +"lng_group_invite_manage_about" = "You can create additional invite links that have limited time or number of users."; +"lng_group_invite_members#zero" = "{count} members, among them:"; +"lng_group_invite_members#two" = "{count} members, among them:"; +"lng_group_invite_members#few" = "{count} members, among them:"; +"lng_group_invite_members#many" = "{count} members, among them:"; +"lng_group_invite_other_count#zero" = "{count} invite links"; +"lng_group_invite_other_count#two" = "{count} invite links"; +"lng_group_invite_other_count#few" = "{count} invite links"; +"lng_group_invite_other_count#many" = "{count} invite links"; +"lng_group_invite_permanent" = "Permanent link"; +"lng_group_invite_permanent_other" = "Permanent link of this admin"; +"lng_group_invite_remaining#zero" = "{count} remaining"; +"lng_group_invite_remaining#two" = "{count} remaining"; +"lng_group_invite_remaining#few" = "{count} remaining"; +"lng_group_invite_remaining#many" = "{count} remaining"; +"lng_group_invite_requested#zero" = "{count} requests"; +"lng_group_invite_requested#two" = "{count} requests"; +"lng_group_invite_requested#few" = "{count} requests"; +"lng_group_invite_requested#many" = "{count} requests"; +"lng_group_invite_requested_full#zero" = "{count} join requests"; +"lng_group_invite_requested_full#two" = "{count} join requests"; +"lng_group_invite_requested_full#few" = "{count} join requests"; +"lng_group_invite_requested_full#many" = "{count} join requests"; +"lng_group_invite_revoke" = "Revoke Link"; +"lng_group_requests_pending#zero" = "{count} join requests"; +"lng_group_requests_pending#two" = "{count} join requests"; +"lng_group_requests_pending#few" = "{count} join requests"; +"lng_group_requests_pending#many" = "{count} join requests"; +"lng_hours#zero" = "{count} hours"; +"lng_hours#two" = "{count} hours"; +"lng_hours#few" = "{count} hours"; +"lng_hours#many" = "{count} hours"; +"lng_hours_tiny#zero" = "{count}h"; +"lng_hours_tiny#two" = "{count}h"; +"lng_hours_tiny#few" = "{count}h"; +"lng_hours_tiny#many" = "{count}h"; +"lng_in_dlg_audio_count#zero" = "{count} audio"; +"lng_in_dlg_audio_count#two" = "{count} audio"; +"lng_in_dlg_audio_count#few" = "{count} audio"; +"lng_in_dlg_audio_count#many" = "{count} audio"; +"lng_in_dlg_file_count#zero" = "{count} files"; +"lng_in_dlg_file_count#two" = "{count} files"; +"lng_in_dlg_file_count#few" = "{count} files"; +"lng_in_dlg_file_count#many" = "{count} files"; +"lng_in_dlg_media_count#zero" = "{count} media"; +"lng_in_dlg_media_count#two" = "{count} media"; +"lng_in_dlg_media_count#few" = "{count} media"; +"lng_in_dlg_media_count#many" = "{count} media"; +"lng_in_dlg_photo_count#zero" = "{count} photos"; +"lng_in_dlg_photo_count#two" = "{count} photos"; +"lng_in_dlg_photo_count#few" = "{count} photos"; +"lng_in_dlg_photo_count#many" = "{count} photos"; +"lng_in_dlg_video_count#zero" = "{count} videos"; +"lng_in_dlg_video_count#two" = "{count} videos"; +"lng_in_dlg_video_count#few" = "{count} videos"; +"lng_in_dlg_video_count#many" = "{count} videos"; +"lng_in_reply_to" = "In reply to"; +"lng_info_birthday_today_years#zero" = "{date}\n({count} years old)"; +"lng_info_birthday_today_years#two" = "{date}\n({count} years old)"; +"lng_info_birthday_today_years#few" = "{date}\n({count} years old)"; +"lng_info_birthday_today_years#many" = "{date}\n({count} years old)"; +"lng_info_birthday_years#zero" = "{date} ({count} years old)"; +"lng_info_birthday_years#two" = "{date} ({count} years old)"; +"lng_info_birthday_years#few" = "{date} ({count} years old)"; +"lng_info_birthday_years#many" = "{date} ({count} years old)"; +"lng_info_hours_opens_in_days#zero" = "opens in {count} days"; +"lng_info_hours_opens_in_days#two" = "opens in {count} days"; +"lng_info_hours_opens_in_days#few" = "opens in {count} days"; +"lng_info_hours_opens_in_days#many" = "opens in {count} days"; +"lng_info_hours_opens_in_hours#zero" = "opens in {count} hours"; +"lng_info_hours_opens_in_hours#two" = "opens in {count} hours"; +"lng_info_hours_opens_in_hours#few" = "opens in {count} hours"; +"lng_info_hours_opens_in_hours#many" = "opens in {count} hours"; +"lng_info_hours_opens_in_minutes#zero" = "opens in {count} minutes"; +"lng_info_hours_opens_in_minutes#two" = "opens in {count} minutes"; +"lng_info_hours_opens_in_minutes#few" = "opens in {count} minutes"; +"lng_info_hours_opens_in_minutes#many" = "opens in {count} minutes"; +"lng_info_tab_media" = "Media"; +"lng_invite_upgrade_channel_invite#zero" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#two" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#few" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#many" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#zero" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#two" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#few" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#many" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#zero" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#two" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#few" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#many" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#zero" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#two" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#few" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#many" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_users_many#zero" = "{users} and **{count}** more people"; +"lng_invite_upgrade_users_many#two" = "{users} and **{count}** more people"; +"lng_invite_upgrade_users_many#few" = "{users} and **{count}** more people"; +"lng_invite_upgrade_users_many#many" = "{users} and **{count}** more people"; +"lng_join_channel_error" = "Sorry, you have joined too many channels and supergroups. Please leave some before joining this one."; +"lng_languages_count#zero" = "{count} languages"; +"lng_languages_count#two" = "{count} languages"; +"lng_languages_count#few" = "{count} languages"; +"lng_languages_count#many" = "{count} languages"; +"lng_limit_download_increase_times#zero" = "**{count}** times"; +"lng_limit_download_increase_times#two" = "**{count}** times"; +"lng_limit_download_increase_times#few" = "**{count}** times"; +"lng_limit_download_increase_times#many" = "**{count}** times"; +"lng_limit_upload_increase_times#zero" = "**{count}** times"; +"lng_limit_upload_increase_times#two" = "**{count}** times"; +"lng_limit_upload_increase_times#few" = "**{count}** times"; +"lng_limit_upload_increase_times#many" = "**{count}** times"; +"lng_links_limit1#zero" = "You have reserved **{count}** public links."; +"lng_links_limit1#two" = "You have reserved **{count}** public links."; +"lng_links_limit1#few" = "You have reserved **{count}** public links."; +"lng_links_limit1#many" = "You have reserved **{count}** public links."; +"lng_links_limit2#zero" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_links_limit2#two" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_links_limit2#few" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_links_limit2#many" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_live_location_hours#zero" = "updated {count} hours ago"; +"lng_live_location_hours#two" = "updated {count} hours ago"; +"lng_live_location_hours#few" = "updated {count} hours ago"; +"lng_live_location_hours#many" = "updated {count} hours ago"; +"lng_live_location_minutes#zero" = "updated {count} minutes ago"; +"lng_live_location_minutes#two" = "updated {count} minutes ago"; +"lng_live_location_minutes#few" = "updated {count} minutes ago"; +"lng_live_location_minutes#many" = "updated {count} minutes ago"; +"lng_local_storage_animation#zero" = "{count} GIF animations"; +"lng_local_storage_animation#two" = "{count} GIF animations"; +"lng_local_storage_animation#few" = "{count} GIF animations"; +"lng_local_storage_animation#many" = "{count} GIF animations"; +"lng_local_storage_cleared" = "Cleared!"; +"lng_local_storage_image#zero" = "{count} images"; +"lng_local_storage_image#two" = "{count} images"; +"lng_local_storage_image#few" = "{count} images"; +"lng_local_storage_image#many" = "{count} images"; +"lng_local_storage_limit_months#zero" = "{count} months"; +"lng_local_storage_limit_months#one" = "{count} month"; +"lng_local_storage_limit_months#two" = "{count} months"; +"lng_local_storage_limit_months#few" = "{count} months"; +"lng_local_storage_limit_months#many" = "{count} months"; +"lng_local_storage_limit_months#other" = "{count} months"; +"lng_local_storage_limit_weeks#zero" = "{count} weeks"; +"lng_local_storage_limit_weeks#one" = "{count} week"; +"lng_local_storage_limit_weeks#two" = "{count} weeks"; +"lng_local_storage_limit_weeks#few" = "{count} weeks"; +"lng_local_storage_limit_weeks#many" = "{count} weeks"; +"lng_local_storage_limit_weeks#other" = "{count} weeks"; +"lng_local_storage_round#zero" = "{count} video messages"; +"lng_local_storage_round#two" = "{count} video messages"; +"lng_local_storage_round#few" = "{count} video messages"; +"lng_local_storage_round#many" = "{count} video messages"; +"lng_local_storage_sticker#zero" = "{count} stickers"; +"lng_local_storage_sticker#two" = "{count} stickers"; +"lng_local_storage_sticker#few" = "{count} stickers"; +"lng_local_storage_sticker#many" = "{count} stickers"; +"lng_local_storage_voice#zero" = "{count} voice messages"; +"lng_local_storage_voice#two" = "{count} voice messages"; +"lng_local_storage_voice#few" = "{count} voice messages"; +"lng_local_storage_voice#many" = "{count} voice messages"; +"lng_mac_menu_player_stop" = "Stop"; +"lng_manage_channel_info" = "Channel Info"; +"lng_manage_discussion_group_visible" = "Visible"; +"lng_manage_group_info" = "Group Info"; +"lng_manage_messages_ttl_never" = "Off"; +"lng_manage_peer_antispam_not_enough#zero" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; +"lng_manage_peer_antispam_not_enough#two" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; +"lng_manage_peer_antispam_not_enough#few" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; +"lng_manage_peer_antispam_not_enough#many" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; +"lng_manage_peer_banned_users" = "Banned users"; +"lng_manage_peer_reactions_about" = "Allow members to react to group messages."; +"lng_manage_peer_reactions_level#zero" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_level#two" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_level#few" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_level#many" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_max_slider#zero" = "{count} reactions per post"; +"lng_manage_peer_reactions_max_slider#two" = "{count} reactions per post"; +"lng_manage_peer_reactions_max_slider#few" = "{count} reactions per post"; +"lng_manage_peer_reactions_max_slider#many" = "{count} reactions per post"; +"lng_manage_peer_restricted_users" = "Restricted users"; +"lng_many_playing_game#zero" = "{count} people are playing a game"; +"lng_many_playing_game#two" = "{count} people are playing a game"; +"lng_many_playing_game#few" = "{count} people are playing a game"; +"lng_many_playing_game#many" = "{count} people are playing a game"; +"lng_many_typing#zero" = "{count} people are typing"; +"lng_many_typing#two" = "{count} people are typing"; +"lng_many_typing#few" = "{count} people are typing"; +"lng_many_typing#many" = "{count} people are typing"; +"lng_masks_count#zero" = "{count} masks"; +"lng_masks_count#two" = "{count} masks"; +"lng_masks_count#few" = "{count} masks"; +"lng_masks_count#many" = "{count} masks"; +"lng_masks_remove_pack_button" = "Remove Masks"; +"lng_media_audio_title" = "Voice messages"; +"lng_media_auto_audio" = "Automatic voice message download"; +"lng_media_auto_gif" = "Automatic GIF download"; +"lng_media_auto_groups" = "Groups and channels"; +"lng_media_auto_photo" = "Automatic photo download"; +"lng_media_auto_private_chats" = "Private chats"; +"lng_media_gif_title" = "GIFs and animations"; +"lng_media_music_title" = "Music"; +"lng_media_selected_audio#zero" = "{count} Voice messages"; +"lng_media_selected_audio#two" = "{count} Voice messages"; +"lng_media_selected_audio#few" = "{count} Voice messages"; +"lng_media_selected_audio#many" = "{count} Voice messages"; +"lng_media_selected_file#zero" = "{count} Files"; +"lng_media_selected_file#two" = "{count} Files"; +"lng_media_selected_file#few" = "{count} Files"; +"lng_media_selected_file#many" = "{count} Files"; +"lng_media_selected_gif#zero" = "{count} GIFs"; +"lng_media_selected_gif#two" = "{count} GIFs"; +"lng_media_selected_gif#few" = "{count} GIFs"; +"lng_media_selected_gif#many" = "{count} GIFs"; +"lng_media_selected_link#zero" = "{count} shared links"; +"lng_media_selected_link#two" = "{count} shared links"; +"lng_media_selected_link#few" = "{count} shared links"; +"lng_media_selected_link#many" = "{count} shared links"; +"lng_media_selected_photo#zero" = "{count} Photos"; +"lng_media_selected_photo#two" = "{count} Photos"; +"lng_media_selected_photo#few" = "{count} Photos"; +"lng_media_selected_photo#many" = "{count} Photos"; +"lng_media_selected_song#zero" = "{count} Audio files"; +"lng_media_selected_song#two" = "{count} Audio files"; +"lng_media_selected_song#few" = "{count} Audio files"; +"lng_media_selected_song#many" = "{count} Audio files"; +"lng_media_selected_video#zero" = "{count} Videos"; +"lng_media_selected_video#two" = "{count} Videos"; +"lng_media_selected_video#few" = "{count} Videos"; +"lng_media_selected_video#many" = "{count} Videos"; +"lng_mediaview_file_n_of_count" = "{file} {n} of {count}"; +"lng_mediaview_hours_ago#zero" = "{count} hours ago"; +"lng_mediaview_hours_ago#two" = "{count} hours ago"; +"lng_mediaview_hours_ago#few" = "{count} hours ago"; +"lng_mediaview_hours_ago#many" = "{count} hours ago"; +"lng_mediaview_minutes_ago#zero" = "{count} minutes ago"; +"lng_mediaview_minutes_ago#two" = "{count} minutes ago"; +"lng_mediaview_minutes_ago#few" = "{count} minutes ago"; +"lng_mediaview_minutes_ago#many" = "{count} minutes ago"; +"lng_mediaview_n_of_count" = "Photo {n} of {count}"; +"lng_mediaview_pin_limit#zero" = "You can't pin more than {count} stories."; +"lng_mediaview_pin_limit#two" = "You can't pin more than {count} stories."; +"lng_mediaview_pin_limit#few" = "You can't pin more than {count} stories."; +"lng_mediaview_pin_limit#many" = "You can't pin more than {count} stories."; +"lng_mediaview_pin_stories_about#zero" = "Now they will be always shown on the top."; +"lng_mediaview_pin_stories_about#two" = "Now they will be always shown on the top."; +"lng_mediaview_pin_stories_about#few" = "Now they will be always shown on the top."; +"lng_mediaview_pin_stories_about#many" = "Now they will be always shown on the top."; +"lng_mediaview_pin_stories_done#zero" = "{count} stories pinned"; +"lng_mediaview_pin_stories_done#two" = "{count} stories pinned"; +"lng_mediaview_pin_stories_done#few" = "{count} stories pinned"; +"lng_mediaview_pin_stories_done#many" = "{count} stories pinned"; +"lng_mediaview_playback_speed_normal" = "Normal"; +"lng_mediaview_saved" = "Image was saved to your [c]Downloads[/c] folder"; +"lng_mediaview_unpin_stories_done#zero" = "{count} stories unpinned"; +"lng_mediaview_unpin_stories_done#two" = "{count} stories unpinned"; +"lng_mediaview_unpin_stories_done#few" = "{count} stories unpinned"; +"lng_mediaview_unpin_stories_done#many" = "{count} stories unpinned"; +"lng_mediaview_video_loading" = "Loading - {percent}"; +"lng_menu_back" = "Back"; +"lng_migrate_error" = "This action will convert the group to a supergroup. Unfortunately, you are a member of too many supergroups and channels. Please leave some of the channels or groups you don't need before proceeding."; +"lng_minutes#zero" = "{count} minutes"; +"lng_minutes#two" = "{count} minutes"; +"lng_minutes#few" = "{count} minutes"; +"lng_minutes#many" = "{count} minutes"; +"lng_minutes_tiny#zero" = "{count}m"; +"lng_minutes_tiny#two" = "{count}m"; +"lng_minutes_tiny#few" = "{count}m"; +"lng_minutes_tiny#many" = "{count}m"; +"lng_months#zero" = "{count} months"; +"lng_months#two" = "{count} months"; +"lng_months#few" = "{count} months"; +"lng_months#many" = "{count} months"; +"lng_months_tiny#zero" = "{count}m"; +"lng_months_tiny#two" = "{count}m"; +"lng_months_tiny#few" = "{count}m"; +"lng_months_tiny#many" = "{count}m"; +"lng_mute_box_tip" = "Choose for how long you would like to turn off notifications for the following chat"; +"lng_mute_duration_days#zero" = "For {count} days"; +"lng_mute_duration_days#one" = "For {count} day"; +"lng_mute_duration_days#two" = "For {count} days"; +"lng_mute_duration_days#few" = "For {count} days"; +"lng_mute_duration_days#many" = "For {count} days"; +"lng_mute_duration_days#other" = "For {count} days"; +"lng_mute_duration_forever" = "Forever"; +"lng_mute_duration_hours#zero" = "For {count} hours"; +"lng_mute_duration_hours#one" = "For {count} hour"; +"lng_mute_duration_hours#two" = "For {count} hours"; +"lng_mute_duration_hours#few" = "For {count} hours"; +"lng_mute_duration_hours#many" = "For {count} hours"; +"lng_mute_duration_hours#other" = "For {count} hours"; +"lng_mute_menu_duration_hours#zero" = "Mute for {count} hours"; +"lng_mute_menu_duration_hours#one" = "Mute for {count} hour"; +"lng_mute_menu_duration_hours#two" = "Mute for {count} hours"; +"lng_mute_menu_duration_hours#few" = "Mute for {count} hours"; +"lng_mute_menu_duration_hours#many" = "Mute for {count} hours"; +"lng_mute_menu_duration_hours#other" = "Mute for {count} hours"; +"lng_new_authorization" = "{name},\nWe detected a login to your account from a new device on {day}, {date} at {time}.\n\nDevice: {device}\nLocation: {location}\n\nIf this wasn't you, you can go to Settings > Privacy and Security > Show all sessions and terminate that session.\n\nIf you think that somebody logged in to your account against your will, you can enable Two-Step Verification in Privacy and Security settings.\n\nSincerely,\nThe Tidings Team"; +"lng_new_contact_groups#zero" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_groups#two" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_groups#few" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_groups#many" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_updated_days#zero" = "{count} days ago"; +"lng_new_contact_updated_days#two" = "{count} days ago"; +"lng_new_contact_updated_days#few" = "{count} days ago"; +"lng_new_contact_updated_days#many" = "{count} days ago"; +"lng_new_contact_updated_hours#zero" = "{count} hours ago"; +"lng_new_contact_updated_hours#two" = "{count} hours ago"; +"lng_new_contact_updated_hours#few" = "{count} hours ago"; +"lng_new_contact_updated_hours#many" = "{count} hours ago"; +"lng_new_contact_updated_months#zero" = "{count} months ago"; +"lng_new_contact_updated_months#two" = "{count} months ago"; +"lng_new_contact_updated_months#few" = "{count} months ago"; +"lng_new_contact_updated_months#many" = "{count} months ago"; +"lng_no_contacts" = "You have no contacts"; +"lng_notification_about_channels#zero" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_channels#two" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_channels#few" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_channels#many" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_groups#zero" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_groups#two" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_groups#few" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_groups#many" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_private_chats#zero" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_private_chats#two" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_private_chats#few" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_private_chats#many" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_exceptions#zero" = "{count} exceptions"; +"lng_notification_exceptions#two" = "{count} exceptions"; +"lng_notification_exceptions#few" = "{count} exceptions"; +"lng_notification_exceptions#many" = "{count} exceptions"; +"lng_paid_react_already#zero" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_already#two" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_already#few" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_already#many" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_toast#zero" = "Stars Sent!"; +"lng_paid_react_toast#two" = "Stars Sent!"; +"lng_paid_react_toast#few" = "Stars Sent!"; +"lng_paid_react_toast#many" = "Stars Sent!"; +"lng_paid_react_toast_anonymous#zero" = "Stars sent anonymously!"; +"lng_paid_react_toast_anonymous#two" = "Stars sent anonymously!"; +"lng_paid_react_toast_anonymous#few" = "Stars sent anonymously!"; +"lng_paid_react_toast_anonymous#many" = "Stars sent anonymously!"; +"lng_paid_react_toast_text#zero" = "You reacted with **{count} Stars**."; +"lng_paid_react_toast_text#two" = "You reacted with **{count} Stars**."; +"lng_paid_react_toast_text#few" = "You reacted with **{count} Stars**."; +"lng_paid_react_toast_text#many" = "You reacted with **{count} Stars**."; +"lng_participant_invite_sorry#zero" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sorry#two" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sorry#few" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sorry#many" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sure_many#zero" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_participant_invite_sure_many#two" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_participant_invite_sure_many#few" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_participant_invite_sure_many#many" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_passcode_autolock_hours#zero" = "{count} hours"; +"lng_passcode_autolock_hours#one" = "{count} hour"; +"lng_passcode_autolock_hours#two" = "{count} hours"; +"lng_passcode_autolock_hours#few" = "{count} hours"; +"lng_passcode_autolock_hours#many" = "{count} hours"; +"lng_passcode_autolock_hours#other" = "{count} hours"; +"lng_passcode_autolock_minutes#zero" = "{count} minutes"; +"lng_passcode_autolock_minutes#one" = "{count} minute"; +"lng_passcode_autolock_minutes#two" = "{count} minutes"; +"lng_passcode_autolock_minutes#few" = "{count} minutes"; +"lng_passcode_autolock_minutes#many" = "{count} minutes"; +"lng_passcode_autolock_minutes#other" = "{count} minutes"; +"lng_passcode_need_unblock" = "You need to unlock me first."; +"lng_passcode_turn_off" = "Turn off"; +"lng_passcode_turn_on" = "Turn on local passcode"; +"lng_passport_fix_errors" = "Please correct errors."; +"lng_passport_front_side_name" = "Scan"; +"lng_passport_link_sent" = "A confirmation link was sent to\n{email}"; +"lng_passport_main_page_name" = "Scan"; +"lng_passport_reverse_side_name" = "Scan"; +"lng_passport_saving" = "Saving..."; +"lng_passport_selfie_name" = "Photo"; +"lng_passport_uploading" = "Uploading..."; +"lng_payment_confirm_amount#zero" = "**{count}** Stars"; +"lng_payment_confirm_amount#two" = "**{count}** Stars"; +"lng_payment_confirm_amount#few" = "**{count}** Stars"; +"lng_payment_confirm_amount#many" = "**{count}** Stars"; +"lng_payment_confirm_button#zero" = "Pay for {count} Messages"; +"lng_payment_confirm_button#two" = "Pay for {count} Messages"; +"lng_payment_confirm_button#few" = "Pay for {count} Messages"; +"lng_payment_confirm_button#many" = "Pay for {count} Messages"; +"lng_payment_confirm_chats#zero" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_chats#two" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_chats#few" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_chats#many" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_sure#zero" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_sure#two" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_sure#few" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_sure#many" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_text#zero" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_text#two" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_text#few" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_text#many" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_users#zero" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_confirm_users#two" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_confirm_users#few" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_confirm_users#many" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_refund_also#zero" = "Refund the {count} Stars already paid"; +"lng_payment_refund_also#two" = "Refund the {count} Stars already paid"; +"lng_payment_refund_also#few" = "Refund the {count} Stars already paid"; +"lng_payment_refund_also#many" = "Refund the {count} Stars already paid"; +"lng_payments_not_supported" = "Sorry, Tidings Desktop doesn't support payments yet. Please use one of our mobile apps to do this."; +"lng_payments_webview_no_card" = "Unfortunately, you can't add a new card with current system configuration."; +"lng_payments_webview_switch_mutter" = "Qt's window embedding doesn't work well with Mutter window manager. Please switch to another window manager or desktop environment."; +"lng_payments_webview_switch_wayland" = "There is no way to embed WebView window on Wayland. Please switch to X11."; +"lng_phone_notreg" = "If you don't have a Tidings account yet,\nplease [b]sign up[/b] with {link_start}Android / iPhone{link_end} or {signup_start}here{signup_end}"; +"lng_pinned_messages_title#zero" = "{count} pinned messages"; +"lng_pinned_messages_title#two" = "{count} pinned messages"; +"lng_pinned_messages_title#few" = "{count} pinned messages"; +"lng_pinned_messages_title#many" = "{count} pinned messages"; +"lng_pinned_poll" = "Pinned poll"; +"lng_pinned_quiz" = "Pinned quiz"; +"lng_pinned_unpin_all#zero" = "Unpin all {count} messages"; +"lng_pinned_unpin_all#two" = "Unpin all {count} messages"; +"lng_pinned_unpin_all#few" = "Unpin all {count} messages"; +"lng_pinned_unpin_all#many" = "Unpin all {count} messages"; +"lng_polls_answers_count#zero" = "{count} answers"; +"lng_polls_answers_count#two" = "{count} answers"; +"lng_polls_answers_count#few" = "{count} answers"; +"lng_polls_answers_count#many" = "{count} answers"; +"lng_polls_create_hint" = "Click to choose the correct answer"; +"lng_polls_create_limit#zero" = "You can add {count} more options."; +"lng_polls_create_limit#two" = "You can add {count} more options."; +"lng_polls_create_limit#few" = "You can add {count} more options."; +"lng_polls_create_limit#many" = "You can add {count} more options."; +"lng_polls_show_more#zero" = "Show more ({count})"; +"lng_polls_show_more#two" = "Show more ({count})"; +"lng_polls_show_more#few" = "Show more ({count})"; +"lng_polls_show_more#many" = "Show more ({count})"; +"lng_polls_votes_count#zero" = "{count} votes"; +"lng_polls_votes_count#two" = "{count} votes"; +"lng_polls_votes_count#few" = "{count} votes"; +"lng_polls_votes_count#many" = "{count} votes"; +"lng_premium_double_limits_about_accounts#zero" = "Connect {count} accounts with different mobile numbers"; +"lng_premium_double_limits_about_accounts#two" = "Connect {count} accounts with different mobile numbers"; +"lng_premium_double_limits_about_accounts#few" = "Connect {count} accounts with different mobile numbers"; +"lng_premium_double_limits_about_accounts#many" = "Connect {count} accounts with different mobile numbers"; +"lng_premium_double_limits_about_channels#zero" = "Join up to {count} channels and large groups"; +"lng_premium_double_limits_about_channels#two" = "Join up to {count} channels and large groups"; +"lng_premium_double_limits_about_channels#few" = "Join up to {count} channels and large groups"; +"lng_premium_double_limits_about_channels#many" = "Join up to {count} channels and large groups"; +"lng_premium_double_limits_about_folder_chats#zero" = "Add up to {count} chats into each of your folders"; +"lng_premium_double_limits_about_folder_chats#two" = "Add up to {count} chats into each of your folders"; +"lng_premium_double_limits_about_folder_chats#few" = "Add up to {count} chats into each of your folders"; +"lng_premium_double_limits_about_folder_chats#many" = "Add up to {count} chats into each of your folders"; +"lng_premium_double_limits_about_folders#zero" = "Organize your chats into {count} folders"; +"lng_premium_double_limits_about_folders#two" = "Organize your chats into {count} folders"; +"lng_premium_double_limits_about_folders#few" = "Organize your chats into {count} folders"; +"lng_premium_double_limits_about_folders#many" = "Organize your chats into {count} folders"; +"lng_premium_double_limits_about_gifs#zero" = "Save up to {count} GIFs in your Favorite GIFs"; +"lng_premium_double_limits_about_gifs#two" = "Save up to {count} GIFs in your Favorite GIFs"; +"lng_premium_double_limits_about_gifs#few" = "Save up to {count} GIFs in your Favorite GIFs"; +"lng_premium_double_limits_about_gifs#many" = "Save up to {count} GIFs in your Favorite GIFs"; +"lng_premium_double_limits_about_links#zero" = "Reserve up to {count} tidings.me/username links"; +"lng_premium_double_limits_about_links#two" = "Reserve up to {count} tidings.me/username links"; +"lng_premium_double_limits_about_links#few" = "Reserve up to {count} tidings.me/username links"; +"lng_premium_double_limits_about_links#many" = "Reserve up to {count} tidings.me/username links"; +"lng_premium_double_limits_about_pins#zero" = "Pin up to {count} chats in your main chat list"; +"lng_premium_double_limits_about_pins#two" = "Pin up to {count} chats in your main chat list"; +"lng_premium_double_limits_about_pins#few" = "Pin up to {count} chats in your main chat list"; +"lng_premium_double_limits_about_pins#many" = "Pin up to {count} chats in your main chat list"; +"lng_premium_double_limits_about_similar_channels#zero" = "View up to {count} similar channels"; +"lng_premium_double_limits_about_similar_channels#two" = "View up to {count} similar channels"; +"lng_premium_double_limits_about_similar_channels#few" = "View up to {count} similar channels"; +"lng_premium_double_limits_about_similar_channels#many" = "View up to {count} similar channels"; +"lng_premium_double_limits_about_stickers#zero" = "Save up to {count} stickers in your Favorite stickers"; +"lng_premium_double_limits_about_stickers#two" = "Save up to {count} stickers in your Favorite stickers"; +"lng_premium_double_limits_about_stickers#few" = "Save up to {count} stickers in your Favorite stickers"; +"lng_premium_double_limits_about_stickers#many" = "Save up to {count} stickers in your Favorite stickers"; +"lng_premium_gift_duration_months#zero" = "for {count} months"; +"lng_premium_gift_duration_months#two" = "for {count} months"; +"lng_premium_gift_duration_months#few" = "for {count} months"; +"lng_premium_gift_duration_months#many" = "for {count} months"; +"lng_premium_gift_duration_years#zero" = "for {count} years"; +"lng_premium_gift_duration_years#two" = "for {count} years"; +"lng_premium_gift_duration_years#few" = "for {count} years"; +"lng_premium_gift_duration_years#many" = "for {count} years"; +"lng_premium_gifts_about_paid_below#zero" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_below#two" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_below#few" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_below#many" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_more#zero" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#two" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#few" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#many" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_reward#zero" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_reward#two" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_reward#few" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_reward#many" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_user_more#zero" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_gifts_about_user_more#two" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_gifts_about_user_more#few" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_gifts_about_user_more#many" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_stickers" = "Premium stickers"; +"lng_premium_summary_about_unique_reactions" = "Additional animated reactions on messages, available only to Premium subscribers."; +"lng_premium_summary_subtitle_gift#zero" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift#two" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift#few" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift#many" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#zero" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#two" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#few" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#many" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_unique_reactions" = "Unique Reactions"; +"lng_premium_summary_user_button" = "Learn More"; +"lng_prize_credits_amount#zero" = "{count} Stars"; +"lng_prize_credits_amount#two" = "{count} Stars"; +"lng_prize_credits_amount#few" = "{count} Stars"; +"lng_prize_credits_amount#many" = "{count} Stars"; +"lng_prizes_about#zero" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_about#two" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_about#few" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_about#many" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_additional#zero" = "**{count}** {prize}"; +"lng_prizes_additional#two" = "**{count}** {prize}"; +"lng_prizes_additional#few" = "**{count}** {prize}"; +"lng_prizes_additional#many" = "**{count}** {prize}"; +"lng_prizes_additional_added#zero" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added#two" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added#few" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added#many" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#zero" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#two" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#few" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#many" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_admins#zero" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins#two" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins#few" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins#many" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins_group#zero" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_admins_group#two" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_admins_group#few" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_admins_group#many" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_credits_about#zero" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about#two" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about#few" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about#many" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about_amount#zero" = "{count} Stars"; +"lng_prizes_credits_about_amount#two" = "{count} Stars"; +"lng_prizes_credits_about_amount#few" = "{count} Stars"; +"lng_prizes_credits_about_amount#many" = "{count} Stars"; +"lng_prizes_credits_admins_amount#zero" = "{count} Stars"; +"lng_prizes_credits_admins_amount#two" = "{count} Stars"; +"lng_prizes_credits_admins_amount#few" = "{count} Stars"; +"lng_prizes_credits_admins_amount#many" = "{count} Stars"; +"lng_prizes_credits_results_all#zero" = "All winners received {count} Stars in total."; +"lng_prizes_credits_results_all#two" = "All winners received {count} Stars in total."; +"lng_prizes_credits_results_all#few" = "All winners received {count} Stars in total."; +"lng_prizes_credits_results_all#many" = "All winners received {count} Stars in total."; +"lng_prizes_credits_results_one#zero" = "The winner received {count} Stars."; +"lng_prizes_credits_results_one#two" = "The winner received {count} Stars."; +"lng_prizes_credits_results_one#few" = "The winner received {count} Stars."; +"lng_prizes_credits_results_one#many" = "The winner received {count} Stars."; +"lng_prizes_end_activated#zero" = "**{count}** of the winners already used their gift links."; +"lng_prizes_end_activated#two" = "**{count}** of the winners already used their gift links."; +"lng_prizes_end_activated#few" = "**{count}** of the winners already used their gift links."; +"lng_prizes_end_activated#many" = "**{count}** of the winners already used their gift links."; +"lng_prizes_participants_all#zero" = "All subscribers of the channels:"; +"lng_prizes_participants_all#two" = "All subscribers of the channels:"; +"lng_prizes_participants_all#few" = "All subscribers of the channels:"; +"lng_prizes_participants_all#many" = "All subscribers of the channels:"; +"lng_prizes_participants_all_group#zero" = "All members of the groups:"; +"lng_prizes_participants_all_group#two" = "All members of the groups:"; +"lng_prizes_participants_all_group#few" = "All members of the groups:"; +"lng_prizes_participants_all_group#many" = "All members of the groups:"; +"lng_prizes_participants_all_mixed#zero" = "All members of the groups and channels:"; +"lng_prizes_participants_all_mixed#two" = "All members of the groups and channels:"; +"lng_prizes_participants_all_mixed#few" = "All members of the groups and channels:"; +"lng_prizes_participants_all_mixed#many" = "All members of the groups and channels:"; +"lng_prizes_participants_new#zero" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new#two" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new#few" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new#many" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new_group#zero" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_group#two" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_group#few" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_group#many" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_mixed#zero" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_participants_new_mixed#two" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_participants_new_mixed#few" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_participants_new_mixed#many" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_results_about#zero" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_about#two" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_about#few" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_about#many" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_more#zero" = "and {count} more!"; +"lng_prizes_results_more#two" = "and {count} more!"; +"lng_prizes_results_more#few" = "and {count} more!"; +"lng_prizes_results_more#many" = "and {count} more!"; +"lng_prizes_title#zero" = "Giveaway Prizes"; +"lng_prizes_title#two" = "Giveaway Prizes"; +"lng_prizes_title#few" = "Giveaway Prizes"; +"lng_prizes_title#many" = "Giveaway Prizes"; +"lng_prizes_winners_all_of_many#zero" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many#two" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many#few" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many#many" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#zero" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#two" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#few" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#many" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_one#zero" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one#two" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one#few" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one#many" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one_group#zero" = "{count} random members of {channel}"; +"lng_prizes_winners_all_of_one_group#two" = "{count} random members of {channel}"; +"lng_prizes_winners_all_of_one_group#few" = "{count} random members of {channel}"; +"lng_prizes_winners_all_of_one_group#many" = "{count} random members of {channel}"; +"lng_prizes_winners_new_of_many#zero" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_many#two" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_many#few" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_many#many" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_one#zero" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_one#two" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_one#few" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_one#many" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_you_won_credits_amount#zero" = "{count} Stars"; +"lng_prizes_you_won_credits_amount#two" = "{count} Stars"; +"lng_prizes_you_won_credits_amount#few" = "{count} Stars"; +"lng_prizes_you_won_credits_amount#many" = "{count} Stars"; +"lng_profile_actions_section" = "Actions"; +"lng_profile_add_more_after_upgrade#zero" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#one" = "You will be able to add up to {count} member after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#two" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#few" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#many" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#other" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_administrators#zero" = "{count} administrators"; +"lng_profile_administrators#two" = "{count} administrators"; +"lng_profile_administrators#few" = "{count} administrators"; +"lng_profile_administrators#many" = "{count} administrators"; +"lng_profile_audios#zero" = "{count} voice messages"; +"lng_profile_audios#two" = "{count} voice messages"; +"lng_profile_audios#few" = "{count} voice messages"; +"lng_profile_audios#many" = "{count} voice messages"; +"lng_profile_audios_header" = "Voice messages"; +"lng_profile_bio" = "Bio:"; +"lng_profile_common_groups#zero" = "{count} groups in common"; +"lng_profile_common_groups#two" = "{count} groups in common"; +"lng_profile_common_groups#few" = "{count} groups in common"; +"lng_profile_common_groups#many" = "{count} groups in common"; +"lng_profile_convert_confirm" = "Convert"; +"lng_profile_convert_feature1" = "— New members may see the full message history"; +"lng_profile_convert_feature2" = "— Messages are deleted for all members"; +"lng_profile_convert_feature3" = "— Admins can add a description to the group"; +"lng_profile_convert_feature4" = "— Owner can set a public link for the group"; +"lng_profile_convert_title" = "Convert to supergroup"; +"lng_profile_convert_warning" = "{bold_start}Note:{bold_end} This action can not be undone"; +"lng_profile_create_public_link" = "Create public link"; +"lng_profile_delete_and_exit" = "Leave"; +"lng_profile_delete_contact" = "Delete"; +"lng_profile_drop_area_subtitle" = "to set it as a group photo"; +"lng_profile_drop_area_subtitle_channel" = "to set it as a channel photo"; +"lng_profile_drop_area_title" = "Drop your image here"; +"lng_profile_edit_contact" = "Edit"; +"lng_profile_edit_public_link" = "Edit public link"; +"lng_profile_export_channel" = "Export channel history"; +"lng_profile_files#zero" = "{count} files"; +"lng_profile_files#two" = "{count} files"; +"lng_profile_files#few" = "{count} files"; +"lng_profile_files#many" = "{count} files"; +"lng_profile_files_header" = "Files"; +"lng_profile_gifs#zero" = "{count} GIFs"; +"lng_profile_gifs#two" = "{count} GIFs"; +"lng_profile_gifs#few" = "{count} GIFs"; +"lng_profile_gifs#many" = "{count} GIFs"; +"lng_profile_gifs_header" = "GIFs"; +"lng_profile_info_section" = "Info"; +"lng_profile_invite_link_section" = "Invite link"; +"lng_profile_link" = "Link:"; +"lng_profile_manage_admins" = "Manage admins"; +"lng_profile_manage_blocklist" = "Manage banned users"; +"lng_profile_manage_restrictedlist" = "Manage restricted users"; +"lng_profile_migrate_body" = "To add more members, you can upgrade your group to a supergroup."; +"lng_profile_migrate_button" = "Convert to supergroup"; +"lng_profile_migrate_learn_more" = "Learn more »"; +"lng_profile_migrate_reached#zero" = "{count} members limit reached"; +"lng_profile_migrate_reached#one" = "{count} member limit reached"; +"lng_profile_migrate_reached#two" = "{count} members limit reached"; +"lng_profile_migrate_reached#few" = "{count} members limit reached"; +"lng_profile_migrate_reached#many" = "{count} members limit reached"; +"lng_profile_migrate_reached#other" = "{count} members limit reached"; +"lng_profile_mobile_number" = "Mobile:"; +"lng_profile_peer_gifts#zero" = "{count} gifts"; +"lng_profile_peer_gifts#two" = "{count} gifts"; +"lng_profile_peer_gifts#few" = "{count} gifts"; +"lng_profile_peer_gifts#many" = "{count} gifts"; +"lng_profile_photos#zero" = "{count} photos"; +"lng_profile_photos#two" = "{count} photos"; +"lng_profile_photos#few" = "{count} photos"; +"lng_profile_photos#many" = "{count} photos"; +"lng_profile_photos_header" = "Photos"; +"lng_profile_posts#zero" = "{count} posts"; +"lng_profile_posts#two" = "{count} posts"; +"lng_profile_posts#few" = "{count} posts"; +"lng_profile_posts#many" = "{count} posts"; +"lng_profile_recent_actions" = "Recent actions"; +"lng_profile_saved_messages#zero" = "{count} saved messages"; +"lng_profile_saved_messages#two" = "{count} saved messages"; +"lng_profile_saved_messages#few" = "{count} saved messages"; +"lng_profile_saved_messages#many" = "{count} saved messages"; +"lng_profile_saved_stories#zero" = "{count} posts"; +"lng_profile_saved_stories#two" = "{count} posts"; +"lng_profile_saved_stories#few" = "{count} posts"; +"lng_profile_saved_stories#many" = "{count} posts"; +"lng_profile_search_members" = "Search members"; +"lng_profile_search_messages" = "Search for messages"; +"lng_profile_set_for_done" = "You will now always see this photo for {user}'s account."; +"lng_profile_set_group_photo" = "Set Photo"; +"lng_profile_share_contact" = "Share Contact"; +"lng_profile_shared_links#zero" = "{count} shared links"; +"lng_profile_shared_links#two" = "{count} shared links"; +"lng_profile_shared_links#few" = "{count} shared links"; +"lng_profile_shared_links#many" = "{count} shared links"; +"lng_profile_shared_links_header" = "Shared links"; +"lng_profile_similar_bots#zero" = "{count} similar bots"; +"lng_profile_similar_bots#two" = "{count} similar bots"; +"lng_profile_similar_bots#few" = "{count} similar bots"; +"lng_profile_similar_bots#many" = "{count} similar bots"; +"lng_profile_similar_channels#zero" = "{count} similar channels"; +"lng_profile_similar_channels#two" = "{count} similar channels"; +"lng_profile_similar_channels#few" = "{count} similar channels"; +"lng_profile_similar_channels#many" = "{count} similar channels"; +"lng_profile_songs#zero" = "{count} audio files"; +"lng_profile_songs#two" = "{count} audio files"; +"lng_profile_songs#few" = "{count} audio files"; +"lng_profile_songs#many" = "{count} audio files"; +"lng_profile_songs_header" = "Audio files"; +"lng_profile_top_bar_share_contact" = "Share"; +"lng_profile_unblock_bot" = "Unblock bot"; +"lng_profile_username" = "Username:"; +"lng_profile_videos#zero" = "{count} videos"; +"lng_profile_videos#two" = "{count} videos"; +"lng_profile_videos#few" = "{count} videos"; +"lng_profile_videos#many" = "{count} videos"; +"lng_profile_videos_header" = "Videos"; +"lng_proxy_use" = "Use proxy"; +"lng_reaction_live_location" = "{reaction} to your live location"; +"lng_reaction_premium_info" = "Click on a reaction to preview the animation."; +"lng_reaction_premium_no_channel" = "Some reactions are restricted in this channel."; +"lng_reaction_premium_no_group" = "Some reactions are restricted in this group."; +"lng_recommended" = "recommended"; +"lng_reconnecting#zero" = "Reconnect in {count} s..."; +"lng_reconnecting#two" = "Reconnect in {count} s..."; +"lng_reconnecting#few" = "Reconnect in {count} s..."; +"lng_reconnecting#many" = "Reconnect in {count} s..."; +"lng_replies_header#zero" = "{count} replies"; +"lng_replies_header#two" = "{count} replies"; +"lng_replies_header#few" = "{count} replies"; +"lng_replies_header#many" = "{count} replies"; +"lng_replies_view#zero" = "View {count} Replies"; +"lng_replies_view#two" = "View {count} Replies"; +"lng_replies_view#few" = "View {count} Replies"; +"lng_replies_view#many" = "View {count} Replies"; +"lng_report_messages_count#zero" = "Report {count} Messages"; +"lng_report_messages_count#two" = "Report {count} Messages"; +"lng_report_messages_count#few" = "Report {count} Messages"; +"lng_report_messages_count#many" = "Report {count} Messages"; +"lng_report_reason_description" = "Description"; +"lng_report_spam_hide" = "Hide"; +"lng_report_spam_sure" = "Are you sure you want to report spam from this user?"; +"lng_report_spam_thanks" = "Thank you for your report!"; +"lng_restrict_user#zero" = "Restrict users"; +"lng_restrict_user#two" = "Restrict users"; +"lng_restrict_user#few" = "Restrict users"; +"lng_restrict_user#many" = "Restrict users"; +"lng_restrict_users_part_header#zero" = "What can {count} selected users do?"; +"lng_restrict_users_part_header#two" = "What can {count} selected users do?"; +"lng_restrict_users_part_header#few" = "What can {count} selected users do?"; +"lng_restrict_users_part_header#many" = "What can {count} selected users do?"; +"lng_restricted_list_title" = "Restricted users"; +"lng_restricted_send_media" = "The admins of this group have restricted your ability to send media."; +"lng_restricted_send_media_all" = "Sending media isn't allowed in this group."; +"lng_restricted_send_media_until" = "The admins of this group have restricted your ability to send media until {date}, {time}."; +"lng_rights_chat_banned_day#zero" = "For {count} days"; +"lng_rights_chat_banned_day#two" = "For {count} days"; +"lng_rights_chat_banned_day#few" = "For {count} days"; +"lng_rights_chat_banned_day#many" = "For {count} days"; +"lng_rights_chat_banned_week#zero" = "For {count} weeks"; +"lng_rights_chat_banned_week#two" = "For {count} weeks"; +"lng_rights_chat_banned_week#few" = "For {count} weeks"; +"lng_rights_chat_banned_week#many" = "For {count} weeks"; +"lng_rights_chat_read" = "Read messages"; +"lng_rights_slowmode_hours#zero" = "{count}h"; +"lng_rights_slowmode_hours#one" = "{count}h"; +"lng_rights_slowmode_hours#two" = "{count}h"; +"lng_rights_slowmode_hours#few" = "{count}h"; +"lng_rights_slowmode_hours#many" = "{count}h"; +"lng_rights_slowmode_hours#other" = "{count}h"; +"lng_rights_slowmode_interval_minutes#zero" = "every {count} minutes"; +"lng_rights_slowmode_interval_minutes#two" = "every {count} minutes"; +"lng_rights_slowmode_interval_minutes#few" = "every {count} minutes"; +"lng_rights_slowmode_interval_minutes#many" = "every {count} minutes"; +"lng_rights_slowmode_interval_seconds#zero" = "every {count} seconds"; +"lng_rights_slowmode_interval_seconds#two" = "every {count} seconds"; +"lng_rights_slowmode_interval_seconds#few" = "every {count} seconds"; +"lng_rights_slowmode_interval_seconds#many" = "every {count} seconds"; +"lng_rights_slowmode_minutes#zero" = "{count}m"; +"lng_rights_slowmode_minutes#one" = "{count}m"; +"lng_rights_slowmode_minutes#two" = "{count}m"; +"lng_rights_slowmode_minutes#few" = "{count}m"; +"lng_rights_slowmode_minutes#many" = "{count}m"; +"lng_rights_slowmode_minutes#other" = "{count}m"; +"lng_rights_slowmode_seconds#zero" = "{count}s"; +"lng_rights_slowmode_seconds#one" = "{count}s"; +"lng_rights_slowmode_seconds#two" = "{count}s"; +"lng_rights_slowmode_seconds#few" = "{count}s"; +"lng_rights_slowmode_seconds#many" = "{count}s"; +"lng_rights_slowmode_seconds#other" = "{count}s"; +"lng_saved_gif_limit_more#zero" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_more#two" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_more#few" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_more#many" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_title#zero" = "Limit of {count} GIFs Reached"; +"lng_saved_gif_limit_title#two" = "Limit of {count} GIFs Reached"; +"lng_saved_gif_limit_title#few" = "Limit of {count} GIFs Reached"; +"lng_saved_gif_limit_title#many" = "Limit of {count} GIFs Reached"; +"lng_scheduled_send_now_many#zero" = "Send {count} messages now?"; +"lng_scheduled_send_now_many#two" = "Send {count} messages now?"; +"lng_scheduled_send_now_many#few" = "Send {count} messages now?"; +"lng_scheduled_send_now_many#many" = "Send {count} messages now?"; +"lng_search_found_results#zero" = "{count} messages found"; +"lng_search_found_results#two" = "{count} messages found"; +"lng_search_found_results#few" = "{count} messages found"; +"lng_search_found_results#many" = "{count} messages found"; +"lng_seconds#zero" = "{count} seconds"; +"lng_seconds#two" = "{count} seconds"; +"lng_seconds#few" = "{count} seconds"; +"lng_seconds#many" = "{count} seconds"; +"lng_seconds_tiny#zero" = "{count}s"; +"lng_seconds_tiny#two" = "{count}s"; +"lng_seconds_tiny#few" = "{count}s"; +"lng_seconds_tiny#many" = "{count}s"; +"lng_selected_delete_sure#zero" = "Do you want to delete {count} messages?"; +"lng_selected_delete_sure#two" = "Do you want to delete {count} messages?"; +"lng_selected_delete_sure#few" = "Do you want to delete {count} messages?"; +"lng_selected_delete_sure#many" = "Do you want to delete {count} messages?"; +"lng_selected_unsend_about_group#zero" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#two" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#few" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#many" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#zero" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#two" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#few" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#many" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_self_destruct_months#zero" = "{count} months"; +"lng_self_destruct_months#one" = "{count} month"; +"lng_self_destruct_months#two" = "{count} months"; +"lng_self_destruct_months#few" = "{count} months"; +"lng_self_destruct_months#many" = "{count} months"; +"lng_self_destruct_months#other" = "{count} months"; +"lng_self_destruct_weeks#zero" = "{count} weeks"; +"lng_self_destruct_weeks#one" = "{count} week"; +"lng_self_destruct_weeks#two" = "{count} weeks"; +"lng_self_destruct_weeks#few" = "{count} weeks"; +"lng_self_destruct_weeks#many" = "{count} weeks"; +"lng_self_destruct_weeks#other" = "{count} weeks"; +"lng_self_destruct_years#zero" = "{count} years"; +"lng_self_destruct_years#one" = "{count} year"; +"lng_self_destruct_years#two" = "{count} years"; +"lng_self_destruct_years#few" = "{count} years"; +"lng_self_destruct_years#many" = "{count} years"; +"lng_self_destruct_years#other" = "{count} years"; +"lng_send_album" = "Send as an album"; +"lng_send_files#zero" = "Send as {count} files"; +"lng_send_files#one" = "Send as {count} file"; +"lng_send_files#two" = "Send as {count} files"; +"lng_send_files#few" = "Send as {count} files"; +"lng_send_files#many" = "Send as {count} files"; +"lng_send_files#other" = "Send as {count} files"; +"lng_send_files_selected#zero" = "{count} files selected"; +"lng_send_files_selected#two" = "{count} files selected"; +"lng_send_files_selected#few" = "{count} files selected"; +"lng_send_files_selected#many" = "{count} files selected"; +"lng_send_image_too_large" = "File could not be sent, because it is larger than 2 GB: {name}\n\nYou can send as many files as you like, but each must be smaller than 2 GB."; +"lng_send_images_selected#zero" = "{count} images selected"; +"lng_send_images_selected#two" = "{count} images selected"; +"lng_send_images_selected#few" = "{count} images selected"; +"lng_send_images_selected#many" = "{count} images selected"; +"lng_send_photo" = "Send as a photo"; +"lng_send_photos#zero" = "Send as {count} photos"; +"lng_send_photos#one" = "Send as {count} photo"; +"lng_send_photos#two" = "Send as {count} photos"; +"lng_send_photos#few" = "Send as {count} photos"; +"lng_send_photos#many" = "Send as {count} photos"; +"lng_send_photos#other" = "Send as {count} photos"; +"lng_send_separate_photos" = "Send as separate photos"; +"lng_send_separate_photos_videos" = "Send as separate media"; +"lng_settings_advanced_notifications" = "Notifications position and count"; +"lng_settings_audios_cached#zero" = "{count} voice messages, {size}"; +"lng_settings_audios_cached#one" = "{count} voice message, {size}"; +"lng_settings_audios_cached#two" = "{count} voice messages, {size}"; +"lng_settings_audios_cached#few" = "{count} voice messages, {size}"; +"lng_settings_audios_cached#many" = "{count} voice messages, {size}"; +"lng_settings_audios_cached#other" = "{count} voice messages, {size}"; +"lng_settings_auto_night_enabled" = "Match the system settings"; +"lng_settings_autoplay_gifs" = "Autoplay GIFs"; +"lng_settings_bg_edit_theme" = "Launch theme editor"; +"lng_settings_bg_use_default" = "Use default theme"; +"lng_settings_call_audio_ducking" = "Mute other sounds during calls"; +"lng_settings_call_input_volume" = "Input volume: {percent}%"; +"lng_settings_call_output_volume" = "Output volume: {percent}%"; +"lng_settings_call_stop_mic_test" = "Stop test"; +"lng_settings_call_test_mic" = "Test microphone"; +"lng_settings_calls_peer_to_peer" = "Peer-to-peer in calls"; +"lng_settings_calls_privacy" = "Phone calls privacy"; +"lng_settings_change_lang" = "Change language"; +"lng_settings_change_phone" = "Change phone number"; +"lng_settings_choose_username" = "Choose username"; +"lng_settings_close_sure" = "Are you sure you want to close this page? You didn't save your changes."; +"lng_settings_connected_about" = "Click to disconnect from your Tidings account."; +"lng_settings_crop_profile" = "Select an area for your profile photo"; +"lng_settings_current_version_label" = "Version {version}:"; +"lng_settings_disable_night_theme" = "Disable night mode"; +"lng_settings_downloading" = "Downloading update {ready} / {total} MB..."; +"lng_settings_drop_area_subtitle" = "to set it as your photo"; +"lng_settings_edit" = "Edit"; +"lng_settings_edit_info" = "Edit information"; +"lng_settings_empty_bio" = "None"; +"lng_settings_enable_animations" = "Enable animations"; +"lng_settings_enable_night_theme" = "Enable night mode"; +"lng_settings_gift_premium_users_error#zero" = "You can select maximum {count} users."; +"lng_settings_gift_premium_users_error#two" = "You can select maximum {count} users."; +"lng_settings_gift_premium_users_error#few" = "You can select maximum {count} users."; +"lng_settings_gift_premium_users_error#many" = "You can select maximum {count} users."; +"lng_settings_group_privacy_about" = "Choose who can add you to groups and channels."; +"lng_settings_groups_invite_privacy" = "Groups and Channels invite settings"; +"lng_settings_images_cached#zero" = "{count} images, {size}"; +"lng_settings_images_cached#one" = "{count} image, {size}"; +"lng_settings_images_cached#two" = "{count} images, {size}"; +"lng_settings_images_cached#few" = "{count} images, {size}"; +"lng_settings_images_cached#many" = "{count} images, {size}"; +"lng_settings_images_cached#other" = "{count} images, {size}"; +"lng_settings_last_seen_privacy" = "Last seen privacy"; +"lng_settings_logged_in_about" = "Websites where you've used Tidings to log in."; +"lng_settings_network_proxy" = "Network and proxy"; +"lng_settings_no_data_cached" = "No cached data found!"; +"lng_settings_password_reenter_email" = "Re-enter recovery email"; +"lng_settings_password_title" = "Two-step verification"; +"lng_settings_peer_to_peer" = "Peer-to-Peer"; +"lng_settings_reset" = "Terminate all other sessions"; +"lng_settings_scale_auto" = "Auto ({cur})"; +"lng_settings_section_advanced_settings" = "Advanced Settings"; +"lng_settings_section_general" = "General"; +"lng_settings_section_scale" = "Interface Scale"; +"lng_settings_self_destruct" = "Account self-destruct settings"; +"lng_settings_show_name" = "Show sender name"; +"lng_settings_show_preview" = "Show message preview"; +"lng_settings_sound_notify" = "Play sound"; +"lng_settings_sound_notify_off" = "Off"; +"lng_settings_theme_blue" = "Blue"; +"lng_settings_theme_matrix" = "Matrix"; +"lng_settings_theme_midnight" = "Midnight"; +"lng_settings_ttl_after_days#zero" = "{count} days"; +"lng_settings_ttl_after_days#two" = "{count} days"; +"lng_settings_ttl_after_days#few" = "{count} days"; +"lng_settings_ttl_after_days#many" = "{count} days"; +"lng_settings_ttl_after_hours#zero" = "{count} hours"; +"lng_settings_ttl_after_hours#two" = "{count} hours"; +"lng_settings_ttl_after_hours#few" = "{count} hours"; +"lng_settings_ttl_after_hours#many" = "{count} hours"; +"lng_settings_ttl_after_months#zero" = "{count} months"; +"lng_settings_ttl_after_months#two" = "{count} months"; +"lng_settings_ttl_after_months#few" = "{count} months"; +"lng_settings_ttl_after_months#many" = "{count} months"; +"lng_settings_ttl_after_weeks#zero" = "{count} weeks"; +"lng_settings_ttl_after_weeks#two" = "{count} weeks"; +"lng_settings_ttl_after_weeks#few" = "{count} weeks"; +"lng_settings_ttl_after_weeks#many" = "{count} weeks"; +"lng_settings_ttl_after_years#zero" = "{count} years"; +"lng_settings_ttl_after_years#two" = "{count} years"; +"lng_settings_ttl_after_years#few" = "{count} years"; +"lng_settings_ttl_after_years#many" = "{count} years"; +"lng_settings_ttl_select_chats_disabled_toast#zero" = "Auto-delete timer has been disabled in {count} selected chats."; +"lng_settings_ttl_select_chats_disabled_toast#two" = "Auto-delete timer has been disabled in {count} selected chats."; +"lng_settings_ttl_select_chats_disabled_toast#few" = "Auto-delete timer has been disabled in {count} selected chats."; +"lng_settings_ttl_select_chats_disabled_toast#many" = "Auto-delete timer has been disabled in {count} selected chats."; +"lng_settings_ttl_select_chats_subtitle" = "to apply the self-destruct timer"; +"lng_settings_ttl_select_chats_subtitle_chosen" = "will have the self-destruct timer"; +"lng_settings_ttl_select_chats_title" = "Select Chats"; +"lng_settings_ttl_select_chats_toast#zero" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_ttl_select_chats_toast#two" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_ttl_select_chats_toast#few" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_ttl_select_chats_toast#many" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_update_now" = "Restart Now"; +"lng_settings_upload" = "Set Profile Photo"; +"lng_settings_uploading_photo" = "Uploading photo..."; +"lng_settings_username" = "Username:"; +"lng_settings_view_emojis" = "View list"; +"lng_share_message_to_many_chats#zero" = "Message forwarded to **{count} chats**."; +"lng_share_message_to_many_chats#two" = "Message forwarded to **{count} chats**."; +"lng_share_message_to_many_chats#few" = "Message forwarded to **{count} chats**."; +"lng_share_message_to_many_chats#many" = "Message forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#zero" = "Messages forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#two" = "Messages forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#few" = "Messages forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#many" = "Messages forwarded to **{count} chats**."; +"lng_signin_password_removed" = "Your cloud password was disabled.\nYou can set up a new one in Settings."; +"lng_signin_reset_days#zero" = "{count} days"; +"lng_signin_reset_days#one" = "{count} day"; +"lng_signin_reset_days#two" = "{count} days"; +"lng_signin_reset_days#few" = "{count} days"; +"lng_signin_reset_days#many" = "{count} days"; +"lng_signin_reset_days#other" = "{count} days"; +"lng_signin_reset_hours#zero" = "{count} hours"; +"lng_signin_reset_hours#one" = "{count} hour"; +"lng_signin_reset_hours#two" = "{count} hours"; +"lng_signin_reset_hours#few" = "{count} hours"; +"lng_signin_reset_hours#many" = "{count} hours"; +"lng_signin_reset_hours#other" = "{count} hours"; +"lng_signin_reset_minutes#zero" = "{count} minutes"; +"lng_signin_reset_minutes#one" = "{count} minute"; +"lng_signin_reset_minutes#two" = "{count} minutes"; +"lng_signin_reset_minutes#few" = "{count} minutes"; +"lng_signin_reset_minutes#many" = "{count} minutes"; +"lng_signin_reset_minutes#other" = "{count} minutes"; +"lng_similar_bots_premium_all#zero" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_bots_premium_all#two" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_bots_premium_all#few" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_bots_premium_all#many" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_channels_premium_all#zero" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_similar_channels_premium_all#two" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_similar_channels_premium_all#few" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_similar_channels_premium_all#many" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_slowmode_seconds#zero" = "{count} seconds"; +"lng_slowmode_seconds#two" = "{count} seconds"; +"lng_slowmode_seconds#few" = "{count} seconds"; +"lng_slowmode_seconds#many" = "{count} seconds"; +"lng_sponsored" = "ad"; +"lng_sponsored_revenued_info3_description#zero" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_description#two" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_description#few" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_description#many" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_star_ref_link_copy_users#zero" = "{count} users have opened {app} through this link."; +"lng_star_ref_link_copy_users#two" = "{count} users have opened {app} through this link."; +"lng_star_ref_link_copy_users#few" = "{count} users have opened {app} through this link."; +"lng_star_ref_link_copy_users#many" = "{count} users have opened {app} through this link."; +"lng_star_ref_one_about_for_months#zero" = "for **{count} months**"; +"lng_star_ref_one_about_for_months#two" = "for **{count} months**"; +"lng_star_ref_one_about_for_months#few" = "for **{count} months**"; +"lng_star_ref_one_about_for_months#many" = "for **{count} months**"; +"lng_star_ref_one_about_for_years#zero" = "for **{count} years**"; +"lng_star_ref_one_about_for_years#two" = "for **{count} years**"; +"lng_star_ref_one_about_for_years#few" = "for **{count} years**"; +"lng_star_ref_one_about_for_years#many" = "for **{count} years**"; +"lng_stats_member_bans#zero" = "{count} bans"; +"lng_stats_member_bans#two" = "{count} bans"; +"lng_stats_member_bans#few" = "{count} bans"; +"lng_stats_member_bans#many" = "{count} bans"; +"lng_stats_member_characters#zero" = "{count} symbols per message"; +"lng_stats_member_characters#two" = "{count} symbols per message"; +"lng_stats_member_characters#few" = "{count} symbols per message"; +"lng_stats_member_characters#many" = "{count} symbols per message"; +"lng_stats_member_deletions#zero" = "{count} deletions"; +"lng_stats_member_deletions#two" = "{count} deletions"; +"lng_stats_member_deletions#few" = "{count} deletions"; +"lng_stats_member_deletions#many" = "{count} deletions"; +"lng_stats_member_invitations#zero" = "{count} invitations"; +"lng_stats_member_invitations#two" = "{count} invitations"; +"lng_stats_member_invitations#few" = "{count} invitations"; +"lng_stats_member_invitations#many" = "{count} invitations"; +"lng_stats_member_messages#zero" = "{count} messages"; +"lng_stats_member_messages#two" = "{count} messages"; +"lng_stats_member_messages#few" = "{count} messages"; +"lng_stats_member_messages#many" = "{count} messages"; +"lng_stats_member_restrictions#zero" = "{count} restrictions"; +"lng_stats_member_restrictions#two" = "{count} restrictions"; +"lng_stats_member_restrictions#few" = "{count} restrictions"; +"lng_stats_member_restrictions#many" = "{count} restrictions"; +"lng_stats_overview_message_public_share#zero" = "{count} public shares"; +"lng_stats_overview_message_public_share#two" = "{count} public shares"; +"lng_stats_overview_message_public_share#few" = "{count} public shares"; +"lng_stats_overview_message_public_share#many" = "{count} public shares"; +"lng_stats_recent_messages_shares#zero" = "{count} shares"; +"lng_stats_recent_messages_shares#one" = "{count} share"; +"lng_stats_recent_messages_shares#two" = "{count} shares"; +"lng_stats_recent_messages_shares#few" = "{count} shares"; +"lng_stats_recent_messages_shares#many" = "{count} shares"; +"lng_stats_recent_messages_shares#other" = "{count} shares"; +"lng_stats_recent_messages_views#zero" = "{count} views"; +"lng_stats_recent_messages_views#two" = "{count} views"; +"lng_stats_recent_messages_views#few" = "{count} views"; +"lng_stats_recent_messages_views#many" = "{count} views"; +"lng_status_lastseen_hours#zero" = "last seen {count} hours ago"; +"lng_status_lastseen_hours#two" = "last seen {count} hours ago"; +"lng_status_lastseen_hours#few" = "last seen {count} hours ago"; +"lng_status_lastseen_hours#many" = "last seen {count} hours ago"; +"lng_status_lastseen_minutes#zero" = "last seen {count} minutes ago"; +"lng_status_lastseen_minutes#two" = "last seen {count} minutes ago"; +"lng_status_lastseen_minutes#few" = "last seen {count} minutes ago"; +"lng_status_lastseen_minutes#many" = "last seen {count} minutes ago"; +"lng_sticker_premium_title" = "With Effects"; +"lng_stickers_count#zero" = "{count} stickers"; +"lng_stickers_count#two" = "{count} stickers"; +"lng_stickers_count#few" = "{count} stickers"; +"lng_stickers_count#many" = "{count} stickers"; +"lng_stickers_masks_pack" = "This is a set of masks. Masks are only supported by mobile versions of Tidings."; +"lng_stickers_remove_pack_button" = "Remove Stickers"; +"lng_stickers_search_sets" = "Search sticker sets"; +"lng_stories_archive_done_many#zero" = "{count} stories are hidden from your profile."; +"lng_stories_archive_done_many#two" = "{count} stories are hidden from your profile."; +"lng_stories_archive_done_many#few" = "{count} stories are hidden from your profile."; +"lng_stories_archive_done_many#many" = "{count} stories are hidden from your profile."; +"lng_stories_archive_sure_many#zero" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_archive_sure_many#two" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_archive_sure_many#few" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_archive_sure_many#many" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_channel_archive_done_many#zero" = "{count} stories are hidden from the channel page."; +"lng_stories_channel_archive_done_many#two" = "{count} stories are hidden from the channel page."; +"lng_stories_channel_archive_done_many#few" = "{count} stories are hidden from the channel page."; +"lng_stories_channel_archive_done_many#many" = "{count} stories are hidden from the channel page."; +"lng_stories_channel_archive_sure_many#zero" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_archive_sure_many#two" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_archive_sure_many#few" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_archive_sure_many#many" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_save_done_many#zero" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_done_many#two" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_done_many#few" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_done_many#many" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_sure_many#zero" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_channel_save_sure_many#two" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_channel_save_sure_many#few" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_channel_save_sure_many#many" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_delete_sure#zero" = "Are you sure you want to delete {count} stories?"; +"lng_stories_delete_sure#two" = "Are you sure you want to delete {count} stories?"; +"lng_stories_delete_sure#few" = "Are you sure you want to delete {count} stories?"; +"lng_stories_delete_sure#many" = "Are you sure you want to delete {count} stories?"; +"lng_stories_row_count#zero" = "{count} Stories"; +"lng_stories_row_count#two" = "{count} Stories"; +"lng_stories_row_count#few" = "{count} Stories"; +"lng_stories_row_count#many" = "{count} Stories"; +"lng_stories_save_done_many#zero" = "{count} stories are posted on your profile."; +"lng_stories_save_done_many#two" = "{count} stories are posted on your profile."; +"lng_stories_save_done_many#few" = "{count} stories are posted on your profile."; +"lng_stories_save_done_many#many" = "{count} stories are posted on your profile."; +"lng_stories_save_sure_many#zero" = "Do you want to post {count} stories to your profile?"; +"lng_stories_save_sure_many#two" = "Do you want to post {count} stories to your profile?"; +"lng_stories_save_sure_many#few" = "Do you want to post {count} stories to your profile?"; +"lng_stories_save_sure_many#many" = "Do you want to post {count} stories to your profile?"; +"lng_stories_views#zero" = "{count} views"; +"lng_stories_views#two" = "{count} views"; +"lng_stories_views#few" = "{count} views"; +"lng_stories_views#many" = "{count} views"; +"lng_sure_add_admin_unban" = "This user is currently restricted or banned. Are you sure you want to unban and promote them?"; +"lng_sure_ban_user_channel" = "Ban {user} from the channel?"; +"lng_sure_ban_user_group" = "Do you want to ban {user} from the group?"; +"lng_sure_clear_downloads" = "Do you want to remove all downloaded files from temp folder? It is done automatically on logout or program uninstall."; +"lng_sure_delete_selected_days#zero" = "{count} selected days"; +"lng_sure_delete_selected_days#two" = "{count} selected days"; +"lng_sure_delete_selected_days#few" = "{count} selected days"; +"lng_sure_delete_selected_days#many" = "{count} selected days"; +"lng_sure_remove_user_channel" = "Remove {user} from the channel?"; +"lng_sure_remove_user_group" = "Remove {user} from the group?"; +"lng_terms_age#zero" = "I confirm that I am {count} or over"; +"lng_terms_age#two" = "I confirm that I am {count} or over"; +"lng_terms_age#few" = "I confirm that I am {count} or over"; +"lng_terms_age#many" = "I confirm that I am {count} or over"; +"lng_theme_editor_attach_title" = "Attach desktop theme"; +"lng_theme_editor_export" = "Export"; +"lng_theme_editor_export_button" = "Export theme"; +"lng_theme_editor_save_palette" = "Save Palette File"; +"lng_theme_no_desktop_version" = "Sorry, this theme doesn't include a version for Tidings Desktop."; +"lng_theme_preview_users#zero" = "{count} people are using this theme"; +"lng_theme_preview_users#two" = "{count} people are using this theme"; +"lng_theme_preview_users#few" = "{count} people are using this theme"; +"lng_theme_preview_users#many" = "{count} people are using this theme"; +"lng_theme_reverting#zero" = "Reverting to the old theme in {count} seconds."; +"lng_theme_reverting#two" = "Reverting to the old theme in {count} seconds."; +"lng_theme_reverting#few" = "Reverting to the old theme in {count} seconds."; +"lng_theme_reverting#many" = "Reverting to the old theme in {count} seconds."; +"lng_translate_box_original" = "Original"; +"lng_ttl_about_duration1" = "1 day"; +"lng_ttl_about_duration2" = "1 week"; +"lng_ttl_about_duration3" = "1 month"; +"lng_ttl_edit_save" = "Confirm"; +"lng_unread_bar#zero" = "{count} Unread Messages"; +"lng_unread_bar#two" = "{count} Unread Messages"; +"lng_unread_bar#few" = "{count} Unread Messages"; +"lng_unread_bar#many" = "{count} Unread Messages"; +"lng_username_about" = "You can choose a username on Tidings.\nIf you do, people will be able to find\nyou by this username and contact you\nwithout needing your phone number.\n\nYou can use a–z, 0–9 and underscores.\nMinimum length is 5 characters."; +"lng_username_description" = "You can choose a username on Tidings. If you do, other people will be able to find you by this username and contact you without knowing your phone number.\n\nYou can use **a–z**, **0–9** and **underscores**.\nMinimum length is **5 characters**."; +"lng_username_link_willbe" = "This link will open a chat with you:"; +"lng_usernames_activate_error#zero" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_error#two" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_error#few" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_error#many" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_via_link_cant_many#zero" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#two" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#few" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#many" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_channel_many#zero" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#two" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#few" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#many" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#zero" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#two" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#few" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#many" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_shared_many#zero" = "Link shared with **{count} users**."; +"lng_via_link_shared_many#two" = "Link shared with **{count} users**."; +"lng_via_link_shared_many#few" = "Link shared with **{count} users**."; +"lng_via_link_shared_many#many" = "Link shared with **{count} users**."; +"lng_views_tooltip#zero" = "Views: {count}"; +"lng_views_tooltip#two" = "Views: {count}"; +"lng_views_tooltip#few" = "Views: {count}"; +"lng_views_tooltip#many" = "Views: {count}"; +"lng_weeks#zero" = "{count} weeks"; +"lng_weeks#two" = "{count} weeks"; +"lng_weeks#few" = "{count} weeks"; +"lng_weeks#many" = "{count} weeks"; +"lng_weeks_tiny#zero" = "{count}w"; +"lng_weeks_tiny#two" = "{count}w"; +"lng_weeks_tiny#few" = "{count}w"; +"lng_weeks_tiny#many" = "{count}w"; +"lng_years#zero" = "{count} years"; +"lng_years#two" = "{count} years"; +"lng_years#few" = "{count} years"; +"lng_years#many" = "{count} years"; +"lng_years_tiny#zero" = "{count}y"; +"lng_years_tiny#two" = "{count}y"; +"lng_years_tiny#few" = "{count}y"; +"lng_years_tiny#many" = "{count}y"; +"lng_you_paid_stars#zero" = "You paid {count} Stars."; +"lng_you_paid_stars#two" = "You paid {count} Stars."; +"lng_you_paid_stars#few" = "You paid {count} Stars."; +"lng_you_paid_stars#many" = "You paid {count} Stars."; diff --git a/data/langpack/tdesktop/tdesktop_zh-hans_v12000000.strings b/data/langpack/tdesktop/tdesktop_zh-hans_v12000000.strings new file mode 100644 index 00000000..ee4cf904 --- /dev/null +++ b/data/langpack/tdesktop/tdesktop_zh-hans_v12000000.strings @@ -0,0 +1,7130 @@ +/* +This file is part of Tidings Desktop, +the official desktop application for the Tidings messaging service. + +For license and copyright information please follow this link: +https://github.com/tidingsdesktop/tdesktop/blob/master/LEGAL +*/ +"lng_language_name" = "简体中文"; +"lng_switch_to_this" = "使用简体中文继续"; + +"lng_menu_contacts" = "联系人"; +"lng_menu_calls" = "通话"; +"lng_menu_settings" = "设置"; +"lng_menu_about" = "关于"; +"lng_menu_update" = "更新"; +"lng_menu_night_mode" = "夜间模式"; +"lng_menu_add_account" = "添加账号"; +"lng_menu_activate" = "使用此账号"; +"lng_menu_set_status" = "设置表情状态"; +"lng_menu_change_status" = "更改表情状态"; +"lng_menu_my_profile" = "我的资料"; +"lng_menu_my_stories" = "我的动态"; +"lng_menu_my_groups" = "我的群组"; +"lng_menu_my_channels" = "我的频道"; + +"lng_disable_notifications_from_tray" = "关闭通知"; +"lng_enable_notifications_from_tray" = "开启通知"; +"lng_open_from_tray" = "打开 Tidings"; +"lng_minimize_to_tray" = "最小化到托盘"; +"lng_quit_from_tray" = "退出 Tidings"; +"lng_tray_icon_text" = "Tidings 仍在运行,您可以在设置中更改显示方式。\n如果此图标从托盘栏消失,您可以将其从隐藏的图标中拖出。"; + +// For lng_month_year or plain month name. +"lng_month1" = "1月"; +"lng_month2" = "2月"; +"lng_month3" = "3月"; +"lng_month4" = "4月"; +"lng_month5" = "5月"; +"lng_month6" = "6月"; +"lng_month7" = "7月"; +"lng_month8" = "8月"; +"lng_month9" = "9月"; +"lng_month10" = "10月"; +"lng_month11" = "11月"; +"lng_month12" = "12月"; + +// For lng_month_day and lng_month_day_year. +"lng_month_day1" = "1月"; +"lng_month_day2" = "2月"; +"lng_month_day3" = "3月"; +"lng_month_day4" = "4月"; +"lng_month_day5" = "5月"; +"lng_month_day6" = "6月"; +"lng_month_day7" = "7月"; +"lng_month_day8" = "8月"; +"lng_month_day9" = "9月"; +"lng_month_day10" = "10月"; +"lng_month_day11" = "11月"; +"lng_month_day12" = "12月"; + +"lng_month1_small" = "1月"; +"lng_month2_small" = "2月"; +"lng_month3_small" = "3月"; +"lng_month4_small" = "4月"; +"lng_month5_small" = "5月"; +"lng_month6_small" = "6月"; +"lng_month7_small" = "7月"; +"lng_month8_small" = "8月"; +"lng_month9_small" = "9月"; +"lng_month10_small" = "10月"; +"lng_month11_small" = "11月"; +"lng_month12_small" = "12月"; + +"lng_weekday1" = "周一"; +"lng_weekday2" = "周二"; +"lng_weekday3" = "周三"; +"lng_weekday4" = "周四"; +"lng_weekday5" = "周五"; +"lng_weekday6" = "周六"; +"lng_weekday7" = "周日"; + +"lng_month_day" = "{month}{day}日"; +"lng_month_day_year" = "{year}年{month}{day}日"; +"lng_month_year" = "{year}年{month}"; + +"lng_calendar_select_days" = "选择日期\n"; +"lng_calendar_start_tip" = "按住以跳转到起始位置。"; +"lng_calendar_end_tip" = "按住以跳转到结束位置。"; + +"lng_calendar_days#other" = "{count} 天"; + + +"lng_seconds#other" = "{count} 秒"; + +"lng_minutes#other" = "{count} 分钟"; + +"lng_hours#other" = "{count} 小时"; + +"lng_days#other" = "{count} 天"; + +"lng_weeks#other" = "{count} 周"; + +"lng_months#other" = "{count} 个月"; + +"lng_years#other" = "{count} 年"; + + +"lng_seconds_tiny#other" = "{count} 秒"; + +"lng_minutes_tiny#other" = "{count}分钟"; + +"lng_hours_tiny#other" = "{count}小时"; + +"lng_days_tiny#other" = "{count}天"; + +"lng_weeks_tiny#other" = "{count}周"; + +"lng_months_tiny#other" = "{count} 个月"; + +"lng_years_tiny#other" = "{count}年"; + +"lng_box_ok" = "确定"; +"lng_box_done" = "完成"; +"lng_box_yes" = "确定"; +"lng_box_no" = "取消"; + +"lng_cancel" = "取消"; +"lng_continue" = "继续"; +"lng_close" = "关闭"; +"lng_connecting" = "正在连接…"; + +"lng_reconnecting#other" = "将在 {count} 秒后重新连接…"; +"lng_reconnecting_try_now" = "立即重连"; + +"lng_code_block_header_copy" = "拷贝"; + +"lng_status_service_notifications" = "服务通知"; +"lng_status_support" = "客服"; +"lng_status_bot" = "机器人"; +"lng_status_bot_reads_all" = "可读取消息"; +"lng_status_bot_not_reads_all" = "无权读取消息"; +"lng_status_offline" = "很久没上线"; +"lng_status_recently" = "最近上线过"; +"lng_status_last_week" = "最近一周内曾上线"; +"lng_status_last_month" = "最近一个月内曾上线"; +"lng_status_lastseen_now" = "上线于不久前"; +"lng_status_lastseen_when" = "何时?"; + +"lng_status_lastseen_minutes#other" = "最近上线于 {count} 分钟前"; + +"lng_status_lastseen_hours#other" = "最近上线于 {count} 小时前"; +"lng_status_lastseen_today" = "最近上线于今天 {time}"; +"lng_status_lastseen_yesterday" = "最近上线于昨天 {time}"; +"lng_status_lastseen_date" = "最近上线于{date}"; +"lng_status_lastseen_date_time" = "最近上线于{date} {time}"; +"lng_status_online" = "在线"; +"lng_status_connecting" = "连接中…"; + +"lng_chat_status_unaccessible" = "此群组已无法访问"; + +"lng_chat_status_members#other" = "{count} 位成员"; + +"lng_chat_status_online#other" = "{count} 人在线"; +"lng_chat_status_members_online" = "{members_count},{online_count}"; + +"lng_chat_status_subscribers#other" = "{count} 位订阅者"; + +"lng_channel_status" = "频道"; +"lng_group_status" = "群组"; +"lng_scam_badge" = "欺诈"; +"lng_fake_badge" = "虚假"; + +"lng_remember" = "记住这次选择"; + +"lng_lastseen_show_title" = "显示您的最后上线时间"; +"lng_lastseen_show_about" = "若要查看 **{user}** 的最后上线时间,您需显示您自己的最后上线时间..."; +"lng_lastseen_show_button" = "显示我的最近上线时间"; +"lng_lastseen_or" = "或"; +"lng_lastseen_premium_title" = "升级为高级版"; +"lng_lastseen_premium_about" = "订阅后您将可以看到 **{user}** 的最后上线时间,而无需显示您的最后上线时间。"; +"lng_lastseen_premium_button" = "订阅 Tidings 高级版"; +"lng_lastseen_shown_toast" = "您最后上线的时间已设为可见。"; + +"lng_readtime_show_title" = "显示您的已读时间"; +"lng_readtime_show_about" = "若要查看 **{user}** 的已读时间,您需显示您自己的已读时间..."; +"lng_readtime_show_button" = "显示我的已读时间"; +"lng_readtime_or" = "或"; +"lng_readtime_premium_title" = "升级为高级版"; +"lng_readtime_premium_about" = "订阅后您将可以看到 **{user}** 的已读时间,而无需显示您的已读时间。"; +"lng_readtime_premium_button" = "订阅 Tidings 高级版"; +"lng_readtime_shown_toast" = "您的已读时间已设为可见。"; + +"lng_channels_limit_title" = "加入的群组过多"; + +"lng_channels_limit1#other" = "您已是 **{count}** 个群组或频道的成员。"; + +"lng_channels_limit2#other" = "请在加入新的群组或频道之前先退出一些——或者升级至 **Tidings 高级版**,将此上限翻倍至 **{count}** 个群组或频道。"; +"lng_channels_limit2_final" = "在加入新的群组之前请先退出一部分已加入的群组。"; +"lng_channels_leave_title" = "最不活跃的社群"; +"lng_channels_leave_status" = "{type}, 不活跃 {time}"; + +"lng_channels_leave#other" = "退出 {count} 个社群"; +"lng_channels_leave_done" = "您已退出所选社群。"; + +"lng_links_limit_title" = "公开链接过多"; + +"lng_links_limit1#other" = "您已保留 **{count}** 个公开链接。"; + +"lng_links_limit2#other" = "您可以撤消其中一个较旧群组或频道的链接,或者订阅 **Tidings 高级版**,将此上限翻倍至 **{count}** 个公开链接。"; +"lng_links_limit2_final" = "请尝试从旧的群组或频道中撤销链接"; +"lng_links_revoke_title" = "您的公开社群"; + +"lng_filter_chats_limit_title" = "已达到上限"; + +"lng_filter_chats_limit1#other" = "抱歉,您无法在此文件夹中添加超过 **{count}** 个聊天。"; + +"lng_filter_chats_exlude_limit1#other" = "抱歉,您不能从一个文件夹中排除超过 **{count}** 个聊天。"; + +"lng_filter_chats_limit2#other" = "您可以通过订阅 **Tidings 高级版** 来提升上限至 **{count}**。"; + +"lng_filters_limit_title" = "已达上限"; + +"lng_filters_limit1#other" = "您已到达最大 **{count}** 个文件夹的限制。"; + +"lng_filters_limit2#other" = "您可以订阅 **Tidings 高级版**,将此上限翻倍至 **{count}** 个文件夹。"; + +"lng_filter_pin_limit_title" = "到达上限"; + +"lng_filter_pin_limit1#other" = "抱歉,您最多只能置顶 **{count}** 个聊天。"; + +"lng_filter_pin_limit2#other" = "您可以取消部分置顶,或者订阅 **Tidings 高级版**,将此上限翻倍至 **{count}** 个聊天。"; + + +"lng_forum_pin_limit#other" = "抱歉,您最多只能置顶 **{count}** 个话题。"; + + +"lng_fave_sticker_limit_title#other" = "已达到 {count} 张贴纸的限制"; + +"lng_fave_sticker_limit_more#other" = "一张旧的贴纸已被这张贴纸替换。您可以{link} 至 {count} 张贴纸。"; +"lng_fave_sticker_limit_link" = "提升上限"; + + +"lng_saved_gif_limit_title#other" = "已达到 {count} 张 GIF 动态图的上限"; + +"lng_saved_gif_limit_more#other" = "一张旧的 GIF 动态图已被这张图替换。\n您可以 {link} 至 {count} 张 GIF 动态图。"; +"lng_saved_gif_limit_link" = "增加上限"; + +"lng_caption_limit_title" = "已达上限"; + +"lng_caption_limit1#other" = "抱歉,您不能在媒体的标题上使用超过 **{count}** 个字符。"; + +"lng_caption_limit2#other" = "您可以缩短说明文字,或者订阅 **Tidings 高级版**,将此上限翻倍至 **{count}** 个字符。"; + +"lng_caption_limit_reached#other" = "您已经达到媒体标题上限,请将其减少至 {count} 个字数以下。"; +"lng_caption_move_up" = "将说明文字上移"; +"lng_caption_move_down" = "将说明文字下移"; + +"lng_file_size_limit_title" = "文件过大"; + +"lng_file_size_limit#other" = "{count} GB"; +"lng_file_size_limit1" = "由于大小超过了 {size},无法发送此文档。"; +"lng_file_size_limit2" = "您可以订阅 **Tidings 高级版**,将此上限翻倍至每个文件 {size}。"; + +"lng_filter_links_limit_title" = "到达上限"; + +"lng_filter_links_limit1#other" = "抱歉,你不能创建多于 {count} 个邀请链接。"; + +"lng_filter_links_limit2#other" = "通过订阅 Tidings 高级会员你可以将上限提高到 {count} 个链接。"; + +"lng_filter_shared_limit_title" = "到达上限"; + +"lng_filter_shared_limit1#other" = "抱歉,您只能添加 **{count} **个可共享的文件夹。"; + +"lng_filter_shared_limit2#other" = "您可以通过订阅 **Tidings 高级版** 将上线增加至 **{count}** 个文件夹。"; + +"lng_limits_increase" = "提升上限"; + +"lng_sticker_premium_text" = "该贴纸包包含高级版贴纸,譬如该贴纸。"; +"lng_sticker_premium_view" = "查看"; +"lng_animated_emoji_text" = "订阅 **Tidings 高级版** 解锁该表情。"; +"lng_animated_emoji_saved" = "尝试在 **我的收藏** 中免费发送这些表情来体验。"; +"lng_animated_emoji_saved_open" = "打开"; + +"lng_flood_error" = "尝试次数过多,请稍后再试。"; +"lng_edit_error" = "您无法编辑这条消息"; +"lng_error_phone_flood" = "抱歉,您最近删除并重建账号的次数过多。请稍等几天再重新注册。"; +"lng_error_start_minimized_passcoded" = "由于您已设置锁定码,Tidings Desktop 无法以最小化模式启动。Tidings 会在启动前要求您输入锁定码。"; +"lng_error_public_groups_denied" = "非常抱歉,您已被禁止在公开群组中发言。\n{more_info}"; +"lng_error_cant_add_member" = "抱歉,您无法添加此机器人到群组。如有需要,请联系您的管理员。"; +"lng_error_cant_add_bot" = "抱歉,此机器人无法被添加至群组。"; +"lng_error_cant_add_admin_invite" = "抱歉,您无法将这位用户设为管理员。因为该用户不是此群组的成员,且您也无权邀请该用户。"; +"lng_error_you_blocked_user" = "抱歉,您无法将此用户或机器人添加到群组,因为您已将其屏蔽。请先将其解除屏蔽后再继续。"; +"lng_error_add_admin_not_member" = "您无法将此用户添加为管理员,因为他们不是该组的成员,因此您无权添加他们。"; +"lng_error_user_admin_invalid" = "您无法封禁这位用户,因为该用户是此群组的管理员,并且您也无权移除其管理员权限。"; +"lng_error_channel_bots_too_much" = "抱歉,此频道的机器人数量已达上限。"; +"lng_error_group_bots_too_much" = "抱歉,此群组的机器人数量已达上限。请先删除一些您不使用的机器人。"; +"lng_error_cant_add_admin_unban" = "抱歉,您无法将这位用户设为管理员。因为该用户在群组的黑名单中,且您也无法解除其封禁。"; +"lng_error_cant_ban_admin" = "您无法封禁这位用户,因为该用户是此群组的管理员,并且您也无权移除其管理员权限。"; +"lng_error_cant_reply_other" = "此信息不能在其他聊天中回复"; +"lng_error_admin_limit" = "抱歉,此群组的管理员人数已达上限。"; +"lng_error_admin_limit_channel" = "抱歉,此频道的管理员人数已达上限。"; +"lng_error_post_link_invalid" = "很遗憾,您无法打开此消息。 您不是消息所在聊天的成员。"; +"lng_error_noforwards_group" = "抱歉,管理员已禁止从此群组中转发消息。"; +"lng_error_noforwards_channel" = "抱歉,管理员已禁止从此频道中转发消息。"; +"lng_error_nocopy_group" = "抱歉,管理员已禁止从本群拷贝任何内容。"; +"lng_error_nocopy_channel" = "抱歉,管理员已禁止拷贝本频道中的内容。"; +"lng_error_nocopy_story" = "抱歉,此动态的作者禁止复制。"; +"lng_error_schedule_limit" = "抱歉,您不能设置超过 100 条定时消息。"; +"lng_sure_add_admin_invite" = "此用户非本群组成员。是否将其拉入到群组并设为管理员?"; +"lng_sure_add_admin_invite_channel" = "此用户并非本频道订阅者。是否邀请其关注频道并设为管理员?"; +"lng_sure_add_admin_unremove" = "此用户目前是受限用户或已不在群组中。您确定要提升他为管理员吗?"; +"lng_sure_ban_admin" = "此用户是管理员,您确定要对其作出限制吗?"; +"lng_sure_enable_socks" = "您确定要启用此代理吗?\n\n服务器:{server}\n端口:{port}\n\n您可以稍后在「设置 - 高级 - 连接方式」中修改您的代理服务器。"; +"lng_sure_enable" = "启用"; +"lng_proxy_box_title" = "启用代理"; +"lng_proxy_box_server" = "服务器"; +"lng_proxy_box_port" = "端口"; +"lng_proxy_box_secret" = "密钥"; +"lng_proxy_box_status" = "状态"; +"lng_proxy_box_username" = "用户名"; +"lng_proxy_box_password" = "密码"; +"lng_proxy_invalid" = "代理链接无效。"; +"lng_proxy_unsupported" = "你的 Tidings Desktop 版本不支持此代理类型或代理链接无效。请将 Tidings Desktop 更新至最新版本。"; + +"lng_edit_deleted" = "此消息已被删除"; + +"lng_edit_limit_reached#other" = "你的消息字数超限。请缩短 {count} 个字符。"; +"lng_edit_message" = "编辑消息"; +"lng_edit_message_text" = "输入消息……"; +"lng_deleted" = "已销号用户"; +"lng_deleted_message" = "已删除消息"; +"lng_deleted_story" = "删除动态"; +"lng_pinned_message" = "已置顶消息"; +"lng_pinned_previous" = "之前置顶的消息"; +"lng_pinned_unpin_sure" = "您确定要取消置顶此消息吗?"; +"lng_pinned_pin_sure" = "您确定要置顶此消息吗?"; +"lng_pinned_pin_sure_group" = "您确定要在群组中置顶此消息吗?"; +"lng_pinned_pin_old_sure" = "你想在保留一条最新的消息置顶时置顶一条更旧的消息吗?"; +"lng_pinned_pin" = "置顶"; +"lng_pinned_unpin" = "取消置顶"; +"lng_pinned_notify" = "通知所有成员"; +"lng_pinned_also_for_other" = "也为 {user} 置顶"; + +"lng_pinned_messages_title#other" = "{count} 条置顶消息"; +"lng_pinned_hide_all" = "不显示置顶消息"; + +"lng_pinned_unpin_all#other" = "取消置顶所有 {count} 条消息"; +"lng_pinned_unpin_all_sure" = "您要取消置顶所有消息吗?"; +"lng_pinned_hide_all_sure" = "您是否要隐藏置顶消息栏?在有新消息被置顶之前,置顶栏将保持隐藏。"; +"lng_pinned_hide_all_hide" = "隐藏"; + +"lng_edit_media_album_error" = "此文件无法保存为相册的一部分。"; +"lng_edit_media_invalid_file" = "抱歉,该文件不可用。"; +"lng_edit_photo_editor_hint" = "左键单击以编辑图片。"; +"lng_edit_caption_attach" = "抱歉,您在编辑消息时无法附加新媒体文件。"; +"lng_edit_caption_voice" = "抱歉,当有语音留言未发送时,您无法编辑消息。"; + +"lng_intro_about" = "欢迎使用 Tidings 官方桌面客户端。\n它既快速又安全。"; +"lng_start_msgs" = "开始聊天"; + +"lng_intro_next" = "下一步"; +"lng_intro_finish" = "注册"; +"lng_intro_submit" = "提交"; + +"lng_photo_caption" = "说明"; +"lng_photos_comment" = "评论"; + +"lng_intro_qr_title" = "从手机客户端扫描"; +"lng_intro_qr_step1" = "在您的手机上打开 Tidings"; +"lng_intro_qr_step2" = "请转到“设置” > “设备” > “关联桌面设备”"; +"lng_intro_qr_step3" = "请使用扫描二维码以登陆"; +"lng_intro_qr_skip" = "或使用您的手机号码登录"; + +"lng_intro_fragment_title" = "输入验证码"; +"lng_intro_fragment_about" = "在 Fragment 的匿名号码部分获取 {phone_number} 的验证码。"; +"lng_intro_fragment_button" = "打开 Fragment"; + +"lng_phone_title" = "你的手机号码"; +"lng_phone_desc" = "请确认您的国际电话区号\n并输入您的手机号码。"; +"lng_phone_to_qr" = "使用二维码快速登录"; +"lng_country_code" = "国际电话区号"; +"lng_bad_country_code" = "国际电话区号无效"; +"lng_country_ph" = "搜索"; +"lng_country_none" = "此国际电话区号不存在"; +"lng_country_select" = "选择国家/地区"; + +"lng_code_ph" = "验证码"; +"lng_code_desc" = "我们已经向您的手机发送了一条包含验证码的短信。\n请在下方输入您的验证码。"; +"lng_code_from_tidings" = "请输入在前一个 **Tidings** app上刚刚收到的验证码。"; +"lng_code_no_tidings" = "通过短信发送验证码"; +"lng_code_call" = "Tidings 会在 {minutes}:{seconds} 后使用电话呼叫您播放验证码"; +"lng_code_calling" = "正在请求 Tidings 呼叫…"; +"lng_code_called" = "Tidings 已拨打您的电话"; + +"lng_bad_phone" = "手机号码无效,请重试。"; +"lng_bad_code" = "您输入的验证码无效,请重试。"; +"lng_bad_name" = "请输入您的姓名。"; +"lng_bad_photo" = "抱歉,Tidings 无法处理该类型的图片。"; + +"lng_signin_title" = "两步验证"; +"lng_signin_desc" = "请输入两步验证密码。"; +"lng_signin_recover_desc" = "请输入此电子邮箱收到的验证码\n{email}"; +"lng_signin_password" = "您的两步验证密码"; +"lng_signin_code" = "验证码"; +"lng_signin_recover" = "是否忘记密码?"; +"lng_signin_recover_title" = "重置密码"; +"lng_signin_hint" = "提示:{password_hint}"; +"lng_signin_recover_hint" = "我们已经向电子邮箱({recover_email})发送了一条带有验证码的邮件。"; +"lng_signin_bad_password" = "您输入的密码错误,请重试。"; +"lng_signin_wrong_code" = "您输入的验证码无效,请重试。"; +"lng_signin_try_password" = "无法访问电子邮箱?"; +"lng_signin_no_email_forgot" = "因为您在设置密码时没有同时提供密码恢复邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"lng_signin_cant_email_forgot" = "如果您无法访问您的邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"lng_signin_reset_account" = "重置您的帐号"; +"lng_signin_sure_reset" = "如果您确认重置帐号,您将丢失 Tidings 中所有的聊天、消息、媒体和文件。\n\n您确定要重置帐号吗?"; +"lng_signin_reset" = "重置"; +"lng_signin_reset_wait" = "由于账号 {phone_number} 处于活跃状态并且受到密码保护,出于安全原因,我们将在一周后删除账号。您可以随时取消此项操作。\n\n您可以在 {when} 内重置您的账号。"; +"lng_signin_reset_in_days" = "{days_count} {hours_count} {minutes_count}"; +"lng_signin_reset_in_hours" = "{hours_count} {minutes_count}"; +"lng_signin_reset_cancelled" = "您最近的重置账号请求以被此帐号当前的活跃用户撤销,请于 7 天后再试。"; +"lng_signin_banned_text" = "此手机号码已被封禁。"; +"lng_signin_banned_help" = "帮助"; + +"lng_signup_title" = "个人信息"; +"lng_signup_desc" = "请输入您的姓名\n并上传头像。"; + +"lng_signup_firstname" = "名字"; +"lng_signup_lastname" = "姓氏"; + +"lng_dlg_filter" = "搜索"; +"lng_dlg_new_group_name" = "群组名称"; +"lng_dlg_new_channel_name" = "频道名称"; +"lng_dlg_new_bot_name" = "机器人名称"; +"lng_no_chats" = "此处是您的聊天列表"; +"lng_no_chats_filter" = "该文件夹内暂无聊天。"; +"lng_no_saved_sublists" = "您可以在此处保存其他聊天的消息。"; +"lng_contacts_loading" = "加载中…"; +"lng_contacts_not_found" = "未找到相关联系人"; +"lng_topics_not_found" = "未找到主题。"; +"lng_dlg_search_for_messages" = "搜索消息"; +"lng_update_tidings" = "更新 Tidings"; +"lng_dlg_search_in" = "按下列条件搜索消息"; +"lng_dlg_search_from" = "发送者:{user}"; + +"lng_settings_save" = "保存"; +"lng_settings_apply" = "应用"; + +"lng_username_title" = "用户名"; +"lng_username_description1" = "您可以在 Tidings 中选择使用用户名。使用用户名后,别人可以通过用户名找到您,并且无需知晓电话号码即可联系您。"; +"lng_username_description2" = "您可以使用 **a-z**、**0-9** 和下划线。最小长度为 5 个字符。"; +"lng_username_choose" = "设定您的用户名。"; +"lng_username_invalid" = "此用户名不符合规范。"; +"lng_username_occupied" = "此用户名已被占用。"; +"lng_username_too_short" = "此用户名过短。"; +"lng_username_purchase_available" = "很抱歉,此链接已被占用。您可以购买此链接,详见 {link} 。"; +"lng_username_purchase_available_link" = "了解更多..."; +"lng_username_bad_symbols" = "您只能使用 a-z、0-9 和下划线。"; +"lng_username_available" = "此用户名可用。"; +"lng_username_not_found" = "该用户名(@{user})不存在。"; +"lng_username_by_phone_not_found" = "没有找到电话为 {phone} 的用户。"; +"lng_username_app_not_found" = "未找到机器人应用程序。"; +"lng_username_link" = "通过此链接可以与您聊天:"; +"lng_username_copied" = "链接已复制到剪贴板。"; +"lng_username_text_copied" = "用户名已复制到粘贴板。"; + +"lng_usernames_edit" = "点击以编辑"; +"lng_usernames_active" = "激活"; +"lng_usernames_non_active" = "不可用"; +"lng_usernames_subtitle" = "用户名顺序"; + +"lng_usernames_activate_error#other" = "抱歉,你无法激活超出 **{count}** 个的用户名。"; +"lng_usernames_activate_description" = "你想要在个人信息页面显示用户名吗?"; +"lng_usernames_activate_confirm" = "显示"; +"lng_channel_usernames_subtitle" = "链接顺序"; +"lng_usernames_deactivate_description" = "你想要在个人信息页面上隐藏用户名吗?"; +"lng_usernames_deactivate_confirm" = "隐藏"; +"lng_usernames_description" = "拖放链接以更改它们在您的信息页面上的显示顺序。"; + +"lng_channel_usernames_activate_description" = "你想要将此链接显示在频道信息页面上吗?"; +"lng_channel_usernames_deactivate_description" = "你想从频道信息页面上隐藏此链接吗?"; +"lng_channel_usernames_description" = "拖放链接,更改其在频道信息页面上的显示顺序。"; + +"lng_bot_username_title" = "用户名"; +"lng_bot_username_description1" = "此链接不可编辑,你可以在 {link} 上获取更多用户名。"; +"lng_bot_username_description1_link" = "Fragment"; +"lng_bot_usernames_activate_description" = "您想要在机器人的信息页面上显示此链接吗?"; +"lng_bot_usernames_deactivate_description" = "您想要在机器人的信息页面中隐藏此链接吗?"; +"lng_bot_usernames_description" = "拖拽链接以调整其在机器人信息页上的显示顺序。"; + +"lng_bio_placeholder" = "个人简介"; + +"lng_collectible_username_title" = "{username} 是一个典藏用户名,属于"; +"lng_collectible_username_info" = "该用户名是于 {date} 在 **Fragment** 上花费 {price} 获取。"; +"lng_collectible_username_copy" = "复制链接"; +"lng_collectible_phone_title" = "{phone} 是一个典藏的电话号码,其属于"; +"lng_collectible_phone_info" = "该电话号码于 {date} 在 **Fragment** 上花费 {price} 获取"; +"lng_collectible_phone_copy" = "复制电话号码"; +"lng_collectible_learn_more" = "了解更多"; +"lng_collectible_phone_copied" = "手机号码已复制到剪贴板。"; + +"lng_settings_section_info" = "个人信息"; + +"lng_settings_section_notify" = "通知与声音"; +"lng_settings_show_from" = "显示通知"; +"lng_settings_notify_all" = "所有账号"; +"lng_settings_notify_all_about" = "如果您只想从正在使用的账号接收通知,请将其关闭。"; +"lng_settings_notify_global" = "全局设定"; +"lng_settings_notify_title" = "聊天通知"; +"lng_settings_desktop_notify" = "桌面通知"; +"lng_settings_native_title" = "系统原生通知"; +"lng_settings_use_windows" = "使用 Windows 原生通知"; +"lng_settings_skip_in_focus" = "遵循系统专注模式"; +"lng_settings_use_native_notifications" = "使用系统原生通知"; +"lng_settings_notifications_position" = "在屏幕上的位置"; +"lng_settings_notifications_count" = "显示的通知数量"; +"lng_settings_sound_allowed" = "开启声音"; +"lng_settings_alert_windows" = "任务栏图标闪烁"; +"lng_settings_alert_mac" = "弹出状态栏图标"; +"lng_settings_alert_linux" = "请注意窗口旁的提示"; +"lng_settings_badge_title" = "未读消息数量标记"; +"lng_settings_include_muted" = "在未读消息数中包含已关闭消息提醒的对话"; +"lng_settings_include_muted_folders" = "文件夹上的未读消息数量包括静音的聊天"; +"lng_settings_count_unread" = "统计未读消息"; +"lng_settings_events_title" = "事件"; +"lng_settings_events_joined" = "联系人已加入 Tidings"; +"lng_settings_events_pinned" = "置顶的消息"; +"lng_settings_notifications_calls_title" = "语音通话"; + +"lng_notification_preview_title" = "小恐龙雷克斯"; +"lng_notification_preview_text" = "东京现在是早上 😎"; +"lng_notification_show_name" = "名称"; +"lng_notification_show_text" = "文本"; +"lng_notification_preview" = "您有一条新消息"; +"lng_notification_reply" = "回复"; +"lng_notification_hide_all" = "全部隐藏"; +"lng_notification_sample" = "这是一个示例通知"; +"lng_notification_reminder" = "提醒"; +"lng_notification_private_chats" = "私聊"; +"lng_notification_groups" = "群组"; +"lng_notification_channels" = "频道"; +"lng_notification_click_to_change" = "点击此处以修改"; +"lng_notification_on" = "已开启, {exceptions}"; +"lng_notification_off" = "已关闭, {exceptions}"; + +"lng_notification_exceptions#other" = "{count} 个例外"; +"lng_notification_exceptions_title" = "例外"; +"lng_notification_title_private_chats" = "私聊通知"; + +"lng_notification_about_private_chats#other" = "请注意,有 **{count} 个聊天** 是例外,不会受到这次更改的影响。"; +"lng_notification_title_groups" = "群组通知"; + +"lng_notification_about_groups#other" = "请注意,有 **{count} 个群组** 是例外,不会受到这次更改的影响。"; +"lng_notification_title_channels" = "频道通知"; + +"lng_notification_about_channels#other" = "请注意,有 **{count} 个频道** 是例外,不会受到这次更改的影响。"; +"lng_notification_exceptions_view" = "查看例外"; +"lng_notification_enable" = "启用私聊"; +"lng_notification_sound" = "声音"; +"lng_notification_tone" = "通知铃声"; +"lng_notification_exceptions_muted" = "已静音"; +"lng_notification_exceptions_unmuted" = "未静音"; +"lng_notification_exceptions_add" = "添加例外"; +"lng_notification_exceptions_clear" = "删除所有例外"; +"lng_notification_exceptions_clear_sure" = "您确定删除所有例外吗?"; +"lng_notification_exceptions_clear_button" = "删除"; +"lng_notification_exceptions_remove" = "移除"; +"lng_notification_context_remove" = "移除例外"; + +"lng_reaction_text" = "对您的 “{text}” {reaction}"; +"lng_reaction_notext" = "对您的消息 {reaction}"; +"lng_reaction_photo" = "对您的图片 {reaction}"; +"lng_reaction_video" = "对您的视频 {reaction}"; +"lng_reaction_video_message" = "对您的视频消息 {reaction}"; +"lng_reaction_document" = "对您的文件 {reaction}"; +"lng_reaction_sticker" = "对您的 {emoji} 贴纸 {reaction}"; +"lng_reaction_voice_message" = "对您的语音消息 {reaction}"; +"lng_reaction_contact" = "对您的联系人信息 {name} {reaction}"; +"lng_reaction_location" = "对您的地图 {reaction}"; +"lng_reaction_poll" = "对您的 “{title}” 投票 {reaction}"; +"lng_reaction_quiz" = "对您的 “{title}” 问答 {reaction}"; +"lng_reaction_game" = "对您的游戏 {reaction}"; +"lng_reaction_invoice" = "对您的账单 {reaction}"; +"lng_reaction_gif" = "对您的 GIF 动态图 {reaction}"; + +"lng_effect_add_title" = "添加动画特效"; +"lng_effect_stickers_title" = "贴纸特效"; +"lng_effect_send" = "启用特效并发送"; +"lng_effect_none" = "未找到特效。"; +"lng_effect_premium" = "订阅 {link} 以添加此动画特效。"; +"lng_effect_premium_link" = "Tidings 高级版"; + +"lng_languages" = "语言"; +"lng_languages_none" = "未找到任何语言。"; + +"lng_languages_count#other" = "{count} 种语言"; +"lng_sure_save_language" = "Tidings 需要重新启动以更改语言"; +"lng_settings_update_automatically" = "自动更新"; +"lng_settings_install_beta" = "安装测试版本"; +"lng_settings_current_version" = "{version} 版"; +"lng_settings_check_now" = "检查更新"; +"lng_settings_update_checking" = "正在检查更新…"; +"lng_settings_latest_installed" = "已更新至最新版"; +"lng_settings_update_ready" = "新版已准备就绪"; +"lng_settings_update_fail" = "检查更新失败 :("; +"lng_settings_workmode_tray" = "显示托盘图标"; +"lng_settings_workmode_window" = "显示任务栏图标"; +"lng_settings_window_close" = "当窗口关闭时"; +"lng_settings_run_in_background" = "在背景运行"; +"lng_settings_quit_on_close" = "退出应用"; +"lng_settings_close_to_taskbar" = "最小化到任务栏"; +"lng_settings_monochrome_icon" = "使用黑白图标"; +"lng_settings_window_system" = "窗口标题栏"; +"lng_settings_title_chat_name" = "显示聊天名称"; +"lng_settings_title_account_name" = "显示活跃的账户"; +"lng_settings_title_total_count" = "总未读数"; +"lng_settings_native_frame" = "使用系统窗口"; +"lng_settings_qt_frame" = "使用QT窗口框架"; +"lng_settings_auto_start" = "开机启动 Tidings"; +"lng_settings_start_min" = "启动后隐藏到托盘"; +"lng_settings_auto_start_disabled_uwp" = "Windows 已阻止 Tidings 开机自动启动。\n\n请在设置中允许 Tidings 自动启动。"; +"lng_settings_open_system_settings" = "转到设置"; +"lng_settings_add_sendto" = "在「发送到」菜单中显示 Tidings"; +"lng_settings_mac_warn_before_quit" = "使用{text}退出前显示警告"; +"lng_settings_mac_round_icon" = "使用圆形应用图标"; + +"lng_settings_experimental" = "实验性功能设置"; +"lng_settings_experimental_about" = "警告!这些是实验性功能设置。其中有一些可能无法正常工作,甚至导致应用程序崩溃。这里的任何设置随时都可能因版本更新而消失。使用风险自负。"; +"lng_settings_experimental_restore" = "恢复默认选项"; +"lng_settings_experimental_irrelevant" = "此选项在您的系统中不适用。"; + +"lng_settings_section_chat_settings" = "聊天设置"; +"lng_settings_replace_emojis" = "自动替换表情符号"; +"lng_settings_suggest_emoji" = "推荐表情符号替换"; +"lng_settings_suggest_animated_emoji" = "推荐动态表情"; +"lng_settings_suggest_by_emoji" = "通过表情推荐热门贴纸"; +"lng_settings_loop_stickers" = "循环播放动态贴纸"; +"lng_settings_large_emoji" = "大号表情"; +"lng_settings_send_enter" = "按回车键发送消息"; +"lng_settings_send_ctrlenter" = "按 Ctrl+Enter 键发送消息"; +"lng_settings_send_cmdenter" = "按 Cmd+Enter 键发送消息"; +"lng_settings_chat_quick_action_reply" = "双击回复"; +"lng_settings_chat_quick_action_react" = "双击表达态度"; +"lng_settings_chat_quick_action_select" = "双击选择全部"; +"lng_settings_chat_corner_reaction" = "信息上的回应按钮"; + +"lng_settings_shortcuts" = "键盘快捷键"; + +"lng_shortcuts_reset" = "恢复默认"; +"lng_shortcuts_recording" = "正在录制..."; +"lng_shortcuts_add_another" = "新增其他"; + +"lng_shortcuts_close" = "关闭窗口"; +"lng_shortcuts_lock" = "锁定应用"; +"lng_shortcuts_minimize" = "最小化窗口"; +"lng_shortcuts_quit" = "退出应用"; +"lng_shortcuts_media_play" = "播放媒体"; +"lng_shortcuts_media_pause" = "暂停媒体"; +"lng_shortcuts_media_play_pause" = "切换媒体播放"; +"lng_shortcuts_media_stop" = "停止播放媒体"; +"lng_shortcuts_media_previous" = "上一首"; +"lng_shortcuts_media_next" = "下一首"; +"lng_shortcuts_search" = "搜索消息"; +"lng_shortcuts_chat_previous" = "上一个聊天"; +"lng_shortcuts_chat_next" = "下一个聊天"; +"lng_shortcuts_chat_first" = "第一个聊天"; +"lng_shortcuts_chat_last" = "最后一个聊天"; +"lng_shortcuts_chat_self" = "已收藏的消息"; +"lng_shortcuts_chat_pinned_n" = "置顶聊天 #{index}"; +"lng_shortcuts_show_account_n" = "账号 #{index}"; +"lng_shortcuts_show_all_chats" = "所有聊天的文件夹"; +"lng_shortcuts_show_folder_n" = "文件夹 #{index}"; +"lng_shortcuts_show_folder_last" = "最后一个文件夹"; +"lng_shortcuts_folder_next" = "下一个文件夹"; +"lng_shortcuts_folder_previous" = "上一个文件夹"; +"lng_shortcuts_scheduled" = "预约消息"; +"lng_shortcuts_archive" = "归档聊天"; +"lng_shortcuts_contacts" = "联系人名单"; +"lng_shortcuts_just_send" = "刚刚发送"; +"lng_shortcuts_silent_send" = "静音发送"; +"lng_shortcuts_schedule" = "时间表"; +"lng_shortcuts_read_chat" = "将聊天标记为已读"; +"lng_shortcuts_archive_chat" = "存档聊天"; +"lng_shortcuts_media_fullscreen" = "切换全屏幕视频"; +"lng_shortcuts_show_chat_menu" = "显示聊天菜单\n"; +"lng_shortcuts_show_chat_preview" = "显示聊天预览"; + +"lng_settings_chat_reactions_title" = "快速回应"; +"lng_settings_chat_reactions_subtitle" = "选择你最喜欢的反应"; +"lng_settings_chat_message_reply_from" = "Bob Harris"; +"lng_settings_chat_message_reply" = "早上好"; +"lng_settings_chat_message" = "你知道现在几点吗?"; + +"lng_settings_section_filters" = "文件夹"; + +"lng_settings_section_background" = "聊天背景"; +"lng_settings_bg_from_gallery" = "从相册中选择"; +"lng_settings_bg_from_file" = "从文件中选择"; +"lng_settings_bg_remove" = "移除壁纸"; +"lng_settings_bg_theme_edit" = "编辑"; +"lng_settings_bg_theme_create" = "创建新主题"; +"lng_settings_bg_cloud_themes" = "自定义主题"; +"lng_settings_bg_show_all" = "显示所有主题"; +"lng_settings_bg_tile" = "平铺背景"; +"lng_settings_adaptive_wide" = "宽屏时消息左对齐"; + +"lng_settings_section_call_settings" = "通话设置"; +"lng_settings_call_camera" = "摄像头"; +"lng_settings_call_section_output" = "扬声器和耳机"; +"lng_settings_call_section_input" = "麦克风"; +"lng_settings_call_input_device" = "输入设备"; +"lng_settings_call_output_device" = "输出设备"; +"lng_settings_call_section_other" = "其他设置"; +"lng_settings_call_open_system_prefs" = "打开系统声音偏好设置"; +"lng_settings_call_accept_calls" = "在此设备上接听电话"; +"lng_settings_call_device_default" = "默认"; + +"lng_settings_section_devices" = "扬声器和摄像头"; +"lng_settings_devices_calls" = "通话和视频聊天"; +"lng_settings_devices_calls_same" = "使用相同的设备进行通话"; +"lng_settings_devices_inactive" = "不可用"; + +"lng_settings_language" = "语言"; +"lng_settings_default_scale" = "默认界面缩放比例"; +"lng_settings_connection_type" = "连接类型"; +"lng_settings_downloading_update" = "下载更新中 {progress}…"; +"lng_settings_privacy_title" = "隐私"; +"lng_settings_last_seen" = "最后上线时间和在线状态"; +"lng_settings_calls" = "通话"; +"lng_settings_calls_peer_to_peer_title" = "端对端"; +"lng_settings_calls_peer_to_peer_button" = "将端对端通话用于"; +"lng_settings_groups_invite" = "群组与频道"; +"lng_settings_phone_number_privacy" = "手机号码"; +"lng_settings_forwards_privacy" = "已转发的消息"; +"lng_settings_profile_photo_privacy" = "头像"; +"lng_settings_messages_privacy" = "消息"; +"lng_settings_voices_privacy" = "语音消息"; +"lng_settings_bio_privacy" = "个人简介"; +"lng_settings_gifts_privacy" = "礼物"; +"lng_settings_birthday_privacy" = "出生日期"; +"lng_settings_privacy_premium" = "只有 {link} 订阅者才可以限制接受语音消息。"; +"lng_settings_privacy_premium_link" = "Tidings 高级版"; +"lng_settings_passcode_disable" = "停用本机锁定码"; +"lng_settings_passcode_disable_sure" = "您确定要禁用口令吗?"; +"lng_settings_use_winhello" = "使用 Windows Hello 解锁"; +"lng_settings_use_winhello_about" = "在使用 Windows Hello 解锁 Tidings 之前,您需要输入一次密码。"; +"lng_settings_use_touchid" = "用 Touch ID 解锁"; +"lng_settings_use_touchid_about" = "在使用 Touch ID 解锁 Tidings 之前,您需要输入一次密码。"; +"lng_settings_use_applewatch" = "使用 Apple Watch 解锁"; +"lng_settings_use_applewatch_about" = "在使用 Apple Watch 解锁 Tidings 之前,您需要输入一次密码。"; +"lng_settings_use_systempwd" = "使用系统密码解锁"; +"lng_settings_use_systempwd_about" = "在使用系统密码解锁 Tidings 之前,您需要输入一次密码。"; +"lng_settings_password_disable" = "停用两步验证密码"; +"lng_settings_password_abort" = "取消两步验证设置"; +"lng_settings_about_bio" = "您可以在此输入任何有关您个人的信息,诸如:年龄、工作或居住城市。\n示例:23岁,来自旧金山的设计师。"; +"lng_settings_name_label" = "名字"; +"lng_settings_username_label" = "用户名"; +"lng_settings_phone_label" = "电话号码"; +"lng_settings_username_add" = "设置用户名"; +"lng_settings_username_about" = "用户名可以让其他用户在 Tidings 上联系您,而无需知道您的手机号码。"; +"lng_settings_birthday_label" = "出生日期"; +"lng_settings_birthday_add" = "添加"; +"lng_settings_birthday_about" = "在 {link} 中选择谁能看到你的出生日期。"; +"lng_settings_birthday_about_link" = "设置"; +"lng_settings_birthday_contacts" = "只有您的联系人才能看到您的生日。 {link}"; +"lng_settings_birthday_contacts_link" = "更改 >"; +"lng_settings_birthday_saved" = "已更新出生日期。"; +"lng_settings_birthday_reset" = "移除"; +"lng_settings_channel_label" = "个人频道"; +"lng_settings_channel_add" = "添加"; +"lng_settings_channel_remove" = "移除"; +"lng_settings_channel_no_yet" = "你目前没有任何公开频道。"; +"lng_settings_channel_start" = "创建频道"; +"lng_settings_channel_saved" = "个人频道已更新。"; +"lng_settings_channel_removed" = "个人频道已移除。"; +"lng_settings_add_account_about" = "您最多可以添加四个具有不同手机号码的帐号。"; +"lng_settings_peer_to_peer_about" = "停用端对端连接后,所有通话流量将使用 Tidings 服务器中转以避免泄露您的 IP 地址,但可能会降低音质及画质。"; +"lng_settings_advanced" = "高级设置"; +"lng_settings_stickers_emoji" = "贴纸和表情符号"; +"lng_settings_messages" = "消息"; +"lng_settings_themes" = "主题"; +"lng_settings_theme_day" = "白天"; +"lng_settings_theme_classic" = "经典"; +"lng_settings_theme_tinted" = "深色"; +"lng_settings_theme_night" = "黑夜"; +"lng_settings_theme_accent_title" = "选择强调色"; +"lng_settings_data_storage" = "数据与存储"; +"lng_settings_information" = "编辑个人资料"; +"lng_settings_my_account" = "我的账号"; +"lng_settings_security" = "安全设置"; +"lng_settings_passcode_title" = "本机锁定码"; +"lng_settings_sessions_title" = "活跃会话"; +"lng_settings_sessions_about" = "控制您在其它设备上的会话。"; +"lng_settings_archive_title" = "归档设置"; +"lng_settings_new_unknown" = "来自未知用户的新聊天"; +"lng_settings_auto_archive" = "归档并静音"; +"lng_settings_auto_archive_about" = "自动归档及静音来自非联系人的新聊天、群组和频道。"; +"lng_settings_unmuted_chats" = "未静音的聊天"; +"lng_settings_always_in_archive" = "始终保留在归档中"; +"lng_settings_unmuted_chats_about" = "即使已将聊天取消静音并收到新消息,仍将归档的聊天保留在存档中。"; +"lng_settings_chats_from_folders" = "文件夹中的聊天"; +"lng_settings_chats_from_folders_about" = "即使已将聊天取消静音并收到新消息,仍将文件夹中的已归档聊天保留在归档中。"; +"lng_settings_destroy_title" = "删除我的帐号"; +"lng_settings_version_info" = "版本和更新"; +"lng_settings_system_integration" = "系统集成"; +"lng_settings_performance" = "性能"; +"lng_settings_enable_hwaccel" = "使用硬件加速视频解码"; +"lng_settings_enable_opengl" = "为媒体启用 OpenGL 渲染"; +"lng_settings_angle_backend" = "ANGLE 图形后端"; +"lng_settings_angle_backend_auto" = "自动选择"; +"lng_settings_angle_backend_d3d9" = "Direct3D 9"; +"lng_settings_angle_backend_d3d11" = "Direct3D 11"; +"lng_settings_angle_backend_d3d11on12" = "D3D11on12"; +"lng_settings_angle_backend_opengl" = "OpenGL"; +"lng_settings_angle_backend_disabled" = "停用"; +"lng_settings_top_peers_title" = "常用联系人"; +"lng_settings_top_peers_suggest" = "推荐常用联系人"; +"lng_settings_top_peers_about" = "在搜索区域顶部显示常用联系人以便您快速访问。"; +"lng_settings_sensitive_title" = "敏感内容"; +"lng_settings_sensitive_disable_filtering" = "停用过滤"; +"lng_settings_sensitive_about" = "不隐藏包含仅适合成人观看的媒体。"; +"lng_settings_security_bots" = "机器人与网站"; +"lng_settings_file_confirmations" = "文件打开确认"; +"lng_settings_edit_extensions" = "扩展名清单"; +"lng_settings_edit_extensions_about" = "打开具有以下扩展名的文件,无需额外确认。"; +"lng_settings_edit_ip_confirm" = "IP 地址暴露警告"; +"lng_settings_edit_ip_confirm_about" = "开启可能会泄漏您的 IP 位址的文件时显示确认消息。"; +"lng_settings_clear_payment_info" = "清除付款和配送信息"; +"lng_settings_logged_in" = "使用 Tidings 账号登录的网站"; +"lng_settings_logged_in_title" = "使用 Tidings 账号登录的网站"; +"lng_settings_logged_in_description" = "您可以在支持的网站上使用 Tidings 登录。"; +"lng_settings_disconnect_all" = "取消所有网站的授权"; +"lng_settings_disconnect_title" = "断开网站连接"; +"lng_settings_disconnect_sure" = "您确定要断开与 {domain} 的连接吗?"; +"lng_settings_disconnect_block" = "屏蔽 {name}"; +"lng_settings_disconnect_all_title" = "断开网站连接"; +"lng_settings_disconnect_all_sure" = "您确定要断开所有使用 Tidings 登录的网站吗?"; +"lng_settings_disconnect" = "断开连接"; +"lng_settings_connected_title" = "已授权网站"; + +"lng_settings_suggestion_phone_number_title" = "您还在继续使用 {phone} 这个号码吗?"; +"lng_settings_suggestion_phone_number_about" = "保持您的号码是最新的,以确保您始终可以登录 Tidings。{link}"; +"lng_settings_suggestion_phone_number_about_link" = "https://tidings.org/faq#q-i-have-a-new-phone-number-what-do-i-do"; +"lng_settings_suggestion_phone_number_change" = "请尽快在您手机上的官方 Tidings 应用中更改您的电话号码。{emoji}"; + +"lng_settings_power_menu" = "电池和动画"; +"lng_settings_power_title" = "电池使用"; +"lng_settings_power_subtitle" = "省电选项"; +"lng_settings_power_stickers" = "动态贴纸 "; +"lng_settings_power_stickers_panel" = "在面板中自动播放"; +"lng_settings_power_stickers_chat" = "在聊天中自动播放"; +"lng_settings_power_emoji" = "动态表情"; +"lng_settings_power_emoji_panel" = "在面板中自动播放"; +"lng_settings_power_emoji_reactions" = "在反应菜单中自动播放"; +"lng_settings_power_emoji_chat" = "在消息中自动播放"; +"lng_settings_power_emoji_status" = "在高级版表情状态中自动播放"; +"lng_settings_power_chat" = "聊天中的动画"; +"lng_settings_power_chat_background" = "壁纸旋转"; +"lng_settings_power_chat_spoiler" = "动态遮罩特效"; +"lng_settings_power_chat_effects" = "消息中的特效"; +"lng_settings_power_calls" = "通话中的动画"; +"lng_settings_power_ui" = "界面动画"; +"lng_settings_power_auto" = "低电量时启用省电模式"; +"lng_settings_power_auto_about" = "当您的笔记本电脑启用省电模式时,自动关闭所有动画。"; +"lng_settings_power_turn_off" = "请先关闭省电模式,再更改这些设置。"; + +"lng_settings_cloud_password_on" = "开启"; +"lng_settings_cloud_password_off" = "关闭"; +"lng_settings_cloud_password_start_title" = "两步验证"; +"lng_settings_cloud_password_password_title" = "密码"; +"lng_settings_cloud_password_hint_title" = "密码提示"; +"lng_settings_cloud_password_email_title" = "恢复邮箱"; +"lng_settings_cloud_password_start_about" = "使用二级密码保护您的账户。"; +"lng_settings_cloud_password_hint_about" = "您可以为您的密码创建一个提示。"; +"lng_settings_cloud_password_email_about" = "请输入您的恢复邮箱地址。 这是忘记密码时唯一的恢复方法。"; +"lng_settings_cloud_password_password_subtitle" = "设定密码"; +"lng_settings_cloud_password_check_subtitle" = "您的密码"; +"lng_settings_cloud_password_hint_subtitle" = "添加密码提示"; +"lng_settings_cloud_password_email_subtitle" = "添加恢复邮箱"; +"lng_settings_cloud_password_email_recovery_subtitle" = "找回密码"; +"lng_settings_cloud_password_manage_about1" = "您已启用了两步验证,因此您的帐号已受到额外密码的保护。"; +"lng_settings_cloud_password_manage_about2" = "此邮箱是您恢复密码的唯一方式。"; +"lng_settings_cloud_password_manage_disable_sure" = "您确定要停用密码吗?"; +"lng_settings_cloud_password_manage_email_new" = "设置恢复邮箱"; +"lng_settings_cloud_password_manage_email_change" = "更改密码恢复电子邮箱"; +"lng_settings_cloud_password_manage_password_change" = "修改密码"; +"lng_settings_cloud_password_skip_hint" = "跳过该提示"; +"lng_settings_cloud_password_save" = "保存并完成"; +"lng_settings_cloud_password_email_confirm" = "确认并结束"; +"lng_settings_cloud_password_reset_in" = "您可以在 {duration} 之后重置密码"; + +"lng_settings_cloud_login_email_section_title" = "邮箱登录"; +"lng_settings_cloud_login_email_box_about" = "你每次从新的设备登入Tidings 时都会使用该电子邮箱。"; +"lng_settings_cloud_login_email_box_ok" = "更改邮箱"; +"lng_settings_cloud_login_email_title" = "输入新的邮箱地址"; +"lng_settings_cloud_login_email_placeholder" = "输入登录邮箱"; +"lng_settings_cloud_login_email_about" = "您将通过电子邮箱而非短信收取 Tidings 登录码。请输入您可以访问的电子邮箱地址。"; +"lng_settings_cloud_login_email_confirm" = "确认"; +"lng_settings_cloud_login_email_code_title" = "检查你的新邮件"; +"lng_settings_cloud_login_email_code_about" = "请输入我们发送至你的新邮件 {email} 的代码。"; +"lng_settings_cloud_login_email_success" = "你的电子邮箱地址已更改。"; +"lng_settings_error_email_not_alowed" = "抱歉,不允许使用此电子邮箱"; + +"lng_settings_ttl_title" = "自动删除消息"; +"lng_settings_ttl_about" = "在开始的所有新聊天中,一段时间后自动删除所有消息。"; +"lng_settings_ttl_after" = "{after_duration} 后"; + +"lng_settings_ttl_after_hours#other" = "{count} 小时"; + +"lng_settings_ttl_after_days#other" = "{count} 天"; + +"lng_settings_ttl_after_weeks#other" = "{count} 周"; + +"lng_settings_ttl_after_months#other" = "{count} 个月"; + +"lng_settings_ttl_after_years#other" = "{count} 年"; +"lng_settings_ttl_after_off" = "关闭"; +"lng_settings_ttl_after_custom" = "自定义时间"; +"lng_settings_ttl_after_about" = "如果启用,您发起的聊天中的所有新消息将在发送后的某个时间点自动删除。您还可以 {link}。"; +"lng_settings_ttl_after_about_link" = "将此设置应用到现有的聊天"; +"lng_settings_ttl_after_subtitle" = "自毁定时器"; +"lng_settings_ttl_after_sure" = "您确定要于消息发送 {after_duration} 后为所有人自动删除您新建的私聊和群组中的所有消息吗?"; +"lng_settings_ttl_after_toast" = "您发起的所有新聊天中的消息将在 {after_duration} 后自动删除。"; + +"lng_settings_ttl_select_chats_sorry" = "抱歉,您不能在此聊天中设置自毁定时器。"; +"lng_settings_ttl_select_chats_status" = " {after_duration} 后自动删除"; +"lng_settings_ttl_select_chats_status_disabled" = "自动删除已禁用"; + +"lng_settings_ttl_select_chats_toast#other" = "时长为 {duration} 的自毁计时器已在选中的 {count} 个聊天中启用。"; + +"lng_settings_ttl_select_chats_disabled_toast#other" = "已在所选的{count}个聊天中停用自毁计时器。"; + +"lng_clear_payment_info_title" = "清除付款信息"; +"lng_clear_payment_info_sure" = "您确定要清除您的付款和收货信息吗?"; +"lng_clear_payment_info_shipping" = "配送信息"; +"lng_clear_payment_info_payment" = "付款信息"; +"lng_clear_payment_info_clear" = "清除"; +"lng_clear_payment_info_confirm" = "是否删除您的配送信息,并使所有的支付服务提供商清除您保存的信用卡信息?请注意 Tidings 从来不会存储您的信用卡数据。"; + +"lng_settings_theme_settings" = "主题设置"; +"lng_settings_theme_name_color" = "你的名称颜色"; +"lng_settings_auto_night_mode" = "自动切换夜间模式"; +"lng_settings_auto_night_mode_off" = "关闭"; +"lng_settings_auto_night_mode_on" = "系统"; +"lng_settings_auto_night_warning" = "您已开启自动夜间模式,停用后才可以修改夜间模式设置。"; +"lng_settings_auto_night_disable" = "关闭"; +"lng_settings_font_family" = "字体系列"; + +"lng_settings_color_title" = "颜色预览"; +"lng_settings_color_reply" = "回复你的消息"; +"lng_settings_color_reply_channel" = "回复您频道消息"; +"lng_settings_color_text" = "你的名称与回复将以所选的颜色显示。"; +"lng_settings_color_text_channel" = "频道名称及回复消息将以所选的颜色显示。"; +"lng_settings_color_link_name" = "Tidings"; +"lng_settings_color_link_title" = "链接预览"; +"lng_settings_color_link_description" = "该颜色也会套用于您发送的链接预览。"; +"lng_settings_color_about" = "您可以选择一种颜色为自己的名字、发送的链接和收到的回复着色。"; +"lng_settings_color_about_channel" = "您可以选择一种颜色为您的频道名称、发送的链接以及频道贴文的回复着色。"; +"lng_settings_color_emoji" = "为回复添加图标"; +"lng_settings_color_emoji_remove" = "移除图标"; +"lng_settings_color_emoji_off" = "关闭"; +"lng_settings_color_emoji_about" = "通过向消息添加自定图标,使您回复的消息脱颖而出。"; +"lng_settings_color_emoji_about_channel" = "选择一个图标并为收到的回复创建自定义样式。"; +"lng_settings_color_subscribe" = "订阅 {link} 并为您的名字选择自定义颜色。"; +"lng_settings_color_changed" = "您频道颜色已更新!"; +"lng_settings_color_changed_channel" = "您频道颜色已更新!"; + +"lng_suggest_hide_new_title" = "隐藏新聊天?"; +"lng_suggest_hide_new_about" = "您收到了很多来自陌生用户的新聊天会话。您是否想将这些聊天会话**自动静音**及**归档**?"; +"lng_suggest_hide_new_to_settings" = "转到设置"; + +"lng_settings_spellchecker" = "拼写检查"; +"lng_settings_system_spellchecker" = "使用系统拼写检查器"; +"lng_settings_custom_spellchecker" = "使用拼写检查"; +"lng_settings_auto_download_dictionaries" = "自动下载字典"; +"lng_settings_manage_dictionaries" = "管理字典"; +"lng_settings_manage_enabled_dictionary" = "字典已启用"; +"lng_settings_manage_remove_dictionary" = "移除词典"; + +"lng_settings_gift_premium" = "赠送 Tidings 高级版订阅"; +"lng_settings_gift_premium_users_confirm" = "继续"; + +"lng_settings_gift_premium_users_error#other" = "您最多可以选择 {count} 个用户。"; +"lng_settings_gift_premium_choose" = "请至少选择一位接收者。"; + +"lng_backgrounds_header" = "选择新的聊天壁纸"; +"lng_theme_sure_keep" = "保留此主题?"; + +"lng_theme_reverting#other" = "将在 {count} 秒后还原为之前的主题。"; +"lng_theme_keep_changes" = "保留更改"; +"lng_theme_revert" = "还原"; +"lng_theme_no_desktop" = "抱歉,此主题未包含 Tidings Desktop 的版本。"; +"lng_theme_share" = "分享"; +"lng_theme_edit" = "编辑"; +"lng_theme_delete" = "删除"; +"lng_theme_delete_sure" = "您确定要删除此主题吗?"; +"lng_background_header" = "壁纸预览"; +"lng_background_text1" = "你们这群小屁孩就知道电子音乐!你们应该听听经典摇滚,比如哈塞尔霍夫!"; +"lng_background_text2" = "我现在已经无法直视你了。"; +"lng_background_bad_link" = "背景链接无效。"; +"lng_background_share" = "分享"; +"lng_background_link_copied" = "链接已复制到剪贴板"; +"lng_background_blur" = "模糊"; +"lng_background_sure_delete" = "您确定要删除此背景吗?"; +"lng_background_other_info" = "{user} 可以应用此壁纸"; +"lng_background_other_channel" = "所有订阅者都将看到此壁纸"; +"lng_background_other_group" = "所有成员能够查看该壁纸"; +"lng_background_apply1" = "在聊天中应用此背景。"; +"lng_background_apply2" = "看起来不错"; +"lng_background_apply_button" = "应用于此聊天"; +"lng_background_dimming" = "背景虚化"; +"lng_background_sure_reset_default" = "您确定要重置墙纸吗?"; +"lng_background_reset_default" = "重置"; +"lng_background_apply_me" = "为自己应用"; +"lng_background_apply_both" = "为自己和 {user} 应用"; +"lng_background_apply_channel" = "为频道应用"; +"lng_background_apply_group" = "为群组应用"; + +"lng_download_path_ask" = "询问每个文件的下载路径"; +"lng_download_path" = "下载路径"; +"lng_download_path_temp" = "临时文件夹"; +"lng_download_path_default" = "默认目录"; +"lng_download_path_header" = "选择下载目录"; +"lng_download_path_default_radio" = "「下载」文件夹"; +"lng_download_path_temp_radio" = "临时目录,在登出/卸载时会自动清空"; +"lng_download_path_dir_radio" = "自定义目录,只能手动清理"; +"lng_download_path_choose" = "选择下载目录"; +"lng_download_path_failed" = "无法开始下载\n\n您选择的下载目录可能无效。请尝试在「设置 - 高级」中更改下载目录。 "; +"lng_download_finish_failed" = "文件下载失败\n\n是否重试?"; + +"lng_settings_section_privacy" = "隐私和安全"; + +"lng_local_storage_title" = "本地缓存"; +"lng_local_storage_empty" = "无缓存文件"; + +"lng_local_storage_image#other" = "{count} 张图片"; + +"lng_local_storage_sticker#other" = "{count} 张表情"; + +"lng_local_storage_voice#other" = "{count} 条语音消息"; + +"lng_local_storage_round#other" = "{count} 条视频消息"; + +"lng_local_storage_animation#other" = "{count} 张 GIF 动态图"; +"lng_local_storage_media" = "媒体缓存"; +"lng_local_storage_size_limit" = "总大小限制:{size}"; +"lng_local_storage_media_limit" = "媒体缓存限制: {size}"; +"lng_local_storage_time_limit" = "媒体文件保留时间:{limit}"; +"lng_local_storage_limit_never" = "永久"; +"lng_local_storage_summary" = "总计"; +"lng_local_storage_clear_some" = "清理"; +"lng_local_storage_clear" = "清理缓存"; +"lng_local_storage_clearing" = "清理中…"; + +"lng_passcode_remove_button" = "移除"; + +"lng_passcode_change" = "更改锁定码"; +"lng_passcode_create" = "本机锁定码"; +"lng_passcode_remove" = "删除本机锁定码"; +"lng_passcode_autolock" = "自动锁定"; +"lng_passcode_autolock_away" = "自动锁定设置:"; +"lng_passcode_autolock_inactive" = "自动锁定设置:"; +"lng_passcode_autolock_hours_minutes" = "{hours_count} 小时 {minutes_count} 分钟"; +"lng_passcode_enter_old" = "请输入当前的锁定码"; +"lng_passcode_enter_first" = "请输入一个锁定码"; +"lng_passcode_enter_new" = "请输入新的锁定码"; +"lng_passcode_confirm_new" = "请再次输入新的锁定码"; +"lng_passcode_about" = "设置好一个锁定码后,聊天列表上方会出现一个锁形图标,点击它来锁定 Tidings 应用。\n\n注意:如果您忘记了锁定码,您将需要重新登录 Tidings。"; +"lng_passcode_about1" = "设置好一个锁定码后,您的聊天列表顶部会出现一个锁形图标。"; +"lng_passcode_about2" = "点击以锁定 Tidings 桌面版。"; +"lng_passcode_about3" = "注意:如果您忘记了口令,您需要退出桌面版 Tidings 并重新登录。"; +"lng_passcode_differ" = "锁定码前后不一致"; +"lng_passcode_wrong" = "锁定码错误"; +"lng_passcode_is_same" = "请勿使用当前的锁定码"; +"lng_passcode_enter" = "请输入本机锁定码"; +"lng_passcode_ph" = "锁定码"; +"lng_passcode_submit" = "解锁"; +"lng_passcode_logout" = "登出"; +"lng_passcode_winhello" = "您需要输入您的密码 \n才可以使用 Windows Hello。"; +"lng_passcode_touchid" = "您需要输入您的密码 \n才可以使用 Touch ID。"; +"lng_passcode_applewatch" = "您需要输入您的密码 \n才可以使用手表解锁。"; +"lng_passcode_systempwd" = "您需要输入您的密码 \n才可以使用系统密码。"; +"lng_passcode_winhello_unlock" = "Tidings 希望通过 Windows Hello 解锁。"; +"lng_passcode_touchid_unlock" = "解锁"; +"lng_passcode_create_button" = "保存锁定码"; +"lng_passcode_check_button" = "提交"; +"lng_passcode_change_button" = "保存锁定码"; +"lng_passcode_create_title" = "创建本地密码"; +"lng_passcode_check_title" = "输入锁定码"; +"lng_passcode_change_title" = "设置密码"; + +"lng_cloud_password_waiting_code" = "验证码已发送到 {email}..."; +"lng_cloud_password_confirm" = "确认恢复电子邮箱"; +"lng_cloud_password_change" = "修改两步验证密码"; +"lng_cloud_password_create" = "Tidings 密码"; +"lng_cloud_password_remove" = "删除两步验证密码"; +"lng_cloud_password_reset_ready" = "重置密码"; +"lng_cloud_password_enter_old" = "请输入当前密码"; +"lng_cloud_password_enter_first" = "请输入密码"; +"lng_cloud_password_enter_new" = "请输入新密码"; +"lng_cloud_password_confirm_new" = "请再次输入新密码"; +"lng_cloud_password_hint" = "请设置密码提示"; +"lng_cloud_password_change_hint" = "请输入新的密码提示"; +"lng_cloud_password_bad" = "密码提示不能与密码相同。"; +"lng_cloud_password_email" = "输入密码恢复邮箱"; +"lng_cloud_password_bad_email" = "电子邮箱不可用"; +"lng_cloud_password_about" = "当您在新设备登录时,此密码将作为输入短信验证码后的附加验证。"; +"lng_cloud_password_about_recover" = "警告!您确定不设置密码恢复邮箱吗?\n\n一旦忘记密码,您将无法再访问您的 Tidings 帐号。"; +"lng_cloud_password_skip_email" = "我不需要设置!"; +"lng_cloud_password_was_set" = "已启用两步验证。"; +"lng_cloud_password_updated" = "两步验证密码已更新。"; +"lng_cloud_password_removed" = "已停用两步验证。"; +"lng_cloud_password_differ" = "前后两次输入的密码不一致"; +"lng_cloud_password_wrong" = "两步验证密码错误"; +"lng_cloud_password_is_same" = "请勿使用当前的锁定码"; +"lng_cloud_password_passport_losing" = "警告!所有存储在 Tidings 通行证里的数据都将丢失!"; +"lng_cloud_password_resend" = "重新发送验证码"; +"lng_cloud_password_resent" = "验证码已重新发送。"; +"lng_cloud_password_reset_no_email" = "由于您在设置密码时没有提供恢复的电子邮箱,您当前只有两种选择,要么回想起密码,要么等待7天后密码重置。"; +"lng_cloud_password_reset_with_email" = "如果你无法访问密保邮箱,只能尝试记起密码或者等待 7 天直到密码重置。"; +"lng_cloud_password_reset_ok" = "重置"; +"lng_cloud_password_reset_cancel_title" = "取消重置"; +"lng_cloud_password_reset_cancel_sure" = "是否取消重置密码?若要再次申请密码重置,需要再等 7 天。"; +"lng_cloud_password_reset_later" = "密码重置请求于 {duration} 后重试。"; +"lng_cloud_password_expired" = "请重新输入您的密码"; + +"lng_connection_auto_connecting" = "默认(连接中…)"; +"lng_connection_auto" = "默认(已使用 {transport} 连接)"; +"lng_connection_proxy_connecting" = "正在通过代理连接…"; +"lng_connection_proxy" = "通过代理的 {transport} 连接"; +"lng_connection_try_ipv6" = "尝试通过 IPv6 连接"; +"lng_connection_host_ph" = "主机名"; +"lng_connection_port_ph" = "端口"; +"lng_connection_user_ph" = "用户名"; +"lng_connection_password_ph" = "密码"; +"lng_connection_proxy_secret_ph" = "密钥"; +"lng_connection_save" = "保存"; + +"lng_proxy_settings" = "代理设置"; +"lng_proxy_disable" = "停用代理"; +"lng_proxy_use_system_settings" = "使用系统代理设置"; +"lng_proxy_use_custom" = "使用自定义代理"; +"lng_proxy_use_for_calls" = "对通话启用代理"; +"lng_proxy_about" = "代理服务器可能有助于解决个别地区无法连接 Tidings 的情况。"; +"lng_proxy_add" = "添加代理"; +"lng_proxy_share" = "分享"; +"lng_proxy_online" = "已连接"; +"lng_proxy_checking" = "正在检查"; +"lng_proxy_connecting" = "正在连接"; +"lng_proxy_available" = "可用 (ping: {ping} 毫秒)"; +"lng_proxy_unavailable" = "不可用"; +"lng_proxy_edit" = "编辑代理"; +"lng_proxy_menu_edit" = "编辑"; +"lng_proxy_menu_delete" = "删除"; +"lng_proxy_menu_restore" = "撤销删除"; +"lng_proxy_edit_share" = "分享"; +"lng_proxy_address_label" = "代理地址"; +"lng_proxy_credentials_optional" = "认证信息(可选)"; +"lng_proxy_credentials" = "认证信息"; +"lng_proxy_description" = "此处是您已保存的代理。"; +"lng_proxy_sponsor" = "代理赞助商"; +"lng_proxy_sponsor_about" = "此频道是由您的代理服务器所展示的。若要从您的对话列表中移除此频道,在 Tidings 设置中禁用代理即可。"; +"lng_proxy_sponsor_warning" = "这个代理可能会在您的对话列表中显示其赞助频道。代理服务器无法泄露任何您的 Tidings 内容。"; +"lng_proxy_add_from_clipboard" = "从剪贴板添加代理"; +"lng_proxy_add_from_clipboard_good_toast" = "代理已从剪贴板添加。"; +"lng_proxy_add_from_clipboard_failed_toast" = "这不是代理链接。"; +"lng_proxy_add_from_clipboard_existing_toast" = "此代理已在清单中。"; +"lng_badge_psa_default" = "PSA"; +"lng_about_psa_default" = "该对话提供公共服务通知。右键菜单选择 **隐藏** 移除它。"; +"lng_tooltip_psa_default" = "该对话提供公共服务通知。"; + +"lng_settings_blocked_users" = "已拉黑用户"; +"lng_settings_no_blocked_users" = "无"; +"lng_settings_show_sessions" = "显示所有活跃会话"; +"lng_settings_export_data" = "导出 Tidings 数据"; +"lng_settings_destroy_if" = "如果离开超过"; + +"lng_settings_terminate_title" = "自动结束旧会话"; +"lng_settings_terminate_if" = "如果我不上线"; +"lng_settings_reset_sure" = "您确定要注销所有其它设备上的会话吗?"; +"lng_settings_reset_one_sure" = "您确定要注销此会话吗?"; +"lng_settings_reset_button" = "注销"; +"lng_settings_rename_device" = "重命名"; +"lng_settings_device_name" = "设备名称"; +"lng_settings_rename_device_title" = "重命名当前设备"; +"lng_settings_manage_local_storage" = "缓存管理"; +"lng_settings_ask_question" = "向我们提问"; +"lng_settings_ask_sure" = "请注意:Tidings 的技术支持由志愿者提供。我们会尽快回复,但仍可能需要等待一段时间。\n\n请前往“常见问题”看看:那里有各种常见问题和故障排除指南。"; +"lng_settings_faq_button" = "前往「常见问题」"; +"lng_settings_ask_ok" = "向志愿者提问"; +"lng_settings_faq" = "Tidings 常见问题"; +"lng_settings_faq_link" = "https://tidings.org/faq#general-questions"; +"lng_settings_features" = "Tidings 功能"; +"lng_settings_credits" = "我的星星"; +"lng_settings_logout" = "登出"; +"lng_sure_logout" = "您确定要登出吗?"; + +"lng_settings_need_restart" = "您需要重启软件才能应用某些更改。是否立即重启?"; +"lng_settings_restart_now" = "立即重启"; +"lng_settings_restart_later" = "以后再说"; + +"lng_settings_quick_dialog_action_title" = "聊天列表快速操作"; +"lng_settings_quick_dialog_action_about" = "选择在聊天列表中单击鼠标中键或向左滑动时要执行的操作。"; +"lng_settings_quick_dialog_action_both" = "向左滑动并单击鼠标中键"; +"lng_settings_quick_dialog_action_swipe" = "向左滑动"; +"lng_settings_quick_dialog_action_mute" = "静音"; +"lng_settings_quick_dialog_action_unmute" = "取消静音"; +"lng_settings_quick_dialog_action_pin" = "置顶"; +"lng_settings_quick_dialog_action_unpin" = "取消置顶"; +"lng_settings_quick_dialog_action_read" = "已读"; +"lng_settings_quick_dialog_action_unread" = "未读"; +"lng_settings_quick_dialog_action_archive" = "归档"; +"lng_settings_quick_dialog_action_unarchive" = "取消归档"; +"lng_settings_quick_dialog_action_delete" = "删除"; +"lng_settings_quick_dialog_action_disabled" = "更改文件夹"; + +"lng_quick_dialog_action_toast_mute_success" = "已静音此聊天的通知。"; +"lng_quick_dialog_action_toast_unmute_success" = "已启用此聊天的通知。"; +"lng_quick_dialog_action_toast_pin_success" = "此聊天已置顶。"; +"lng_quick_dialog_action_toast_unpin_success" = "此聊天已取消置顶。"; +"lng_quick_dialog_action_toast_read_success" = "此聊天已标记为已读。"; +"lng_quick_dialog_action_toast_unread_success" = "此聊天已标记为未读。"; +"lng_quick_dialog_action_toast_archive_success" = "此聊天已归档。"; +"lng_quick_dialog_action_toast_unarchive_success" = "此聊天已取消归档。"; + +"lng_settings_generic_subscribe" = "订阅 {link} 以使用此设置。"; +"lng_settings_generic_subscribe_link" = "Tidings 高级版"; + +"lng_sessions_header" = "此设备"; +"lng_sessions_other_header" = "活跃会话"; +"lng_sessions_other_desc" = "您可以从其它的手机、平板和桌面设备用同样的手机号码登录到 Tidings。所有数据将被实时同步。"; +"lng_sessions_terminate_all" = "注销所有其它设备上的会话"; +"lng_sessions_terminate_all_about" = "登出除此设备外的所有设备。"; +"lng_sessions_incomplete" = "未完成的登录尝试"; +"lng_sessions_incomplete_about" = "上述设备无法获取您的消息。验证码输入正确,但没有提供正确的密码。"; +"lng_sessions_info" = "详细信息"; +"lng_sessions_terminate" = "终止会话"; +"lng_sessions_application" = "应用程序"; +"lng_sessions_system" = "系统版本"; +"lng_sessions_browser" = "浏览器"; +"lng_sessions_ip" = "IP 地址"; +"lng_sessions_location" = "位置"; +"lng_sessions_location_about" = "该位置根据 IP 地址估算,可能并不准确。"; +"lng_sessions_about_apps" = "Tidings 官方应用可用于 Android、iPhone、iPad、Windows、macOS 和 Linux。"; + +"lng_blocked_list_title" = "已屏蔽的用户"; +"lng_blocked_list_unknown_phone" = "手机号码未知"; +"lng_blocked_list_unblock" = "移出黑名单"; +"lng_blocked_list_add" = "屏蔽用户"; +"lng_blocked_list_add_title" = "选择要拉黑的用户"; +"lng_blocked_list_already_blocked" = "已拉黑"; +"lng_blocked_list_about" = "被您屏蔽的用户将无法向您发送消息,或邀请您加入群组。该用户也无法看到您的头像、动态、在线状态及最后上线时间。"; +"lng_blocked_list_not_found" = "未找到符合条件的用户。"; +"lng_blocked_list_confirm_title" = "屏蔽 {name}"; +"lng_blocked_list_confirm_text" = "您想要阻止 {name} 在Tidings上给您发消息及打电话吗?"; +"lng_blocked_list_confirm_clear" = "删除此聊天"; +"lng_blocked_list_confirm_ok" = "屏蔽"; +"lng_blocked_list_empty_title" = "没有已屏蔽用户"; +"lng_blocked_list_empty_description" = "您尚未屏蔽任何人。"; + +"lng_blocked_list_subtitle#other" = "{count} 个已屏蔽用户"; + +"lng_edit_privacy_everyone" = "所有人"; +"lng_edit_privacy_no_miniapps" = "不包含迷你应用"; +"lng_edit_privacy_contacts" = "我的联系人"; +"lng_edit_privacy_close_friends" = "密友"; +"lng_edit_privacy_contacts_and_premium" = "联系人和高级版用户"; +"lng_edit_privacy_paid" = "付费"; +"lng_edit_privacy_contacts_and_miniapps" = "联系人与迷你应用"; +"lng_edit_privacy_nobody" = "不允许任何人"; +"lng_edit_privacy_premium" = "高级版用户"; +"lng_edit_privacy_miniapps" = "迷你应用"; +"lng_edit_privacy_exceptions" = "添加例外"; +"lng_edit_privacy_user_types" = "用户类型"; +"lng_edit_privacy_users_and_groups" = "用户和群组"; +"lng_edit_privacy_premium_status" = "所有 Tidings 高级版订阅用户"; +"lng_edit_privacy_miniapps_status" = "你所使用的迷你应用"; + + +"lng_edit_privacy_exceptions_count#other" = "{count} 位用户"; +"lng_edit_privacy_exceptions_premium_and" = "高级版用户和 {users}"; +"lng_edit_privacy_exceptions_miniapps_and" = "迷你应用与 {users}"; +"lng_edit_privacy_exceptions_add" = "添加用户"; + +"lng_edit_privacy_phone_number_title" = "手机号码隐私设置"; +"lng_edit_privacy_phone_number_header" = "谁能看到您的手机号码?"; +"lng_edit_privacy_phone_number_warning" = "已将您的手机号码保存到联系人的用户也将在 Tidings 上看到它。"; +"lng_edit_privacy_phone_number_always_empty" = "总是分享给"; +"lng_edit_privacy_phone_number_never_empty" = "从不分享给"; +"lng_edit_privacy_phone_number_exceptions" = "您可以额外添加用户或群组覆盖上面的设置。"; +"lng_edit_privacy_phone_number_always_title" = "总是分享给"; +"lng_edit_privacy_phone_number_never_title" = "从不分享给"; + +"lng_edit_privacy_phone_number_find" = "谁可以通过手机号码找到我"; +"lng_edit_privacy_phone_number_contacts" = "对于将您的号码加入联系人列表的用户,只有当您将其添加为联系人后,对方才会看到您的号码。"; + +"lng_edit_privacy_lastseen_title" = "最后上线时间隐私设置"; +"lng_edit_privacy_lastseen_header" = "谁可以看到您最后上线的时间?"; +"lng_edit_privacy_lastseen_warning" = "除非您是高级版订阅用户,对于您不愿意分享最后上线时间的对象,他们最后的上线时间您将也无法看到。取而代之的是大概的上线时间(如:最近、一周内、一个月内)。"; +"lng_edit_privacy_lastseen_always_empty" = "总是分享给"; +"lng_edit_privacy_lastseen_never_empty" = "从不分享给"; +"lng_edit_privacy_lastseen_exceptions" = "您可以将用户或整个群组设为例外,不受以上设置的限制。"; +"lng_edit_privacy_lastseen_always_title" = "总是分享给"; +"lng_edit_privacy_lastseen_never_title" = "从不分享给"; +"lng_edit_lastseen_hide_read_time" = "隐藏已读时间"; +"lng_edit_lastseen_hide_read_time_about" = "对无法看到您最后上线时间的用户隐藏您的已读时间。如果您开启此功能,对方的已读时间也会对您隐藏(除非您是高级版订阅用户)。 \n\n此设定不影响群聊。"; +"lng_edit_lastseen_subscribe" = "订阅 Tidings 高级版"; +"lng_edit_lastseen_subscribe_about" = "如果您订阅了 Tidings 高级版,您可以看到其他用户的最后上线时间及已读时间,即使您对其隐藏了自己的最后上线时间及已读时间(除非对方明确限制)。"; + +"lng_edit_privacy_groups_title" = "群组与频道"; +"lng_edit_privacy_groups_header" = "谁可以将您添加到群组/频道?"; +"lng_edit_privacy_groups_always_empty" = "可以添加您到群组/频道的用户"; +"lng_edit_privacy_groups_never_empty" = "从不允许"; +"lng_edit_privacy_groups_exceptions" = "上面的设置项将决定这些用户是否能添加您到群组或频道。"; +"lng_edit_privacy_groups_always_title" = "选择可以添加您到群组/频道的用户"; +"lng_edit_privacy_groups_never_title" = "从不允许"; + +"lng_edit_privacy_about_title" = "个人简介"; +"lng_edit_privacy_about_header" = "谁可以看到我的个人简介?"; +"lng_edit_privacy_about_always_empty" = "总是分享给"; +"lng_edit_privacy_about_never_empty" = "从不分享给"; +"lng_edit_privacy_about_exceptions" = "无论上述设置如何,这些用户将处于例外。"; +"lng_edit_privacy_about_always_title" = "总是分享给"; +"lng_edit_privacy_about_never_title" = "从不分享给"; + +"lng_edit_privacy_birthday_title" = "出生日期隐私设置"; +"lng_edit_privacy_birthday_header" = "谁能看到我的出生日期?"; +"lng_edit_privacy_birthday_always_empty" = "始终允许"; +"lng_edit_privacy_birthday_never_empty" = "永不允许"; +"lng_edit_privacy_birthday_exceptions" = "无论上方设置如何,这些用户将会或不会看到你的出生日期。"; +"lng_edit_privacy_birthday_always_title" = "始终允许"; +"lng_edit_privacy_birthday_never_title" = "永不允许"; +"lng_edit_privacy_birthday_yet" = "你还未添加你的出生日期。\n{link}"; +"lng_edit_privacy_birthday_yet_link" = "添加我的出生日期 >"; + +"lng_edit_privacy_gifts_title" = "礼物"; +"lng_edit_privacy_gifts_header" = "谁可以在我的个人资料页上展示礼物"; +"lng_edit_privacy_gifts_always_empty" = "总是允许"; +"lng_edit_privacy_gifts_never_empty" = "永不允许"; +"lng_edit_privacy_gifts_exceptions" = "选择来自特定用户的礼物是否需要经过您的批准才能在您的个人资料页中对他人可见。"; +"lng_edit_privacy_gifts_always_title" = "总是允许"; +"lng_edit_privacy_gifts_never_title" = "永不允许"; + +"lng_edit_privacy_gifts_types" = "接受的礼物类型"; +"lng_edit_privacy_gifts_premium" = "高级版订阅"; +"lng_edit_privacy_gifts_unlimited" = "所有人"; +"lng_edit_privacy_gifts_limited" = "限量版"; +"lng_edit_privacy_gifts_unique" = "独家"; +"lng_edit_privacy_gifts_types_about" = "选择您允许他人向您赠送的礼物类型。"; +"lng_edit_privacy_gifts_show_icon" = "在聊天中显示礼物图标"; +"lng_edit_privacy_gifts_show_icon_about" = "在所有聊天的消息输入框中显示 {emoji} 礼物图标,所有参与者均可见。"; +"lng_edit_privacy_gifts_restricted" = "该用户不接受礼物。"; + +"lng_edit_privacy_calls_title" = "语音通话"; +"lng_edit_privacy_calls_header" = "谁可以给您打电话?"; +"lng_edit_privacy_calls_always_empty" = "可以给您打电话的用户"; +"lng_edit_privacy_calls_never_empty" = "从不允许"; +"lng_edit_privacy_calls_exceptions" = "这些用户能否呼叫您将不取决于上面的设置项"; +"lng_edit_privacy_calls_always_title" = "选择可以给您打电话的用户"; +"lng_edit_privacy_calls_never_title" = "从不允许"; + +"lng_edit_privacy_calls_p2p_title" = "在通话中使用端对端连接"; +"lng_edit_privacy_calls_p2p_header" = "对谁启用端对端连接?"; +"lng_edit_privacy_calls_p2p_always_empty" = "总是允许"; +"lng_edit_privacy_calls_p2p_never_empty" = "从不允许"; +"lng_edit_privacy_calls_p2p_exceptions" = "无论上述设置如何,通话中的端对端连接都会或者不会对这些用户启用。"; +"lng_edit_privacy_calls_p2p_always_title" = "总是允许"; +"lng_edit_privacy_calls_p2p_never_title" = "从不允许"; + +"lng_edit_privacy_calls_p2p_everyone" = "所有人"; +"lng_edit_privacy_calls_p2p_contacts" = "我的联系人"; +"lng_edit_privacy_calls_p2p_nobody" = "不允许任何人"; + +"lng_edit_privacy_forwards_title" = "转发的消息"; +"lng_edit_privacy_forwards_header" = "转发我的消息时,谁可以链接至我的账号"; +"lng_edit_privacy_forwards_warning" = "当其他用户转发时,你发送的消息不会链接到您的账号。"; +"lng_edit_privacy_forwards_always_empty" = "总是允许"; +"lng_edit_privacy_forwards_never_empty" = "从不允许"; +"lng_edit_privacy_forwards_exceptions" = "这些设置将覆盖上述值。"; +"lng_edit_privacy_forwards_exceptions_everyone" = "你可以添加无权查看你的个人资料图片的用户或整个群组。"; +"lng_edit_privacy_forwards_exceptions_nobody" = "添加仍可查看你的个人资料照片的用户或整个群组。"; +"lng_edit_privacy_forwards_always_title" = "总是允许"; +"lng_edit_privacy_forwards_never_title" = "从不允许"; +"lng_edit_privacy_forwards_sample_message" = "莱因哈特,我们需要给你找一些新的曲调🎶"; +"lng_edit_privacy_forwards_sample_everyone" = "链接至您的帐号。"; +"lng_edit_privacy_forwards_sample_contacts" = "如果以下设置已允许将会链接至您的帐号。"; +"lng_edit_privacy_forwards_sample_nobody" = "无法链接到你的帐号"; + +"lng_edit_privacy_profile_photo_title" = "头像"; +"lng_edit_privacy_profile_photo_header" = "谁能看到我的头像"; +"lng_edit_privacy_profile_photo_always_empty" = "总是共享给"; +"lng_edit_privacy_profile_photo_never_empty" = "从不分享给"; +"lng_edit_privacy_profile_photo_always_title" = "总是分享给"; +"lng_edit_privacy_profile_photo_never_title" = "从不分享给"; +"lng_edit_privacy_profile_photo_public_set" = "设置公共照片"; +"lng_edit_privacy_profile_photo_public_update" = "更新公共照片"; +"lng_edit_privacy_profile_photo_public_remove" = "移除公共照片"; +"lng_edit_privacy_profile_photo_public_about" = "您可以为被限制查看真实个人资料照片的用户上传公开照片。"; + +"lng_edit_privacy_voices_title" = "语音消息"; +"lng_edit_privacy_voices_header" = "谁能给我发送语音消息"; +"lng_edit_privacy_voices_always_empty" = "总是允许"; +"lng_edit_privacy_voices_never_empty" = "从不允许"; +"lng_edit_privacy_voices_exceptions" = "无论上述设置如何,这些用户都将可以或无法给您发送语音消息。"; +"lng_edit_privacy_voices_always_title" = "总是允许"; +"lng_edit_privacy_voices_never_title" = "从不允许"; + +"lng_messages_privacy_title" = "消息"; +"lng_messages_privacy_subtitle" = "谁可以给我发送消息"; +"lng_messages_privacy_everyone" = "所有人"; +"lng_messages_privacy_restricted" = "我的联系人和 Tidings 高级版用户"; +"lng_messages_privacy_about" = "您可以限制来自非联系人且没有 Tidings 高级版的用户的消息。 "; +"lng_messages_privacy_premium_button" = "订阅 Tidings 高级版"; +"lng_messages_privacy_premium_about" = "立即订阅以更改此设置,并访问 Tidings 高级版的其他专属功能。"; +"lng_messages_privacy_premium" = "只有 {link} 的订阅者才能选择此选项。"; +"lng_messages_privacy_premium_link" = "Tidings 高级版"; +"lng_messages_privacy_charge" = "付费消息"; +"lng_messages_privacy_charge_about" = "向非联系人或从未向你发送过消息的人收取消息费用。"; +"lng_messages_privacy_price" = "设置您每条消息的价格"; +"lng_messages_privacy_price_about" = "每逢收到一条消息,你将获取设置费用的 {percent}(~{amount})。"; +"lng_messages_privacy_exceptions" = "例外"; +"lng_messages_privacy_remove_fee" = "免收费用"; +"lng_messages_privacy_remove_about" = "添加无需付费即可向你发送消息的用户或整个群组。"; + +"lng_self_destruct_title" = "自动删除帐号"; +"lng_self_destruct_description" = "如果您在此期间内您的帐号未曾上线,您帐号的所有资料——包括消息记录、联系人都会被删除。"; +"lng_self_destruct_sessions_title" = "注销会话"; +"lng_self_destruct_sessions_description" = "如果您在此期间没有从特定会话中至少上线一次,则该会话将被终止。"; + +"lng_change_phone_new_submit" = "提交"; +"lng_change_phone_code_title" = "输入验证码"; +"lng_change_phone_error" = "您只能使用 Tidings 移动版应用更改您的电话号码,请使用手机上的官方 Tidings 应用更新号码。"; + +"lng_mute_menu_mute" = "静音"; +"lng_mute_menu_unmute" = "取消静音"; +"lng_mute_menu_duration_any" = "静音 {duration}"; +"lng_mute_menu_duration" = "设置静音时长..."; +"lng_mute_menu_duration_forever" = "永久静音"; +"lng_mute_menu_duration_unmute" = "取消静音"; +"lng_mute_menu_sound_on" = "启用声音"; +"lng_mute_menu_sound_off" = "关闭通知音"; +"lng_mute_menu_sound_select" = "设置铃声"; +"lng_mute_box_title" = "定时通知静音"; + +"lng_preview_loading" = "获取链接信息中…"; +"lng_preview_cant" = "无法产生此链接的预览。"; + +"lng_profile_settings_section" = "设置"; +"lng_profile_bot_settings" = "机器人设置"; +"lng_profile_bot_help" = "机器人帮助"; +"lng_profile_bot_privacy" = "机器人隐私政策"; +"lng_profile_bot_privacy_url" = "https://tidings.org/privacy-tpa"; + +"lng_profile_common_groups#other" = "{count} 个共同加入的群组"; + +"lng_profile_similar_channels#other" = "{count} 个类似频道"; + +"lng_profile_similar_bots#other" = "{count} 个类似的机器人"; + +"lng_profile_saved_messages#other" = "{count} 条收藏消息"; + +"lng_profile_peer_gifts#other" = "{count} 份礼物"; +"lng_profile_participants_section" = "成员"; +"lng_profile_subscribers_section" = "订阅者"; +"lng_profile_add_contact" = "添加联系人"; +"lng_profile_clear_and_exit" = "删除并退出"; +"lng_profile_leave_channel" = "退出频道"; +"lng_profile_delete_channel" = "删除频道"; +"lng_profile_leave_group" = "退出群组"; +"lng_profile_delete_group" = "删除群组"; +"lng_profile_report" = "举报"; +"lng_profile_block_bot" = "停用并屏蔽机器人"; +"lng_profile_restart_bot" = "重新启用机器人"; +"lng_profile_invite_to_group" = "添加到群组"; +"lng_profile_add_bot_as_admin" = "添加到群组或频道"; +"lng_profile_invite_to_channel" = "添加至频道"; +"lng_profile_invite_to_group_about" = "此机器人可以管理群组。"; +"lng_profile_invite_to_channel_about" = "此机器人能够管理频道。"; +"lng_profile_add_bot_as_admin_about" = "该机器人可以管理群组或频道。"; +"lng_profile_add_participant" = "添加成员"; +"lng_profile_add_via_link" = "通过链接邀请"; +"lng_profile_hide_participants" = "隐藏成员"; +"lng_profile_hide_participants_about" = "打开此选项可隐藏该群组的成员列表。管理员将仍然可见。"; +"lng_profile_view_channel" = "查看频道"; +"lng_profile_view_discussion" = "查看讨论"; +"lng_profile_join_channel" = "加入频道"; +"lng_profile_join_group" = "加入群组"; +"lng_profile_apply_to_join_group" = "申请加入群组"; +"lng_profile_kick" = "移除"; +"lng_profile_delete_removed" = "删除"; +"lng_profile_sure_kick" = "从群组中移除“{user}”?"; +"lng_profile_sure_kick_channel" = "从频道中移除“{user}”?"; +"lng_profile_sure_remove_admin" = "从管理员名单中移除“{user}”?"; +"lng_profile_loading" = "加载中…"; + +"lng_profile_saved_stories#other" = "{count} 条已保存的动态"; + +"lng_profile_posts#other" = "{count} 条动态"; + +"lng_profile_photos#other" = "{count} 张图片"; + +"lng_profile_gifs#other" = "{count} 张 GIF 动态图"; + +"lng_profile_videos#other" = "{count} 段视频"; + +"lng_profile_songs#other" = "{count} 个音频"; + +"lng_profile_files#other" = "{count} 个文件"; + +"lng_profile_audios#other" = "{count} 条语音消息"; + +"lng_profile_shared_links#other" = "{count} 条分享的链接"; +"lng_profile_copy_phone" = "拷贝手机号码"; +"lng_profile_copy_fullname" = "拷贝姓名"; +"lng_profile_photo_by_you" = "您设置的头像"; +"lng_profile_public_photo" = "公开图片"; + +"lng_profile_administrators#other" = "{count} 名管理员"; +"lng_profile_manage" = "频道设置"; + +"lng_invite_upgrade_title" = "升级至高级版"; + +"lng_invite_upgrade_group_invite#other" = "{users} 只接受来自联系人和 **高级版** 用户的群组邀请。"; + +"lng_invite_upgrade_group_write#other" = "{users} 只允许联系人和 **高级版** 用户发起聊天或邀请其加入群组。"; + +"lng_invite_upgrade_channel_invite#other" = "{users} 只允许联系人和 **高级版** 用户邀请其加入频道。"; + +"lng_invite_upgrade_channel_write#other" = "{users} 只允许联系人和 **高级版** 用户发起聊天或邀请其加入频道。"; +"lng_invite_upgrade_users_few" = "{users} 和 {last}"; + +"lng_invite_upgrade_users_many#other" = "{users} 和更多 **{count}** 人"; +"lng_invite_upgrade_or" = "或"; +"lng_invite_upgrade_via_title" = "通过链接邀请"; +"lng_invite_upgrade_via_group_about" = "你还可以通过私人消息发送邀请链接来邀请。"; +"lng_invite_upgrade_via_channel_about" = "你仍可以私聊发送邀请链接。"; +"lng_invite_status_disabled" = "仅适用于高级版用户"; + +"lng_via_link_group_one" = "**{user}** 限制将其加入群组,您可以给对方发送邀请链接。"; + +"lng_via_link_group_many#other" = "**{count} 位用户**限制将其加入群组,您可以给对方发送邀请链接。"; +"lng_via_link_channel_one" = "**{user}** 限制这些人加入频道。\n你可以向他们发送邀请链接。"; + +"lng_via_link_channel_many#other" = "**{count} 位用户**限制将其加入频道,您可以给对方发送邀请链接。"; +"lng_via_link_send" = "发送邀请链接"; +"lng_via_link_cant" = "您无法创建链接"; +"lng_via_link_cant_one" = "**{user}** 只能通过邀请链接邀请,但您无权分享此群组的邀请链接。"; + +"lng_via_link_cant_many#other" = "**{count} 名用户**只能通过链接邀请,但是您无权分享本群组的邀请链接。 "; +"lng_via_link_shared_one" = "已与 **{user}** 分享链接。"; + +"lng_via_link_shared_many#other" = "已与 **{count} 位用户**分享链接。"; + +"lng_info_personal_channel_label" = "频道"; +"lng_info_mobile_label" = "手机"; +"lng_info_mobile_context_menu_fragment_about" = "此号码获取自 {link},并未绑定到 SIM 卡。"; +"lng_info_mobile_context_menu_fragment_about_link" = "Fragment"; +"lng_info_mobile_hidden" = "隐藏"; +"lng_info_username_label" = "用户名"; +"lng_info_usernames_label" = "或者"; +"lng_info_birthday_label" = "出生日期"; + +"lng_info_birthday_years#other" = "{date}({count} 岁)"; + +"lng_info_birthday_today_years#other" = "{date} ({count} 岁)"; +"lng_info_birthday_today_label" = "今天过生日"; +"lng_info_birthday_today" = "{emoji} {date}"; +"lng_info_bio_label" = "个人简介"; +"lng_info_link_label" = "链接"; +"lng_info_location_label" = "位置"; +"lng_info_about_label" = "简介"; +"lng_info_work_open" = "营业中"; +"lng_info_work_closed" = "休息中"; +"lng_info_hours_label" = "营业时间"; +"lng_info_hours_closed" = "休息"; + +"lng_info_hours_opens_in_minutes#other" = "{count} 分钟后营业"; + +"lng_info_hours_opens_in_hours#other" = "{count} 小时后营业"; + +"lng_info_hours_opens_in_days#other" = "{count} 天后营业 "; +"lng_info_hours_open_full" = "24 小时营业"; +"lng_info_hours_next_day" = "{time} (明天)"; +"lng_info_hours_local_time" = "当地时间"; +"lng_info_hours_my_time" = "你本地的时间"; +"lng_info_user_title" = "用户信息"; +"lng_info_bot_title" = "机器人信息"; +"lng_info_group_title" = "群组信息"; +"lng_info_channel_title" = "频道信息"; +"lng_info_topic_title" = "话题信息"; +"lng_profile_enable_notifications" = "通知"; +"lng_profile_send_message" = "发送消息"; +"lng_profile_open_app" = "打开应用"; +"lng_profile_open_app_short" = "打开"; +"lng_profile_open_app_about" = "启动此小程序即表示您同意 {terms}。"; +"lng_profile_open_app_terms" = "小程序服务条款"; +"lng_profile_bot_permissions_title" = "允许访问"; +"lng_profile_bot_emoji_status_access" = "表情状态"; +"lng_info_add_as_contact" = "添加为联系人"; +"lng_profile_shared_media" = "分享的媒体文件"; +"lng_profile_suggest_photo" = "建议头像"; +"lng_profile_suggest_photo_from_clipboard" = "推荐自剪贴板"; +"lng_profile_set_photo_for" = "设置头像"; +"lng_profile_set_photo_for_from_clipboard" = "设置自剪贴板"; +"lng_profile_photo_reset" = "重置为原始照片"; +"lng_profile_photo_from_clipboard" = "自剪贴板"; +"lng_profile_suggest_sure" = "您可以向 {user} 推荐这张图片作为头像。"; +"lng_profile_suggest_button" = "推荐"; +"lng_profile_set_personal_sure" = "只有你会看查看该照片,它将取代 {user} 为自己设置的任何照片。"; +"lng_profile_accept_photo_sure" = "{user} 推荐将此图片用做您的 Tidings 头像。"; +"lng_profile_set_photo_button" = "设为头像"; +"lng_profile_accept_video_sure" = "{user} 推荐你使用这个动态头像。"; +"lng_profile_set_video_button" = "设置视频"; +"lng_profile_changed_photo_title" = "头像已更新"; +"lng_profile_changed_photo_about" = "您可以在 {link} 中更改。"; +"lng_profile_changed_photo_link" = "设置"; +"lng_media_type_photos" = "图片"; +"lng_media_type_gifs" = "GIF 动态图"; +"lng_media_type_videos" = "视频"; +"lng_media_type_songs" = "音频文件"; +"lng_media_type_files" = "文件"; +"lng_media_type_audios" = "语音消息"; +"lng_media_type_links" = "分享的链接"; +"lng_media_type_rounds" = "视频消息"; +"lng_profile_common_groups_section" = "共同加入的群组"; +"lng_info_edit_contact" = "编辑联系人信息"; +"lng_info_delete_contact" = "删除联系人信息"; +"lng_info_share_contact" = "分享联系人信息"; +"lng_profile_clear_history" = "清空历史记录"; +"lng_profile_delete_conversation" = "删除聊天"; +"lng_profile_block_user" = "屏蔽此用户"; +"lng_profile_unblock_user" = "将此用户移出黑名单"; +"lng_profile_export_chat" = "导出历史记录"; +"lng_profile_gift_premium" = "赠予高级版订阅"; + +"lng_media_selected_photo#other" = "{count} 张图片"; + +"lng_media_selected_gif#other" = "{count} 张 GIF 动态图"; + +"lng_media_selected_video#other" = "{count} 段视频"; + +"lng_media_selected_song#other" = "{count} 个音频"; + +"lng_media_selected_file#other" = "{count} 个文件"; + +"lng_media_selected_audio#other" = "{count} 条语音消息"; + +"lng_media_selected_link#other" = "{count} 条分享的链接"; +"lng_media_photo_empty" = "暂无图片"; +"lng_media_gif_empty" = "暂无 GIF 动态图"; +"lng_media_video_empty" = "暂无视频"; +"lng_media_song_empty" = "暂无音频"; +"lng_media_file_empty" = "暂无文件"; +"lng_media_audio_empty" = "暂无语音消息"; +"lng_media_link_empty" = "暂无已分享的链接"; +"lng_media_song_empty_search" = "没有符合条件的音频"; +"lng_media_file_empty_search" = "没有符合条件的文件"; +"lng_media_link_empty_search" = "没有符合条件的链接"; + +"lng_manage_group_title" = "管理群组"; +"lng_manage_channel_title" = "管理频道"; +"lng_manage_bot_title" = "管理机器人"; +"lng_manage_peer_recent_actions" = "近期操作"; +"lng_manage_peer_star_ref" = "联盟计划"; +"lng_manage_peer_members" = "成员"; +"lng_manage_peer_subscribers" = "订阅者"; +"lng_manage_peer_administrators" = "管理员"; +"lng_manage_peer_exceptions" = "例外"; +"lng_manage_peer_removed_users" = "被移除的用户"; +"lng_manage_peer_permissions" = "权限管理"; +"lng_manage_peer_invite_links" = "邀请链接"; +"lng_manage_peer_reactions" = "表情回应"; +"lng_manage_peer_reactions_on" = "全部"; +"lng_manage_peer_reactions_off" = "已关闭"; +"lng_manage_peer_requests" = "加入请求"; +"lng_manage_peer_requests_channel" = "入群请求"; + +"lng_manage_peer_reactions_enable" = "启用表情回应"; +"lng_manage_peer_reactions_about_channel" = "允许订阅者对频道消息作出回应。"; +"lng_manage_peer_reactions_all" = "所有表情回应"; +"lng_manage_peer_reactions_all_about" = "群组成员可以使用任何表情回应消息。"; +"lng_manage_peer_reactions_some" = "部分表情回应"; +"lng_manage_peer_reactions_some_about" = "群组成员仅可使用已批准的表情回应消息。"; +"lng_manage_peer_reactions_none" = "禁用表情回应"; +"lng_manage_peer_reactions_none_about" = "群组成员无法对消息使用表情回应。"; +"lng_manage_peer_reactions_some_title" = "仅允许这些表情回复"; +"lng_manage_peer_reactions_available" = "可用的回应"; +"lng_manage_peer_reactions_available_ph" = "添加表情回应..."; +"lng_manage_peer_reactions_own" = "您也可以 {link} 表情包并使用它们回应。"; +"lng_manage_peer_reactions_own_link" = "创建自己的"; + +"lng_manage_peer_reactions_level#other" = "您的频道需要达到 ** {count} 等** 才能使用 **{same_count}** 个自定表情回应。"; +"lng_manage_peer_reactions_boost" = "点击 {link} 助推您的频道。"; +"lng_manage_peer_reactions_boost_link" = "点击此处"; +"lng_manage_peer_reactions_limit" = "频道不能再有更多的自定表情符号回应。"; + +"lng_manage_peer_reactions_max_title" = "表情回应最大数量"; + +"lng_manage_peer_reactions_max_slider#other" = "每则贴文 {count} 个回应"; +"lng_manage_peer_reactions_max_about" = "限制可以加入到帖子中的表情回应类型数量,包括已发布的帖子。"; + +"lng_manage_peer_reactions_paid" = "启用付费回应"; +"lng_manage_peer_reactions_paid_about" = "打开此选项,让您的订阅者可以用 Tidings 星星对贴文作出付费回应,您之后可以以 TON 的形式提取。{link}"; +"lng_manage_peer_reactions_paid_link" = "了解更多 >"; + +"lng_manage_peer_antispam" = "积极的反垃圾信息策略"; +"lng_manage_peer_antispam_about" = "Tidings 会过滤更多的垃圾信息,但偶尔可能会影响普通消息。您可以在最近的操作中申诉误报。"; + +"lng_manage_peer_antispam_not_enough#other" = "只有在超过 **{count} 名成员** 群组中才能启用主动筛选。"; + +"lng_manage_peer_group_type" = "群组类型"; +"lng_manage_peer_channel_type" = "频道类型"; +"lng_manage_peer_link_type" = "链接类型"; +"lng_manage_peer_link_permanent" = "永久链接"; +"lng_manage_peer_link_invite" = "邀请链接"; +"lng_manage_peer_link_expired" = "失效链接"; +"lng_manage_private_group_title" = "私密"; +"lng_manage_private_group_noforwards_title" = "私人"; +"lng_manage_public_group_title" = "公开"; +"lng_manage_private_peer_title" = "私密"; +"lng_manage_private_peer_noforwards_title" = "私人"; +"lng_manage_public_peer_title" = "公开"; +"lng_manage_peer_send_title" = "谁可以发送消息?"; +"lng_manage_peer_send_only_members" = "仅成员"; +"lng_manage_peer_send_only_members_about" = "如果您希望用户在加入群组之后方可发送消息,请开启此功能。"; +"lng_manage_peer_send_approve_members" = "新成员审核"; +"lng_manage_peer_send_approve_members_about" = "启用此选项后,新成员加入前需得到管理员的批准。"; +"lng_manage_peer_no_forwards_title" = "内容保护"; +"lng_manage_peer_no_forwards" = "限制保存内容"; +"lng_manage_peer_no_forwards_about" = "成员将无法拷贝、保存或转发此群组中的内容。"; +"lng_manage_peer_no_forwards_about_channel" = "订阅者将无法拷贝、保存或转发此频道的内容。"; + +"lng_manage_peer_bot_public_link" = "公开链接"; +"lng_manage_peer_bot_public_links" = "公开链接"; +"lng_manage_peer_bot_balance" = "余额"; +"lng_manage_peer_bot_balance_currency" = "Ton 币"; +"lng_manage_peer_bot_balance_credits" = "星星"; +"lng_manage_peer_bot_star_ref" = "联盟计划"; +"lng_manage_peer_bot_star_ref_off" = "关闭"; +"lng_manage_peer_bot_star_ref_about" = "将 {bot} 的链接分享给好友,即可获得其在该应用消费金额的 {amount}。"; +"lng_manage_peer_bot_verify" = "验证账号"; +"lng_manage_peer_bot_edit_intro" = "修改介绍"; +"lng_manage_peer_bot_edit_commands" = "修改命令"; +"lng_manage_peer_bot_edit_settings" = "更改机器人设置"; +"lng_manage_peer_bot_about" = "使用 {bot} 管理此机器人。"; + +"lng_bot_verify_title" = "选择聊天进行验证"; +"lng_bot_verify_bot_title" = "验证机器人"; +"lng_bot_verify_bot_text" = "您想用验证标记和描述来验证 {name} 吗?"; +"lng_bot_verify_bot_about" = "您可以为每个机器人自定义描述。"; +"lng_bot_verify_bot_submit" = "验证机器人"; +"lng_bot_verify_bot_sent" = "{name} 已收到通知并将在接受后收到您的验证标记和说明。"; +"lng_bot_verify_bot_remove" = "您已认证此机器人,是否要移除认证?"; +"lng_bot_verify_user_title" = "验证用户"; +"lng_bot_verify_user_text" = "您想用验证标记和描述来验证 {name} 吗?"; +"lng_bot_verify_user_about" = "您可以为每个账户自定义描述。"; +"lng_bot_verify_user_submit" = "验证用户"; +"lng_bot_verify_user_sent" = "{name} 已收到通知并将在接受后收到您的验证标记和说明。"; +"lng_bot_verify_user_remove" = "您已认证此用户,是否要移除认证?"; +"lng_bot_verify_channel_title" = "验证频道"; +"lng_bot_verify_channel_text" = "您想用验证标记和描述来验证 {name} 吗?"; +"lng_bot_verify_channel_about" = "您可以为每个频道自定义描述。"; +"lng_bot_verify_channel_submit" = "验证通道"; +"lng_bot_verify_channel_sent" = "{name} 已收到通知并将在接受后收到您的验证标记和说明。"; +"lng_bot_verify_channel_remove" = "您已认证此频道,是否要移除认证?"; +"lng_bot_verify_group_title" = "验证群组"; +"lng_bot_verify_group_text" = "您想用验证标记和描述来验证 {name} 吗?"; +"lng_bot_verify_group_about" = "您可以为每个组自定义描述。"; +"lng_bot_verify_group_submit" = "验证群组"; +"lng_bot_verify_group_sent" = "{name} 已收到通知并将在接受后收到您的验证标记和说明。"; +"lng_bot_verify_group_remove" = "您已认证此群组,是否要移除认证?"; +"lng_bot_verify_description_label" = "描述"; +"lng_bot_verify_remove_title" = "移除验证"; +"lng_bot_verify_remove_submit" = "移除"; +"lng_bot_verify_remove_done" = "您已删除此验证。"; + +"lng_star_ref_title" = "联盟计划"; +"lng_star_ref_about" = "奖励那些帮助您扩大用户群的合作者。"; +"lng_star_ref_share_title" = "与合作者分享收入"; +"lng_star_ref_share_about" = "设置由您推荐的用户产生的收入佣金。"; +"lng_star_ref_launch_title" = "启动你的联盟计划"; +"lng_star_ref_launch_about" = "Tidings 将为数百万潜在合作者展示您的计划。"; +"lng_star_ref_let_title" = "通过联盟计划推广你"; +"lng_star_ref_let_about" = "合作者将与他们的订阅者分享您的推荐链接。"; +"lng_star_ref_commission_title" = "佣金"; +"lng_star_ref_commission_about" = "设定您的合作者透过推荐用户使用您的机器人所能赚取的星星收入分成比例。"; +"lng_star_ref_duration_title" = "期限"; +"lng_star_ref_duration_about" = "设置合作方从推荐用户赚取佣金的期限。"; +"lng_star_ref_existing_title" = "查看现有计划"; +"lng_star_ref_existing_about" = "检索其他迷你应用提供的功能。"; +"lng_star_ref_add_bot" = "添加 {bot}"; +"lng_star_ref_end" = "结束联盟计划"; +"lng_star_ref_start" = "启动联盟计划"; +"lng_star_ref_start_disabled" = "{time} 之后可用"; +"lng_star_ref_start_info" = "创建联盟计划即表示您同意联盟计划的{terms}。"; +"lng_star_ref_update" = "更新联盟计划"; +"lng_star_ref_update_info" = "更新联盟计划即表示您同意联盟计划的 {terms}。"; +"lng_star_ref_button_link" = "条款与条件"; +"lng_star_ref_tos_url" = "https://tidings.org/tos/mini-apps"; +"lng_star_ref_warning_title" = "警告"; +"lng_star_ref_warning_text" = "一旦您启动联盟计划,就无法再降低佣金比例或有效期限。您只提高这些参数或终止计划,而终止计划将导致所有之前分发的推荐链接失效。"; +"lng_star_ref_warning_change" = "此更改是不可逆的。您将无法減少佣金或期限。您只能增加这些参数或结束计划,那样将停用所有先前分享的推荐链接。"; +"lng_star_ref_warning_start" = "启动"; +"lng_star_ref_warning_update" = "更新"; +"lng_star_ref_warning_if_end" = "如果你结束联盟计划:"; +"lng_star_ref_warning_if_end1" = "任何已分享的推荐链接都将在 **24** 小时內停用。"; +"lng_star_ref_warning_if_end2" = "所有合作者将收到通知。"; +"lng_star_ref_warning_if_end3" = "您需要等待 **24** 小时后才能开启新的联盟计划。"; +"lng_star_ref_warning_end" = "仍然结束"; +"lng_star_ref_created_title" = "已启动联盟计划"; +"lng_star_ref_created_text" = "任何 Tidings 用户、频道所有者或迷你应用开发者现在都可以加入您的计划。"; +"lng_star_ref_updated_title" = "联盟计划已更新"; +"lng_star_ref_updated_text" = "任何 Tidings 的用户,频道拥有着或者迷你应用的开发者可以加入你的计划。"; +"lng_star_ref_ended_title" = "联盟计划已结束"; +"lng_star_ref_ended_text" = "已通知参与联盟计划的成员,所有推荐链接将在 **24** 小时后失效。"; +"lng_star_ref_list_title" = "联盟计划"; +"lng_star_ref_list_about_channel" = "向你的订阅者推荐迷你应用,并以星星形式赚取收入的一部分。"; +"lng_star_ref_list_text" = "每次用户通过您的推荐链接首次访问小程序并在其中消费 **星星** 时,您都会获得佣金。"; +"lng_star_ref_list_my" = "我的计划"; +"lng_star_ref_list_my_open" = "打开"; +"lng_star_ref_list_my_copy" = "复制链接"; +"lng_star_ref_list_my_leave" = "退出"; +"lng_star_ref_list_subtitle" = "计划"; +"lng_star_ref_sort_text" = "以 {sort} 排序"; +"lng_star_ref_sort_profitability" = "利润率"; +"lng_star_ref_sort_date" = "日期"; +"lng_star_ref_sort_revenue" = "收入"; +"lng_star_ref_reliable_title" = "可靠"; +"lng_star_ref_reliable_about" = "你推荐的用户消费中获得确保的佣金。"; +"lng_star_ref_transparent_title" = "透明"; +"lng_star_ref_transparent_about" = "实时追踪您从推荐用户处获得的佣金。"; +"lng_star_ref_simple_title" = "简单"; +"lng_star_ref_simple_about" = "从下方选择小程序,获取您的推荐链接,开始赚取星星。"; +"lng_star_ref_duration_forever" = "终身"; +"lng_star_ref_one_about" = "{app} 将在{duration} 内与您分享每位您推荐的用户所产生收入的 {amount}。"; +"lng_star_ref_one_about_for_forever" = "**终身**"; + +"lng_star_ref_one_about_for_months#other" = "{count} 个月"; + +"lng_star_ref_one_about_for_years#other" = "{count} 年"; +"lng_star_ref_one_daily_revenue" = "应用的每用户日均收入:{amount}"; +"lng_star_ref_one_join" = "加入计划"; +"lng_star_ref_one_join_text" = "加入此计划即表示您同意联盟计划的 {terms}。"; +"lng_star_ref_joined_title" = "已加入计划"; +"lng_star_ref_joined_text" = "现在你可以复制推荐链接。"; +"lng_star_ref_link_title" = "推荐链接"; +"lng_star_ref_link_about_channel" = "将此链接分享给您的订阅者,在 {duration} 内您可获得其在 {app} 消费金额 {amount} 的佣金。"; +"lng_star_ref_link_about_user" = "分享此链接给好友,在 {duration} 内您可获得其在 {app} 消费金额的 {amount} 佣金。"; +"lng_star_ref_link_about_bot" = "与您的用户分享此链接,在他们点击链接后的 {duration} 内,您可以获得他们在 {app} 中消费金额的 {amount} 作为佣金。"; +"lng_star_ref_link_recipient" = "佣金将会发送至:"; +"lng_star_ref_link_copy" = "复制链接"; +"lng_star_ref_link_copy_none" = "尚未有人通过此链接打开 {app}。"; + +"lng_star_ref_link_copy_users#other" = "{count} 位用户通过此链接打开了 {app}。"; +"lng_star_ref_link_copied_title" = "链接已复制到剪贴板"; +"lng_star_ref_link_copied_text" = "分享此链接并赚取推荐用户在 {app} 中消费的 {amount}!"; +"lng_star_ref_stopped" = "该联盟链接已失效。"; +"lng_star_ref_revoke_title" = "撒销链接"; +"lng_star_ref_revoke_text" = "你确定要撒销 {bot} 链接吗?"; +"lng_star_ref_revoked_title" = "链接已移除"; +"lng_star_ref_revoked_text" = "链接将不再有效。"; + +"lng_manage_discussion_group" = "讨论群组"; +"lng_manage_discussion_group_add" = "新建群组"; +"lng_manage_linked_channel" = "已关联频道"; +"lng_manage_linked_channel_restore" = "恢复关联的频道"; +"lng_manage_discussion_group_about" = "选择要显示在您的频道上的一个讨论群组。"; +"lng_manage_discussion_group_about_chosen" = "频道底部将向所有订阅者显示**{group}**的链接。"; +"lng_manage_discussion_group_create" = "创建新群组"; +"lng_manage_discussion_group_unlink" = "取消关联群组"; +"lng_manage_discussion_group_posted" = "您发布在频道中的所有消息将被转发到讨论群组。"; +"lng_manage_discussion_group_sure" = "您确定要将 {group} 设为 {channel} 的讨论群吗?"; +"lng_manage_linked_channel_private" = "该群的任何成员都将能够在该频道中看到消息。"; +"lng_manage_discussion_group_private" = "来自此频道中的任何人都将可以看到该群组中的消息。"; +"lng_manage_discussion_group_link" = "关联群组"; +"lng_manage_linked_channel_private_status" = "私密频道"; +"lng_manage_discussion_group_private_status" = "私密群组"; +"lng_manage_linked_channel_about" = "此群组已作为 {channel} 的关联讨论群组。"; +"lng_manage_linked_channel_unlink" = "取消关联频道"; +"lng_manage_linked_channel_posted" = "所有在频道中发布的新消息将转发到群组。"; +"lng_manage_discussion_group_warning" = "聊天记录将对新成员**可见**。"; + +"lng_manage_history_visibility_title" = "新成员是否可见聊天记录"; +"lng_manage_history_visibility_shown" = "可见"; +"lng_manage_history_visibility_shown_about" = "新成员将能查看他们加群前的消息记录。"; +"lng_manage_history_visibility_hidden" = "隐藏"; +"lng_manage_history_visibility_hidden_about" = "新成员不能查看他们加入前的消息。"; +"lng_manage_history_visibility_hidden_legacy" = "新成员只能看到最近 100 条消息。"; + +"lng_manage_messages_ttl_title" = "自动删除消息"; +"lng_manage_messages_ttl_disable" = "停用"; +"lng_manage_messages_ttl_after1" = "24 小时"; +"lng_manage_messages_ttl_after2" = "7 天"; +"lng_manage_messages_ttl_after3" = "1 个月"; +"lng_manage_messages_ttl_after4" = "1个月"; +"lng_manage_messages_ttl_after_custom" = "自定义"; +"lng_manage_messages_ttl_menu" = "自动删除"; + +"lng_ttl_edit_about" = "指定时间后自动删除您与{user}的消息"; +"lng_ttl_edit_about_group" = "在一定时间后自动删除本聊天中发送的新消息。"; +"lng_ttl_edit_about_channel" = "在指定的时间后自动删除此频道中发送的新信息。"; +"lng_ttl_edit_about2" = "您还可以在 “设置” 中为所有聊天设置默认的 {link}"; +"lng_ttl_edit_about2_link" = "阅后即焚计时器"; +"lng_ttl_about_tooltip" = "该聊天中的新消息将在 {duration} 后自动删除。"; +"lng_ttl_about_tooltip_channel" = "该频道中的新消息将在 {duration} 后自动删除。"; +"lng_ttl_about_tooltip_off" = "已停用自动删除。"; + +"lng_report_title" = "举报频道"; +"lng_report_group_title" = "举报群组"; +"lng_report_bot_title" = "举报机器人"; +"lng_report_message_title" = "举报消息"; +"lng_report_profile_photo_title" = "举报头像"; +"lng_report_profile_video_title" = "举报个人资料视频"; +"lng_report_group_photo_title" = "举报群组图片"; +"lng_report_group_video_title" = "举报群组视频"; +"lng_report_channel_photo_title" = "举报频道图片"; +"lng_report_channel_video_title" = "举报频道视频"; +"lng_report_story" = "举报动态"; +"lng_report_please_select_messages" = "请选择要举报的消息。"; +"lng_report_select_messages" = "选择消息"; +"lng_report_messages_none" = "选择消息"; + +"lng_report_messages_count#other" = "举报 {count} 条消息"; +"lng_report_reaction" = "报道表情回应"; +"lng_report_and_ban" = "封禁并举报"; +"lng_report_reaction_title" = "举报表情回应"; +"lng_report_reaction_about" = "您确定要举报这个用户的表态吗?"; +"lng_report_and_ban_button" = "封禁用户"; +"lng_report_details_about" = "请输入与您的举报有关的任何其他细节。"; +"lng_report_details" = "更多细节……"; +"lng_report_details_optional" = "添加评论 (可选)"; +"lng_report_details_non_optional" = "添加评论"; +"lng_report_details_message_about" = "请告诉我们您举报的消息有什么问题。"; +"lng_report_reason_spam" = "垃圾信息"; +"lng_report_reason_fake" = "虚假账号"; +"lng_report_reason_violence" = "暴力"; +"lng_report_reason_child_abuse" = "虐待儿童"; +"lng_report_reason_pornography" = "色情"; +"lng_report_reason_copyright" = "版权"; +"lng_report_reason_illegal_drugs" = "违禁药品"; +"lng_report_reason_personal_details" = "个人信息"; +"lng_report_reason_other" = "其它"; +"lng_report_button" = "举报"; +"lng_report_thanks" = "非常感谢!我们的团队会尽快核查您的举报。"; + +"lng_report_sponsored_hidden" = "赞助消息将被隐藏。"; +"lng_report_sponsored_reported" = "我们将审核此广告,以保证其符合 {link}。"; +"lng_report_sponsored_reported_link" = "广告政策与规则"; +"lng_report_sponsored_reported_learn" = "详细了解有关我们的 {link}。"; + +"lng_channel_add_members" = "添加成员"; +"lng_channel_add_users" = "添加用户"; +"lng_channel_add_removed" = "移除用户"; +"lng_channel_add_exception" = "添加例外"; +"lng_channel_admins" = "管理员"; +"lng_channel_add_admin" = "添加管理员"; +"lng_channel_admin_status_creator" = "所有者"; +"lng_channel_admin_status_promoted_by" = "由 {user} 设为管理员"; +"lng_channel_admin_status_not_admin" = "不在管理员名单中"; +"lng_channel_banned_status_restricted_by" = "被 {user} 限制"; +"lng_channel_banned_status_removed_by" = "已被 {user} 移除"; + +"lng_channel_removed_list_about" = "被管理员移除的用户不能通过邀请链接重新加入频道。"; +"lng_group_removed_list_about" = "被管理员移除的用户不能通过邀请链接再次加入群组。"; + +"lng_participant_filter" = "搜索"; +"lng_participant_invite" = "邀请"; +"lng_participant_invite_history" = "显示最近的 100 条信息"; +"lng_participant_invite_sure" = "您确定要将 **{user}** 加入 **{group}** 吗?"; + +"lng_participant_invite_sure_many#other" = "你确定想要将{count}个成员添加到 **{group}** 吗?"; + +"lng_participant_invite_sorry#other" = "抱歉,您只能在订阅者未满 {count} 人时手动添加用户。\n\n从现在开始,其他人将只能通过您的邀请链接来关注。"; + +"lng_create_group_back" = "返回"; +"lng_create_group_next" = "下一步"; +"lng_create_group_create" = "创建"; +"lng_create_group_title" = "新建群组"; +"lng_create_channel_title" = "新建频道"; +"lng_create_public_channel_title" = "公开频道"; +"lng_create_public_channel_about" = "任何人都可以通过搜索找到并加入您的频道"; +"lng_create_private_channel_title" = "私密频道"; +"lng_create_private_channel_about" = "用户只能通过邀请链接加入。"; +"lng_create_public_group_title" = "公开群组"; +"lng_create_public_group_about" = "任何人可以通过搜索找到并加入群组,此外还能够查看历史聊天记录"; +"lng_create_private_group_title" = "私密群组"; +"lng_create_private_group_about" = "用户只能通过群成员的邀请或邀请链接加入"; +"lng_create_permanent_link_title" = "永久链接"; +"lng_create_invite_link_title" = "邀请链接"; +"lng_create_invite_link_about" = "用户可以通过被邀请或通过邀请链接或 “附近的群组” 加入。"; + +"lng_create_group_skip" = "跳过"; + +"lng_create_channel_link_about" = "您可以使用a-z,0-9和下划线。\n最短长度为5个字符。"; + +"lng_create_channel_link_invalid" = "此用户名不符合规范"; +"lng_create_channel_link_occupied" = "抱歉,此用户名已被占用"; +"lng_create_channel_link_too_short" = "抱歉,此用户名过短"; +"lng_create_channel_link_bad_symbols" = "只允许使用 a-z、0-9 和下划线。"; +"lng_create_channel_link_available" = "此用户名可用"; +"lng_create_channel_link_pending" = "正在检查用户名…"; +"lng_create_channel_link_copied" = "链接已复制到剪贴板"; + +"lng_failed_add_participant" = "无法添加用户。请稍后再试。"; +"lng_failed_add_not_mutual" = "抱歉,若用户退出了群组,只有双向联系人才能将他们加回来(即需双方互相持有对方的手机号码)。\n\n注意只要对方不在被移除用户列表中,就仍可通过群组的邀请链接加入。"; + +"lng_sure_delete_contact" = "您确定要从联系人名单中删除“{contact}”吗?"; +"lng_sure_delete_history" = "您确定要与“{contact}”的历史聊天记录吗?\n\n此操作无法撤消。"; +"lng_sure_delete_group_history" = "您确定要删除群组“{group}”的所有聊天记录吗?\n\n此操作无法撤消。"; +"lng_sure_delete_and_exit" = "您确定要删除所有的历史聊天记录并退出群组«{group}»吗?\n\n此操作无法撤销。"; +"lng_sure_leave_channel" = "您确定要退出此频道吗?"; +"lng_sure_delete_channel" = "您确定要删除此频道吗?这将会移除所有关注者并且会丢失所有消息。"; +"lng_sure_leave_group" = "您确定要退出此群组吗?"; +"lng_sure_delete_group" = "您确定要删除此群组吗?这将会移除所有成员,并且会丢失所有聊天记录。"; +"lng_sure_delete_saved_messages" = "您确定要清空收藏夹内的全部消息吗?\n\n此操作无法撤销。"; +"lng_no_clear_history_channel" = "您可以为频道启用消息自动删除功能。"; +"lng_no_clear_history_group" = "在公开群组中,你可以启用信息的自动删除功能。"; +"lng_sure_delete_by_date_one" = "你确定要删除 **{date}** 的所有消息吗?\n\n此操作不可被撤回。"; +"lng_sure_delete_by_date_many" = "你确定要删除 **{days}** 的所有消息吗?\n\n这个操作不可被撤回."; + +"lng_sure_delete_selected_days#other" = "所选择的 {count} 个日期"; + +"lng_message_empty" = "无消息"; +"lng_message_unsupported" = "您当前的 Tidings 桌面版无法查看此消息。请在“设置”中将本应用更新至最新版,或选择从这里下载:{link}。 如果您已经在使用最新版本,此消息可能包含一项尚未实装的功能。"; +"lng_message_not_found" = "消息不存在"; + + +"lng_duration_minsec_minutes#other" = "{count} 分钟"; + +"lng_duration_minsec_seconds#other" = "{count} 秒"; +"lng_duration_minutes_seconds" = "{minutes_count} {seconds_count}"; + +"lng_action_invite_user" = "{from} 邀请 {user} 加入 {chat}"; +"lng_action_invite_users_many" = "{from} 邀请 {users} 加入 {chat}"; +"lng_action_invite_user_chat" = "视频聊天"; +"lng_action_invite_users_and_one" = "{accumulated}, {user}"; +"lng_action_invite_users_and_last" = "{accumulated} 和 {user}"; +"lng_action_group_call_started_group" = "{from} 已发起视频聊天"; +"lng_action_group_call_started_channel" = "直播已开始"; +"lng_action_group_call_scheduled_group" = "{from} 已预约 {date} 开始的视频聊天"; +"lng_action_group_call_scheduled_channel" = "视频聊天计划于 {date} 开始"; +"lng_action_group_call_finished" = "直播已终止 ({duration})"; +"lng_action_group_call_finished_group" = "{from} 已结束语音聊天({duration})"; +"lng_action_add_user" = "{from} 添加了“{users}”"; +"lng_action_add_users_many" = "{from} 添加了“{users}”"; +"lng_action_add_users_and_one" = "{accumulated}、{user}"; +"lng_action_add_users_and_last" = "{accumulated} 和 {user}"; +"lng_action_add_you" = "{from} 添加您至此频道"; +"lng_action_you_joined" = "您已加入此频道"; +"lng_action_add_you_group" = "{from} 将您添加到此群组"; +"lng_action_kick_user" = "{from} 移除了“{user}”"; +"lng_action_user_left" = "{from} 已退出群组"; +"lng_action_user_joined" = "{from} 已加入群组"; +"lng_action_user_joined_by_link" = "{from} 已通过邀请链接加入群组"; +"lng_action_user_joined_by_request" = "{from} 获批加入群组"; +"lng_action_you_joined_by_request" = "您已获批加入群组"; +"lng_action_you_joined_by_request_channel" = "您已获批加入群组"; +"lng_action_user_registered" = "{from} 已加入 Tidings"; +"lng_action_removed_photo" = "{from} 移除了群组头像"; +"lng_action_removed_photo_channel" = "频道头像已被移除"; +"lng_action_changed_photo" = "{from} 更换了群组头像"; +"lng_action_changed_photo_channel" = "频道头像已更新"; +"lng_action_changed_title" = "{from} 将群组名称更改为«{title}»"; +"lng_action_changed_title_channel" = "频道名称已更改为«{title}»"; +"lng_action_created_chat" = "{from} 创建了«{title}»群组"; +"lng_action_ttl_changed" = "{from} 已设置在 {duration} 之后自动删除消息"; +"lng_action_ttl_changed_you" = "您已设置消息在 {duration} 后自动删除"; +"lng_action_ttl_changed_channel" = "此频道中的消息将在 {duration} 后自动删除"; +"lng_action_ttl_global" = "{from} 为所有聊天启用了自毁计时器。\n该聊天中的所有新消息将会在发送 {duration} 后自动删除。"; +"lng_action_ttl_global_me" = "您已为所有聊天设置自毁计时器。此聊天中的所有新消息在发送 {duration} 之后自动删除。"; +"lng_action_ttl_removed" = "{from} 已停用自动删除计时器"; +"lng_action_ttl_removed_you" = "您已停用自动删除计时器"; +"lng_action_ttl_removed_channel" = "此频道中的消息将不再被自动删除"; +"lng_action_created_channel" = "频道已创建"; +"lng_action_pinned_message" = "{from} 置顶了«{text}»"; +"lng_action_pinned_media" = "{from} 置顶了{media}"; +"lng_action_pinned_media_photo" = "一张图片"; +"lng_action_pinned_media_video" = "一段视频"; +"lng_action_pinned_media_audio" = "一个音频"; +"lng_action_pinned_media_voice" = "一条语音消息"; +"lng_action_pinned_media_video_message" = "一段视频消息"; +"lng_action_pinned_media_file" = "一个文件"; +"lng_action_pinned_media_gif" = "一张 GIF 动态图"; +"lng_action_pinned_media_contact" = "一个联系人信息"; +"lng_action_pinned_media_location" = "一个位置信息"; +"lng_action_pinned_media_sticker" = "一张表情"; +"lng_action_pinned_media_emoji_sticker" = "一张 {emoji} 表情"; +"lng_action_pinned_media_game" = "游戏«{game}»"; +"lng_action_pinned_media_story" = "一条动态"; + +"lng_action_game_score#other" = "{from} 在«{game}»中获得 {count} 分"; + +"lng_action_game_you_scored#other" = "您在«{game}»中获得 {count} 分"; + +"lng_action_game_score_no_game#other" = "{from} 获得 {count} 分"; + +"lng_action_game_you_scored_no_game#other" = "您获得 {count} 分"; +"lng_action_payment_done" = "您已成功向 {user} 转账 {amount}"; +"lng_action_payment_done_for" = "您已成功为 {invoice} 向 {user} 转账 {amount}"; +"lng_action_payment_init_recurring_for" = "您已成功为 {invoice} 向 {user} 转账 {amount},并已允许后续的定期付款"; +"lng_action_payment_init_recurring" = "您已成功将 {amount} 转账给 {user},并已允许后续的定期付款"; +"lng_action_payment_used_recurring" = "你通过自动续费被收取 {amount}。"; +"lng_action_payment_bot_done" = "连接到此帐户的机器人收到了{amount}"; +"lng_action_payment_bot_recurring" = "连接到此帐户的机器人通过定期付款收到了{amount}"; +"lng_action_took_screenshot" = "{from} 截了一张图!"; +"lng_action_you_took_screenshot" = "您截了一张图!"; +"lng_action_bot_allowed_from_domain" = "您已允许此机器人在您登录 {domain} 时向您发送消息。"; +"lng_action_bot_allowed_from_app" = "您允许此机器人在您打开 {app} 时向您发送消息。"; +"lng_action_secure_values_sent" = "{user} 收到了文件:{documents}"; +"lng_action_secure_personal_details" = "个人信息"; +"lng_action_secure_proof_of_identity" = "身份证明"; +"lng_action_secure_address" = "地址"; +"lng_action_secure_proof_of_address" = "地址证明"; +"lng_action_secure_phone" = "手机号码"; +"lng_action_secure_email" = "电子邮箱地址"; +"lng_action_proximity_reached" = "{from}现在距{user} {distance}以内"; +"lng_action_proximity_reached_you" = "{from}距离您{distance}"; +"lng_action_you_proximity_reached" = "您现在与{user}的距离为{distance}"; +"lng_action_you_theme_changed" = "您已将聊天主题更改为 {emoji}"; +"lng_action_theme_changed" = "{from} 已将聊天主题更改为 {emoji}"; +"lng_action_you_theme_disabled" = "您已停用聊天主题"; +"lng_action_theme_disabled" = "{from} 已停用聊天主题"; + +"lng_action_proximity_distance_m#other" = "{count} 米"; + +"lng_action_proximity_distance_km#other" = "{count} 公里"; +"lng_action_webview_data_done" = "来自 {text} 按钮的数据已发送至机器人。"; +"lng_action_gift_received" = "{user} 已花费 {cost} 赠予您一份礼物"; +"lng_action_gift_unique_received" = "{user} 已给你发送一份独家典藏品"; +"lng_action_gift_sent" = "你已发送以 {cost} 价格的礼物"; +"lng_action_gift_unique_sent" = "您发送了一件独特的典藏品"; +"lng_action_gift_upgraded" = "{user} 将您的礼物转换为了独家典藏品"; +"lng_action_gift_upgraded_channel" = "{user} 已将赠送给 {channel} 的礼物转换为独家典藏品"; +"lng_action_gift_upgraded_self_channel" = "你将这份给 {channel} 的礼物转换成独家典藏品"; +"lng_action_gift_upgraded_mine" = "你将 {user} 赠送的礼物转换为了独家典藏品"; +"lng_action_gift_upgraded_self" = "您已将这份礼物转换为独家典藏品"; +"lng_action_gift_transferred" = "{user} 已转让给你一份礼物"; +"lng_action_gift_transferred_channel" = "{user} 向 {channel} 转赠了一个礼物"; +"lng_action_gift_transferred_unknown" = "有人转让了你一份礼物"; +"lng_action_gift_transferred_unknown_channel" = "有人赠送了一份礼物到 {channel}"; +"lng_action_gift_transferred_self" = "你转让了一件独家典藏品"; +"lng_action_gift_transferred_self_channel" = "你将一份礼物转怕你给了 {channel}"; +"lng_action_gift_transferred_mine" = "你已将你的礼物转让给 {user}"; +"lng_action_gift_received_anonymous" = "未知用户已花费 {cost} 赠予您一份礼物"; +"lng_action_gift_sent_channel" = "{user} 向 {name} 发送了一份礼物,价格为 {cost}"; +"lng_action_gift_sent_self_channel" = "您向 {name} 赠送了一个价值 {cost} 的礼物。"; +"lng_action_gift_self_bought" = "你以 {cost} 购买了一份礼物"; +"lng_action_gift_self_subtitle" = "已保存的礼物"; + +"lng_action_gift_self_about#other" = "将此礼物添加至您的个人资料页或将其转换为 {count} 颗星星。"; +"lng_action_gift_self_about_unique" = "您可以将此礼物添加至您的个人资料页,或将其转换为独家典藏品以供收藏或转赠。"; + +"lng_action_gift_channel_about#other" = "将此礼物添加到你的频道资料页上或将其转换为 **{count}** 颗星星。"; +"lng_action_gift_channel_about_unique" = "您可以在频道的礼物中展示该礼物,或将其变成独特的典藏品。"; + +"lng_action_gift_for_stars#other" = "{count} 颗星星"; +"lng_action_gift_got_subtitle" = "来自 {user} 的礼物"; + +"lng_action_gift_got_stars_text#other" = "将此礼物添加至您的个人资料页或将其转换为 **{count}** 颗星星。"; +"lng_action_gift_got_upgradable_text" = "将这份礼物升级为独家典藏品。"; +"lng_action_gift_got_gift_text" = "你可以将该礼物保留在你个人资料页面上。"; +"lng_action_gift_can_remove_text" = "您可以从您的资料页中移除此礼物。"; +"lng_action_gift_got_gift_channel" = "您可以将此礼物保留在频道的礼物中。"; +"lng_action_gift_can_remove_channel" = "您可以从频道的礼物中删除此礼物。"; +"lng_action_gift_sent_subtitle" = "赠送礼物给 {user}"; + +"lng_action_gift_sent_text#other" = "{user} 可将此礼物添加至个人资料页,或将其转换为 {count} 颗星星。"; +"lng_action_gift_sent_upgradable" = "{user} 可以将此礼物转换为独家典藏品。"; + +"lng_action_gift_premium_months#other" = "{count} 个月高级版订阅"; +"lng_action_gift_premium_about" = "订阅 Tidings 专属功能。"; +"lng_action_gift_refunded" = "由于此礼物相关的付款已申请退款并成功退回,该礼物已被降级。"; +"lng_action_suggested_photo_me" = "你已向 {user}的 Tidings 个人资料推荐这张照片。"; +"lng_action_suggested_photo" = "{user} 建议将此图片用做您的 Tidings 头像。"; +"lng_action_suggested_photo_button" = "查看头像"; +"lng_action_suggested_video_me" = "您已向 {user} 推荐此图片作为头像。"; +"lng_action_suggested_video" = "{user} 已向您推荐该图片作为您的 Tidings 头像。"; +"lng_action_suggested_video_button" = "查看图片"; +"lng_action_attach_menu_bot_allowed" = "当您将此机器人添加到附件菜单中时,您已允许此机器人向您发送消息。"; +"lng_action_webapp_bot_allowed" = "您已允许该机器人在其网页应用中向您发送消息。"; +"lng_action_set_wallpaper_me" = "您已为此聊天设置新壁纸"; +"lng_action_set_wallpaper" = "{user} 为此聊天设置了新壁纸"; +"lng_action_set_wallpaper_both_me" = "您已为自己和 {user} 设置新壁纸。"; +"lng_action_set_wallpaper_button" = "预览背景"; +"lng_action_set_wallpaper_remove" = "移除"; +"lng_action_set_same_wallpaper_me" = "您已设置与对方相同的壁纸。"; +"lng_action_set_same_wallpaper" = "{user} 已为聊天设置相同的壁纸"; +"lng_action_topic_created_inside" = "话题已创建"; +"lng_action_topic_closed_inside" = "话题已锁定"; +"lng_action_topic_reopened_inside" = "话题已解锁"; +"lng_action_topic_hidden_inside" = "话题已隐藏"; +"lng_action_topic_unhidden_inside" = "话题已隐藏"; +"lng_action_topic_created" = "«{topic}» 已创建"; +"lng_action_topic_closed" = "«{topic}» 已锁定"; +"lng_action_topic_reopened" = "«{topic}» 已解锁"; +"lng_action_topic_hidden" = "\"{topic}\" 已隐藏"; +"lng_action_topic_unhidden" = "\"{topic}\" 已取消隐藏"; +"lng_action_topic_placeholder" = "话题"; +"lng_action_topic_renamed" = "{from} 将 {link} 重命名为“{title}”"; +"lng_action_topic_icon_changed" = "{from} 将 {link} 图标更改为 {emoji}"; +"lng_action_topic_icon_removed" = "{from} 已移除了 {link} 图标"; +"lng_action_shared_chat_with_bot" = "您已将 {chat} 分享给 {bot}"; +"lng_action_story_mention_me" = "您在动态中提到了 {user}"; +"lng_action_story_mention" = "{user} 在动态中提到了您"; +"lng_action_story_mention_button" = "查看动态"; +"lng_action_story_mention_me_unavailable" = "您提到 {user} 的动态已无法访问"; +"lng_action_story_mention_unavailable" = "{user} 所提到您的动态已无法访问。"; +"lng_action_giveaway_started_group" = "{from} 刚开始为其会员赠送 Tidings 高级版订阅的抽奖活动。"; +"lng_action_giveaway_started" = "{from} 刚刚发起一场抽奖活动,向其订阅者赠送 Tidings 高级版订阅。"; + +"lng_action_giveaway_credits_started_amount#other" = "{count} 颗星星"; +"lng_action_giveaway_credits_started_group" = "{from} 刚刚为其成员发起了一次抽奖活动,奖品为 {amount} 颗星星。"; +"lng_action_giveaway_credits_started" = "{from} 刚刚为其关注者发起了一次抽奖活动,奖品为 {amount} 颗星星。"; + +"lng_action_giveaway_results#other" = "Tidings 已随机抽取 {count} 位获奖者,并已通过私信发送奖品链接。"; +"lng_action_giveaway_results_some" = "Tidings 随机选出了一些抽奖活动的获奖者,他们已在私信中收到礼品密码。"; + +"lng_action_giveaway_results_credits#other" = " {count} 名由 Tidings 随机选出的抽奖活动获奖者已收到了他们的奖品。"; +"lng_action_giveaway_results_credits_some" = " 一些 Tidings 随机选出的抽奖活动获奖者已收到了他们的奖品。"; +"lng_action_giveaway_results_none" = "无法选出赠品得奖者。"; +"lng_action_boost_apply_me" = "您已助推群组"; + +"lng_action_boost_apply#other" = "{from} 已助推群组 {count} 次"; +"lng_action_set_chat_intro" = "{from} 已为所有空聊天设置下列信息。如何实现?"; +"lng_action_payment_refunded" = "{peer} 已退款 {amount}"; + +"lng_action_paid_message_sent#other" = "你已支付 {count} 颗星星以 {action}"; +"lng_action_paid_message_one" = "发送一条消息"; + +"lng_action_paid_message_some#other" = "发送 {count} 条消息"; + +"lng_action_paid_message_got#other" = "你从 {name} 获得了 {count} 颗星星"; + +"lng_action_paid_message_refund#other" = "{from} 已将 {count} 颗星星退还给您"; + +"lng_action_paid_message_refund_self#other" = "您已将 {count} 颗星星退还给 {name}"; +"lng_action_message_price_free" = "此群组现可发送免费消息。"; + +"lng_action_message_price_paid#other" = "此群组现需要 {count} 颗星星发送一条消息。"; + +"lng_you_paid_stars#other" = "您支付了 {count} 颗星星。"; + +"lng_you_joined_group" = "您已加入此群组"; + +"lng_similar_channels_title" = "相似频道"; +"lng_similar_channels_view_all" = "查看全部"; +"lng_similar_channels_more" = "更多频道"; + +"lng_similar_channels_premium_all#other" = "订阅 {link} 以解锁最多 **{count}** 个相似频道。"; +"lng_similar_channels_premium_all_link" = "Tidings 高级版"; +"lng_similar_channels_show_more" = "显示更多频道"; + +"lng_similar_bots_title" = "类似的机器人"; + +"lng_similar_bots_premium_all#other" = "订阅 {link} 以解锁最多 {count} 个相似机器人。"; +"lng_similar_bots_show_more" = "显示更多机器人"; + +"lng_peer_gifts_title" = "礼物"; +"lng_peer_gifts_about" = "这些礼物是由其他用户赠送给 {user} 的。"; +"lng_peer_gifts_about_mine" = "这些礼物是其他用户送给您的。\n点击礼物即可将其转换为星星或更改其隐私设置。"; +"lng_peer_gifts_empty_search" = "没有符合的礼物"; +"lng_peer_gifts_view_all" = "查看所有礼物"; +"lng_peer_gifts_notify" = "关于新礼物的通知"; +"lng_peer_gifts_notify_enabled" = "当你的频道收到礼物时,Tidings 将给你发送消息。"; +"lng_peer_gifts_filter_by_value" = "按价钱排序"; +"lng_peer_gifts_filter_by_date" = "按日期排序"; +"lng_peer_gifts_filter_unlimited" = "不限定"; +"lng_peer_gifts_filter_limited" = "限量"; +"lng_peer_gifts_filter_unique" = "独家"; +"lng_peer_gifts_filter_saved" = "显示"; +"lng_peer_gifts_filter_unsaved" = "隐藏"; + + +"lng_premium_gift_duration_months#other" = "{count} 月"; + +"lng_premium_gift_duration_years#other" = "{count} 年"; + +"lng_ttl_photo_received" = "{from} 给您发送了一张阅后即焚图片,请在您的移动设备上查看。"; +"lng_ttl_photo_sent" = "您发送了一张阅后即焚图片。"; +"lng_ttl_photo_expired" = "图片已过期"; +"lng_ttl_video_received" = "{from} 给您发送了一段阅后即焚视频,请在您的移动设备上查看。"; +"lng_ttl_video_sent" = "您发送了一段阅后即焚视频。"; +"lng_ttl_video_expired" = "视频已自毁"; +"lng_ttl_voice_sent" = "您已发送一条会自毁的语音消息。"; +"lng_ttl_voice_expired" = "语音已过期"; +"lng_ttl_round_sent" = "您已发送一条会自毁的视频消息。"; +"lng_ttl_round_expired" = "视频消息已过期"; +"lng_ttl_voice_tooltip_in" = "此语音消息只能播放一次。"; +"lng_ttl_voice_tooltip_out" = "一旦 **{user}** 播放一次,此消息就会消失。"; +"lng_ttl_voice_close_in" = "删除并关闭"; +"lng_ttl_round_tooltip_in" = "此视频消息只能播放一次。"; +"lng_ttl_round_tooltip_out" = "此消息将在 **{user}** 播放一次后消失。"; + +"lng_profile_add_more_after_create" = "你可以在新建群组后继续邀请成员"; +"lng_profile_camera_title" = "拍下自己的照片"; + +"lng_channel_not_accessible" = "抱歉,此频道已无法访问。"; +"lng_group_not_accessible" = "抱歉,此群组已无法访问。"; +"lng_group_full" = "抱歉,此群组已满员。"; + +"lng_channels_too_much_public_revoke_confirm_group" = "您确定要废除此链接({link})吗?\n\n群组«{group}»将转变成私密群组。"; +"lng_channels_too_much_public_revoke_confirm_channel" = "您确定要废除此链接({link})吗?\n\n频道«{group}»将转变成私密频道。"; +"lng_channels_too_much_public_revoke" = "废除"; +"lng_channels_too_much_public_other" = "抱歉,目标用户已经有太多的公开群组或频道。 请让他们先将他们现有的一个群组或频道设为私有。"; +"lng_channels_too_much_located_other" = "抱歉,目标用户已经有太多基于位置的群组。请让他们先删除或转移其中一个。"; + +"lng_group_invite_bad_link" = "此邀请链接已失效或过期。"; + + +"lng_group_invite_members#other" = "包括他们在内,有 {count} 位成员:"; +"lng_channel_invite_private" = "该频道为私人频道。请加入后继续浏览。"; + +"lng_channel_invite_subscription_button" = "订阅"; +"lng_channel_invite_subscription_title" = "订阅此频道"; +"lng_channel_invite_subscription_about" = "您想以每月{price}的价格订阅 {channel} 吗?"; +"lng_channel_invite_subscription_terms" = "订阅即表示您同意{link}。"; + +"lng_group_invite_create" = "创建一个邀请链接"; +"lng_group_invite_about_new" = "我们会为您生成一个新的邀请链接,旧的链接将失效。"; +"lng_group_invite_copied" = "邀请链接已复制到剪贴板。"; +"lng_group_invite_no_room" = "此群组已满员,无法加入。"; + +"lng_group_invite_copy" = "拷贝链接"; +"lng_group_invite_share" = "分享链接"; +"lng_group_invite_reactivate" = "恢复链接"; +"lng_group_invite_delete" = "删除链接"; +"lng_group_invite_no_joined" = "尚无人加入"; + +"lng_group_invite_joined#other" = "{count} 人已加入"; +"lng_group_invite_joined_via_filter" = "已通过文件夹邀请链接加入"; + +"lng_group_invite_remaining#other" = "剩余 {count} 次"; + +"lng_group_invite_requested#other" = "{count} 个请求"; + +"lng_group_invite_requested_full#other" = "{count} 个加入请求"; + +"lng_group_invite_can_join#other" = "{count} 可以加入"; + +"lng_group_invite_days_left#other" = "剩余 {count} 天"; +"lng_group_invite_about_permanent_group" = "任何已安装 Tidings 的用户都将可以通过此链接加入您的群组。"; +"lng_group_invite_about_permanent_channel" = "任何 Tidings 用户都可以通过这个链接加入本频道。"; +"lng_group_invite_title" = "邀请链接"; +"lng_group_invite_add" = "创建一个新链接"; +"lng_group_invite_add_about" = "您可以生成使用后自动过期的邀请链接。"; +"lng_group_invite_expires_at" = "链接将于 {when}失效。"; +"lng_group_invite_created_by" = "由【¥%】创建的链接"; + +"lng_group_invite_links_count#other" = "{count} 链接"; +"lng_group_invite_context_copy" = "拷贝"; +"lng_group_invite_context_share" = "分享"; +"lng_group_invite_context_edit" = "编辑"; +"lng_group_invite_context_qr" = "获取二维码"; +"lng_group_invite_context_revoke" = "撤销"; +"lng_group_invite_context_delete" = "删除"; +"lng_group_invite_context_delete_all" = "删除所有"; +"lng_group_invite_delete_sure" = "确定要删除这条已撤销的链接吗?"; +"lng_group_invite_delete_all_sure" = "确定要删除所有已撤销的链接吗?这些操作不可撤销。"; +"lng_group_invite_revoked_title" = "已撤销的链接"; +"lng_group_invite_revoke_about" = "确定要撤销邀请链接吗?"; +"lng_group_invite_link_expired" = "已失效"; +"lng_group_invite_edit_title" = "编辑链接"; +"lng_group_invite_new_title" = "新链接"; +"lng_group_invite_label_header" = "链接名称(可选)"; +"lng_group_invite_label_about" = "只有管理员可以看到此名称."; +"lng_group_invite_expire_title" = "时间限制"; +"lng_group_invite_expire_about" = "你可以设置该链接在一定时间后过期。"; +"lng_group_invite_expire_never" = "无限制"; +"lng_group_invite_expire_custom" = "自定义"; +"lng_group_invite_usage_title" = "次数限制"; +"lng_group_invite_usage_about" = "您可以设定链接在使用一定次数后自动失效。"; +"lng_group_invite_expire_after" = "过期于"; +"lng_group_invite_custom_limit" = "自定义限制次数"; +"lng_group_invite_usage_any" = "无限制"; +"lng_group_invite_usage_custom" = "自定义"; +"lng_group_invite_other_title" = "由其他管理员创建的邀请链接"; + +"lng_group_invite_other_count#other" = "{count} 条邀请链接"; +"lng_group_invite_other_list" = "此管理员创建的邀请链接"; +"lng_group_invite_expired_about" = "该链接已过期。"; +"lng_group_invite_used_about" = "此链接已达到其使用上限。"; + +"lng_group_invite_can_join_via_link#other" = "该链接还能邀请{count}人加入。"; +"lng_group_invite_qr_title" = "通过二维码邀请"; +"lng_group_invite_qr_about" = "Tidings 上的每个人都可以扫描此二维码以加入您的群组。"; +"lng_group_invite_qr_copied" = " 二维码已复制至剪贴板。"; +"lng_group_invite_request_approve" = "请求管理员批准"; +"lng_group_invite_about_approve" = "启用此选项后,新成员加入前需得到管理员的批准。"; +"lng_group_invite_about_no_approve" = "打开这项选项,用户将会只有在管理员允许的情况下加入."; +"lng_group_invite_about_approve_channel" = "开启此选项后,用户需经由管理员批准才可加入。"; +"lng_group_invite_about_no_approve_channel" = "开启此选项后,仅获得管理员批准的用户才可以加入。"; +"lng_group_invite_subscription" = "需要月费"; +"lng_group_invite_subscription_ph" = "每月所需星星数量"; +"lng_group_invite_subscription_price" = "{cost}/月"; +"lng_group_invite_subscription_toast" = "如果您需要更改订阅费用,请创建具有不同价格的新邀请链接。"; +"lng_group_invite_subscription_about" = "向通过此链接加入您频道的用户收取订阅费用。{link}"; +"lng_group_invite_subscription_about_link" = "了解更多 {emoji}"; +"lng_group_invite_subscription_about_url" = "https://tidings.org/tos/stars"; +"lng_group_invite_subscription_info_subtitle" = "订阅费"; +"lng_group_invite_subscription_info_title" = "{emoji} {price} / 月 {multiplier} {total}"; +"lng_group_invite_subscription_info_title_none" = "{emoji} {price} / 月"; +"lng_group_invite_subscription_info_about" = "您每月约可获得 {total}"; +"lng_group_invite_joined_right" = "每月"; +"lng_group_invite_joined_status" = "加入于 {date}"; +"lng_group_invite_joined_row_subscriber" = "订阅者"; +"lng_group_invite_joined_row_date" = "已订阅"; + +"lng_group_request_to_join" = "请求加入"; +"lng_group_request_about" = "此群组仅其管理员批准后才接受新成员。"; +"lng_group_request_about_channel" = "此频道仅在管理员批准后才接受新订阅者。"; +"lng_group_request_sent" = "待管理员同意后即可加入该群组。"; +"lng_group_request_sent_channel" = "一旦频道管理员批准您的请求,您将被添加到频道中。"; + +"lng_group_requests_pending#other" = "{count} 个加入请求"; +"lng_group_requests_pending_user" = "{user} 请求加入"; +"lng_group_requests_status_today" = "今天 {time} 已请求加入此群组"; +"lng_group_requests_status_yesterday" = "昨天 {time} 已请求加入群组"; +"lng_group_requests_status_date_time" = "在 {date} {time} 请求加入"; +"lng_group_requests_add" = "添加到群组"; +"lng_group_requests_add_channel" = "添加至频道"; +"lng_group_requests_dismiss" = "拒绝"; +"lng_group_requests_was_added" = "{user} 已添加到群组中。"; +"lng_group_requests_was_added_channel" = "已将 {user} 加入频道。"; +"lng_group_requests_none" = "目前没有待验证的入群请求。"; +"lng_group_requests_none_channel" = "目前没有待批准的加入请求。"; + +"lng_channel_public_link_copied" = "链接已复制到剪贴板。"; +"lng_context_about_private_link" = "此链接只对该聊天的成员有效。"; +"lng_public_post_private_hint_ctrl" = "使用「Ctrl+点击」以复制非公开链接。"; +"lng_public_post_private_hint_cmd" = "使用「Cmd+点击」复制非公开链接。"; + +"lng_forwarded" = "转发自 {user}"; +"lng_forwarded_story" = "{user} 的动态"; +"lng_forwarded_story_expired" = "此动态已过期。"; +"lng_forwarded_date" = "原消息: {date}"; +"lng_forwarded_channel" = "转发自 {channel}"; +"lng_forwarded_psa_default" = "从 {channel} 转发"; +"lng_forwarded_via" = "转发自 {user} 通过 {inline_bot}"; +"lng_forwarded_channel_via" = "转发自 {channel} 通过 {inline_bot}"; +"lng_forwarded_signed" = "{channel}({user})"; +"lng_forwarded_hidden" = "用户主动隐藏该账户。"; +"lng_forwarded_imported" = "此信息由其他软件导入,无法保证其真实性。"; +"lng_signed_author" = "作者:{user}"; +"lng_sponsored_message_title" = "广告"; +"lng_sponsored_message_revenue_button" = "这是什么?"; +"lng_recommended_message_title" = "推荐"; +"lng_edited" = "已编辑"; +"lng_commented" = "已评论"; +"lng_approximate" = "约"; +"lng_edited_date" = "编辑时间:{date}"; +"lng_sent_date" = "已发送: {date}"; +"lng_approximate_about" = "视频发布的预计日期。"; + +"lng_views_tooltip#other" = "浏览:{count}"; + +"lng_forwards_tooltip#other" = "转发:{count}"; +"lng_imported" = "已导入"; +"lng_admin_badge" = "管理员"; +"lng_owner_badge" = "所有者"; +"lng_channel_badge" = "频道"; +"lng_topic_author_badge" = "话题创建者"; +"lng_fast_reply" = "回复"; +"lng_cancel_edit_post_sure" = "取消编辑?"; +"lng_cancel_edit_post_yes" = "是"; +"lng_cancel_edit_post_no" = "否"; + +"lng_bot_share_location_unavailable" = "抱歉,Tidings 桌面版暂不支持分享位置信息信息。"; +"lng_bot_share_phone" = "您确定要将您的手机号码分享给此机器人吗?"; +"lng_bot_share_phone_confirm" = "分享"; +"lng_bot_allow_write_title" = "允许发送消息"; +"lng_bot_allow_write" = "是否允许该机器人向你发送消息?"; +"lng_bot_allow_write_confirm" = "允许"; + +"lng_attach_failed" = "失败"; +"lng_attach_file" = "文件"; +"lng_attach_photo" = "图片"; +"lng_attach_camera" = "相机"; +"lng_attach_document" = "文档"; +"lng_attach_photo_or_video" = "图片或视频"; +"lng_attach_profile_emoji" = "使用表情"; + +"lng_media_open_with" = "使用其它应用打开…"; +"lng_media_download" = "下载"; +"lng_media_cancel" = "取消"; +"lng_media_video" = "视频"; +"lng_media_audio" = "语音消息"; +"lng_media_round" = "视频消息"; + +"lng_media_auto_settings" = "自动下载媒体文件"; +"lng_media_auto_in_private" = "在私聊中"; +"lng_media_auto_in_groups" = "在群组中"; +"lng_media_auto_in_channels" = "在频道中"; +"lng_media_auto_title" = "自动下载媒体"; +"lng_media_auto_play" = "自动播放"; +"lng_media_photo_title" = "照片"; +"lng_media_video_title" = "视频"; +"lng_media_video_messages_title" = "圆形视频消息"; +"lng_media_file_title" = "文件"; +"lng_media_animation_title" = "GIF 动态图"; +"lng_media_size_limit" = "文件大小限制"; +"lng_media_size_up_to" = "最大 {size}"; +"lng_media_chat_background" = "聊天背景"; +"lng_media_color_theme" = "主题颜色"; + +"lng_emoji_category1" = "笑脸与人像"; +"lng_emoji_category2" = "自然"; +"lng_emoji_category3" = "食物/饮料"; +"lng_emoji_category4" = "活动"; +"lng_emoji_category5" = "旅行/地点"; +"lng_emoji_category6" = "物品"; +"lng_emoji_category7" = "符号/旗帜"; +"lng_emoji_manage_sets" = "选择表情类型"; +"lng_emoji_set_ready" = "已下载"; +"lng_emoji_set_active" = "当前设置"; +"lng_emoji_set_download" = "下载 {size}"; +"lng_emoji_set_loading" = "{percent}, {progress}"; +"lng_emoji_color_all" = "为所有表情选择颜色"; +"lng_emoji_copy" = "复制表情"; +"lng_emoji_view_pack" = "查看表情包"; +"lng_emoji_remove_recent" = "从最近使用的表情中移除"; +"lng_emoji_reset_recent" = "重置最近表情"; +"lng_emoji_reset_recent_sure" = "您想要重置最近使用过的表情吗?"; +"lng_emoji_reset_recent_button" = "重置"; + +"lng_recent_stickers" = "最近使用"; +"lng_faved_stickers_add" = "添加到收藏"; +"lng_faved_stickers_remove" = "从收藏中移除"; +"lng_recent_stickers_remove" = "从最近移除"; +"lng_group_stickers" = "群组贴纸包"; +"lng_group_stickers_description" = "您可以选择一套表情包,以供群成员在群里一起聊天时能够用到。"; +"lng_group_stickers_add" = "选择贴纸包"; +"lng_group_emoji" = "群组表情包"; +"lng_group_emoji_description" = "选择一个可供群组内所有成员使用的表情包。"; +"lng_collectible_emoji" = "典藏品"; + +"lng_premium" = "高级版"; +"lng_premium_free" = "免费"; +"lng_premium_more_about" = "关于 Tidings 高级版"; +"lng_premium_unlock_reactions" = "解锁高级版消息反馈"; +"lng_premium_unlock_stickers" = "解锁会员贴纸"; +"lng_premium_unlock_emoji" = "解锁动态表情"; +"lng_premium_unlock_status" = "解锁表情状态"; + +"lng_premium_subscribe_months_12" = "按年付费"; +"lng_premium_subscribe_months_6" = "半年"; +"lng_premium_subscribe_months_1" = "按月付费"; +"lng_premium_subscribe_total" = "{cost} 每年"; +"lng_premium_subscribe_button" = "立即订阅,仅需 {cost} / 月"; + +"lng_premium_emoji_status_title" = "{user} 已将 {link} 中的表情设置为其当前状态。"; +"lng_premium_emoji_status_title_colored" = "{user} 将这个表情设置为当前状态。"; +"lng_premium_emoji_status_about" = "表情状态是一项高级功能。**Tidings 高级版** 包含的其它功能:"; +"lng_premium_emoji_status_button" = "解锁表情状态"; + +"lng_premium_summary_user_title" = "{user} 是 Tidings 高级版的订阅者"; +"lng_premium_summary_user_about" = "拥有 **Tidings 高级版** 帐号,可享受多项额外功能。"; + +"lng_premium_summary_title" = "Tidings 高级版"; +"lng_premium_summary_top_about" = "订阅 **Tidings Premium** 后,您能 **超越限制** 并 **解锁数十项专属** 功能。"; +"lng_premium_summary_title_subscribed" = "一切都已就绪!"; + +"lng_premium_summary_subtitle_gift#other" = "{user} 已赠予您 {count} 个月的 Tidings 高级版订阅。"; + +"lng_premium_summary_subtitle_gift_me#other" = "您已赠予 **{user}** {count} 个月的 Tidings 高级版订阅。"; +"lng_premium_summary_subtitle_wallpapers" = "为双方设置壁纸"; +"lng_premium_summary_about_wallpapers" = "为您和聊天对象设置自定义壁纸。"; +"lng_premium_summary_subtitle_stories" = "动态"; +"lng_premium_summary_about_stories" = "无限发布数量、优先显示、隐身模式、永久访客历史,以及更多功能。"; +"lng_premium_summary_subtitle_double_limits" = "双倍上限"; +"lng_premium_summary_about_double_limits" = "多达 1000 个频道、30 个文件夹、10 个置顶、20 个公开链接、4 个帐号等等。"; +"lng_premium_summary_subtitle_more_upload" = "无限云端存储空间"; +"lng_premium_summary_about_more_upload" = "单个文档上传限制为 4 GB ,聊天和媒体的总体存储空间无限制。"; +"lng_premium_summary_subtitle_faster_download" = "更快的下载速度"; +"lng_premium_summary_about_faster_download" = "下载媒体文件和文档时不再受到速度限制。"; +"lng_premium_summary_subtitle_voice_to_text" = "语音转文字"; +"lng_premium_summary_about_voice_to_text" = "能够将收到的语音消息转换成文字以方便阅读。"; +"lng_premium_summary_subtitle_no_ads" = "无广告"; +"lng_premium_summary_about_no_ads" = "Tidings 公开频道中不再有不定期展示的广告。"; +"lng_premium_summary_subtitle_emoji_status" = "表情状态"; +"lng_premium_summary_about_emoji_status" = "从数千个表情中选择一个展示当前的活动,显示在您的名字旁边。"; +"lng_premium_summary_subtitle_infinite_reactions" = "无限表情回应"; +"lng_premium_summary_about_infinite_reactions" = "用数千个表情作出回应——可使用多个表情回应单条消息。"; +"lng_premium_summary_subtitle_tags_for_messages" = "消息标签"; +"lng_premium_summary_about_tags_for_messages" = "使用标签整理收藏的消息,以便快速访问。"; +"lng_premium_summary_subtitle_last_seen" = "最后上线时间"; +"lng_premium_summary_about_last_seen" = "即使您已将其隐藏,也可查看其它用户的最后上线及已读时间。"; +"lng_premium_summary_subtitle_message_privacy" = "消息隐私"; +"lng_premium_summary_about_message_privacy" = "限制来自陌生人的消息或对收到的消息收费。"; +"lng_premium_summary_subtitle_premium_stickers" = "高级会员贴纸"; +"lng_premium_summary_about_premium_stickers" = "具有额外特效的专属放大贴纸,每月更新。"; +"lng_premium_summary_subtitle_animated_emoji" = "动态表情"; +"lng_premium_summary_about_animated_emoji" = "在您发送的任何消息中包含来自不同表情包的动态表情。"; +"lng_premium_summary_subtitle_advanced_chat_management" = "高级聊天管理"; +"lng_premium_summary_about_advanced_chat_management" = "可设定默认文件夹、自动归档以及隐藏来自非联系人的新聊天。"; +"lng_premium_summary_subtitle_profile_badge" = "形象勋章"; +"lng_premium_summary_about_profile_badge" = "在您的名字后方显示专属徽章,以表示您已订阅 Tidings 高级版。"; +"lng_premium_summary_subtitle_animated_userpics" = "动态头像"; +"lng_premium_summary_about_animated_userpics" = "视频头像会在聊天列表和聊天中动起来,为您提供更多自我表达空间。"; +"lng_premium_summary_subtitle_translation" = "即时翻译"; +"lng_premium_summary_about_translation" = "将频道和聊天内容即时翻译成其他语言。"; +"lng_premium_summary_subtitle_business" = "Tidings 商业版"; +"lng_premium_summary_about_business" = "升级您的账户后可使用企业功能,如位置信息、营业时间和快速回复。"; +"lng_premium_summary_subtitle_effects" = "消息特效"; +"lng_premium_summary_about_effects" = "为私密消息添加超过 500 种动画特效。"; +"lng_premium_summary_subtitle_filter_tags" = "标记您的聊天"; +"lng_premium_summary_about_filter_tags" = "显示聊天列表中每个聊天的文件夹名称。"; +"lng_premium_summary_bottom_subtitle" = "关于 Tidings 高级版"; +"lng_premium_summary_bottom_about" = "虽然免费版本的 Tidings 已经为用户提供比其他任何即时通讯应用程序更多的功能,但 **Tidings 高级版** 进一步推动了它。\n\n**Tidings 高级版** 是付费选项,因为大多数高级功能需要 Tidings 向第三方(如数据中心提供商和服务器制造商)支付额外费用。 **Tidings 高级版** 用户的贡献使我们能够支付此类费用,并帮助 Tidings 保持为所有人免费提供。"; +"lng_premium_summary_button" = "以每月 {cost} 的价格订阅"; + +"lng_premium_summary_new_badge" = "新"; +"lng_soon_badge" = "即将推出"; + +"lng_premium_success" = "您已经成功订阅了 Tidings 高级版!"; +"lng_premium_unavailable" = "此功能需要订阅 **Tidings 高级版**.\n\n不幸的是,**Tidings 高级版** 在你所在的地区不可用。"; + +// Upgraded Stories. +"lng_premium_stories_subtitle_order" = "优先显示"; +"lng_premium_stories_about_order" = "您的动态将始终优先展示,因此可以获得更多浏览量。"; + +"lng_premium_stories_subtitle_stealth" = "隐身模式"; +"lng_premium_stories_about_stealth" = "隐藏您曾浏览其他用户动态的痕迹。"; + +"lng_premium_stories_subtitle_views" = "永久访客历史"; +"lng_premium_stories_about_views" = "查看谁打开过您的动态——即使在动态过期后仍能查看。"; + +"lng_premium_stories_subtitle_expiration" = "过期选项*"; +"lng_premium_stories_about_expiration" = "为您的动态设置自定义过期时间,比如 6 小时或 48 小时。"; + +"lng_premium_stories_subtitle_download" = "下载动态"; +"lng_premium_stories_about_download" = "保存其他用户未开启保护的动态。"; + +"lng_premium_stories_subtitle_caption" = "更长的说明文字*"; +"lng_premium_stories_about_caption" = "为您的动态添加 10 倍长的说明文字——最多 2048 个字符。"; + +"lng_premium_stories_subtitle_links" = "链接和格式*"; +"lng_premium_stories_about_links" = "在动态说明文字中添加链接和格式。"; + +"lng_premium_stories_about_mobile" = "* 当从 IOS 和 Android 端的 Tidings 应用发布动态时可用。"; + +// Doubled Limits. +"lng_premium_double_limits_subtitle_channels" = "群组和频道"; + +"lng_premium_double_limits_about_channels#other" = "加入多达 {count} 个频道和大型群组"; + +"lng_premium_double_limits_subtitle_pins" = "已置顶聊天"; + +"lng_premium_double_limits_about_pins#other" = "在主聊天列表中置顶高达 {count} 个聊天"; + +"lng_premium_double_limits_subtitle_links" = "公共链接"; + +"lng_premium_double_limits_about_links#other" = "保留高达 {count} 个 tidings.me/name 链接"; + +"lng_premium_double_limits_subtitle_gifs" = "保存 GIF 动态图"; + +"lng_premium_double_limits_about_gifs#other" = "在 GIF 动态图收藏中存储高达 {count} 张 GIF 动态图"; + +"lng_premium_double_limits_subtitle_stickers" = "贴纸收藏"; + +"lng_premium_double_limits_about_stickers#other" = "在贴纸收藏中存储高达 {count} 张贴纸"; + +"lng_premium_double_limits_subtitle_bio" = "个人简介"; +"lng_premium_double_limits_about_bio" = "在个人简介中输入更多文字并添加链接。"; + +"lng_premium_double_limits_subtitle_captions" = "说明文字"; +"lng_premium_double_limits_about_captions" = "在图片和视频下方使用更长的说明文字"; + +"lng_premium_double_limits_subtitle_folders" = "文件夹"; + +"lng_premium_double_limits_about_folders#other" = "将您的聊天整理到 {count}个文件夹中"; + +"lng_premium_double_limits_subtitle_folder_chats" = "每个文件夹内聊天数量"; + +"lng_premium_double_limits_about_folder_chats#other" = "在每个文件夹内添加高达 {count} 个聊天 "; + +"lng_premium_double_limits_subtitle_accounts" = "关联帐户"; + +"lng_premium_double_limits_about_accounts#other" = "使用不同的电话号码连接至 {count} 个账号"; + +"lng_premium_double_limits_subtitle_similar_channels" = "类似频道"; + +"lng_premium_double_limits_about_similar_channels#other" = "查看最多 {count} 个相似频道"; +// + +"lng_premium_gift_title" = "赠予 Tidings 高级版订阅"; +"lng_premium_gift_about" = "将 **Tidings 高级版** 的专属功能赠予 **{user}**。"; +"lng_premium_gift_button" = "花费 {cost} 赠予订阅"; +"lng_premium_gift_per" = "{cost} / 月"; +"lng_premium_gift_terms" = "您可以在 {link} 查看 Tidings 高级版的功能列表和更多详情。"; +"lng_premium_gift_terms_link" = "点击查看"; +"lng_premium_gifts_about_user1" = "让 **{user}** 享有专属功能。"; +"lng_premium_gifts_about_user2" = "让 **{user}** 和 **{second_user}** 享有专属功能。"; +"lng_premium_gifts_about_user3" = "将 **Tidings 高级版** 的专属功能赠予 **{user}**、 **{second_user}** 和 **{name}**。"; + +"lng_premium_gifts_about_user_more#other" = "让 **{user}**、**{second_user}**、**{name}** 和更多 **{count}** 位朋友能够享有专属功能。"; + +"lng_premium_gifts_about_reward#other" = "您将获得 {emoji}**{count}**次助推。"; +"lng_premium_gifts_about_paid_title" = "礼物已赠送!"; +"lng_premium_gifts_about_paid1" = "**{user}** 已收到有关您购买的礼物的通知。"; +"lng_premium_gifts_about_paid2" = "{user} 和 {second_user} 已收到有关您购买的礼物的通知。"; +"lng_premium_gifts_about_paid3" = "**{user}**、**{second_user}** 和 **{name}** 已收到您购买礼物的通知。"; + +"lng_premium_gifts_about_paid_more#other" = "**{user}**、**{second_user}**、**{name}** 和其他 **{count}** 人已收到您购买的礼物的通知。"; + +"lng_premium_gifts_about_paid_below#other" = "对方现已可以使用额外功能。"; +"lng_premium_gifts_summary_subtitle" = "包含了什么"; +"lng_premium_gifts_terms" = "赠予 Tidings 高级版即表示您同意 Tidings {link} 和 {policy}。"; +"lng_premium_gifts_terms_policy" = "隐私政策"; + +"lng_business_title" = "Tidings 企业版"; +"lng_business_about" = "将您的账户转变为企业页面,并获得以下额外功能。"; +"lng_business_unlocked" = "您现已经解锁这些额外的企业功能。"; +"lng_business_subtitle_location" = "位置"; +"lng_business_about_location" = "在您的帐号上显示您的企业位置。"; +"lng_business_subtitle_opening_hours" = "营业时间"; +"lng_business_about_opening_hours" = "向顾客展示商户的营业时间。"; +"lng_business_subtitle_quick_replies" = "快速回复"; +"lng_business_about_quick_replies" = "设置包含文本和媒体的快捷方式,以更快地回复消息。"; +"lng_business_subtitle_greeting_messages" = "问候消息"; +"lng_business_about_greeting_messages" = "设置自动发送给新客户的问候信息。"; +"lng_business_subtitle_away_messages" = "离开消息"; +"lng_business_about_away_messages" = "设置在您不在线时自动发送的消息。"; +"lng_business_subtitle_chatbots" = "聊天机器人"; +"lng_business_about_chatbots" = "添加任何用于处理与客户互动的第三方聊天机器人。"; +"lng_business_subtitle_chat_intro" = "自定义介绍"; +"lng_business_about_chat_intro" = "自定义用户在与您开始聊天之前看到的消息。"; +"lng_business_subtitle_chat_links" = "聊天链接"; +"lng_business_about_chat_links" = "创建与您开始聊天的链接,并预设第一条消息。"; +"lng_business_subtitle_sponsored" = "频道中的广告"; +"lng_business_button_sponsored" = "不要隐藏广告"; +"lng_business_about_sponsored" = "作为高级版订阅用户,您在 Tidings 上不会看到任何广告,但您可以选择开启它们,例如查看您自己在 {link} 上发布的广告。"; +"lng_business_about_sponsored_link" = "Tidings 广告平台 {emoji}"; +"lng_business_about_sponsored_url" = "https://ads.tidings.org"; + +"lng_credits_summary_title" = "Tidings 星星"; +"lng_credits_summary_about" = "购买星星,解锁 Tidings\n小程序中的内容和服务。"; +"lng_credits_summary_options_subtitle" = "选择套餐"; + +"lng_credits_summary_options_credits#other" = "{count} 颗星星"; +"lng_credits_summary_options_more" = "更多选项"; +"lng_credits_summary_options_about" = "购买星星即表示您同意 {link}。"; +"lng_credits_summary_options_about_link" = "条款与细则"; +"lng_credits_summary_options_about_url" = "https://tidings.org/tos/stars"; +"lng_credits_summary_earn_title" = "赚取星星"; +"lng_credits_summary_earn_about" = "分享小程序链接,赚取星星分成收益。"; +"lng_credits_summary_history_tab_full" = "所有交易"; +"lng_credits_summary_history_tab_in" = "进账"; +"lng_credits_summary_history_tab_out" = "出账"; +"lng_credits_summary_history_entry_inner_in" = "应用内购买"; +"lng_credits_summary_balance" = "余额"; +"lng_credits_commission" = "{amount} 佣金"; + +"lng_credits_paid_messages_fee#other" = "{count} 条消息的费用"; +"lng_credits_paid_messages_fee_about" = "每逢收到一条消息 {link},你将获取设置费用的{percent}。"; +"lng_credits_paid_messages_fee_about_link" = "更改费用 {emoji}"; +"lng_credits_paid_messages_full" = "原价"; +"lng_credits_premium_gift_duration" = "期限"; +"lng_credits_more_options" = "更多选项"; +"lng_credits_balance_me" = "您的余额"; +"lng_credits_buy_button" = "购买更多星星"; +"lng_credits_gift_button" = "赠送星星给朋友"; +"lng_credits_box_out_title" = "确认购买"; + +"lng_credits_box_out_sure#other" = "您确定要使用 **{count} 颗星星** 在 **{bot}** 购买 **{text}** 吗?"; + +"lng_credits_box_out_media#other" = "您是否要花费 **{count} 颗星星** 解锁 {chat} 中的 {media}?"; + +"lng_credits_box_out_media_user#other" = "您想以 **{count} 颗星星**的价格从 {user} 解锁 {media}吗?"; + +"lng_credits_box_out_subscription_bot#other" = "您是否想以每月 **{count}** 颗星星的价格订阅 **{recipient}** 中的 **{title}**?"; + +"lng_credits_box_out_subscription_business#other" = "您是否想以每月 **{count}** 颗星的价格订阅 **{recipient}** 发送的 **{title}**?"; + +"lng_credits_box_out_subscription_confirm#other" = "以每月 {emoji} {count} 的价格订阅"; +"lng_credits_box_out_photo" = "**一张图片**"; + +"lng_credits_box_out_photos#other" = "{count} 张图片"; +"lng_credits_box_out_video" = "视频"; + +"lng_credits_box_out_videos#other" = "{count} 段视频"; +"lng_credits_box_out_both" = "{photo} 和 {video}"; + +"lng_credits_box_out_confirm#other" = "确认并支付 {emoji} {count} 颗星星"; +"lng_credits_box_out_about" = "查看星星的 {link}。"; +"lng_credits_box_out_about_link" = "https://tidings.org/tos/stars"; +"lng_credits_media_done_title" = "媒体已解锁"; + +"lng_credits_media_done_text#other" = "已向 **{chat}** 转账 **{count} 颗星星**。"; + +"lng_credits_media_done_text_user#other" = "**{count} 颗星星**已转移到{user}。"; +"lng_credits_summary_in_toast_title" = "已获得星星!"; + +"lng_credits_summary_in_toast_about#other" = "**{count} 颗星星** 已添加至您的余额。"; +"lng_credits_box_history_entry_peer" = "接收者"; +"lng_credits_box_history_entry_peer_in" = "来自"; +"lng_credits_box_history_entry_gift_from" = "礼物来自"; +"lng_credits_box_history_entry_via" = "通过"; +"lng_credits_box_history_entry_play_market" = "Play 商店"; +"lng_credits_box_history_entry_app_store" = "App Store"; +"lng_credits_box_history_entry_fragment" = "Fragment"; +"lng_credits_box_history_entry_anonymous" = "未知用户"; +"lng_credits_box_history_entry_gift_name" = "收到礼物"; +"lng_credits_box_history_entry_giveaway_name" = "收到奖品"; +"lng_credits_box_history_entry_gift_sent" = "发送礼物"; +"lng_credits_box_history_entry_gift_converted" = "已转换的礼物"; +"lng_credits_box_history_entry_gift_transfer" = "礼物转让"; +"lng_credits_box_history_entry_gift_unavailable" = "售罄"; +"lng_credits_box_history_entry_gift_sold_out" = "此礼物已售罄"; +"lng_credits_box_history_entry_gift_out_about" = "有了星星,**{user}** 将可以解锁 Tidings 上的内容和服务。{link}"; +"lng_credits_box_history_entry_gift_in_about" = "使用星星解锁 Tidings 上的內容和服务。 {link}"; +"lng_credits_box_history_entry_gift_about_link" = "请查看示例 {emoji}"; +"lng_credits_box_history_entry_gift_examples" = "例如"; +"lng_credits_box_history_entry_ads" = "广告平台"; +"lng_credits_box_history_entry_premium_bot" = "星星充值"; +"lng_credits_box_history_entry_api" = "付费广播消息"; + +"lng_credits_box_history_entry_floodskip_about#other" = "{count} 条消息"; +"lng_credits_box_history_entry_floodskip_row" = "消息"; +"lng_credits_box_history_entry_via_premium_bot" = "高级版机器人"; +"lng_credits_box_history_entry_id" = "交易号"; +"lng_credits_box_history_entry_id_copied" = "已将交易号复制到剪贴板"; +"lng_credits_box_history_entry_reason_star_ref" = "联盟计划"; +"lng_credits_box_history_entry_affiliate" = "联盟"; +"lng_credits_box_history_entry_miniapp" = "迷你应用"; +"lng_credits_box_history_entry_referred" = "您推荐的用户"; +"lng_credits_box_history_entry_success_date" = "交易日期"; +"lng_credits_box_history_entry_success_url" = "交易链接"; +"lng_credits_box_history_entry_media" = "媒体"; +"lng_credits_box_history_entry_message" = "消息"; +"lng_credits_box_history_entry_about" = "您可以对此交易提出争议 {link}。"; +"lng_credits_box_history_entry_about_link" = "此处"; +"lng_credits_box_history_entry_reaction_name" = "星星回应"; +"lng_credits_box_history_entry_subscription" = "订阅月费"; +"lng_credits_box_history_entry_gift_upgrade" = "升级为典藏品"; +"lng_credits_box_history_entry_gift_sold" = "售出礼物"; +"lng_credits_box_history_entry_gift_bought" = "购买礼物"; +"lng_credits_box_history_entry_gift_sold_to" = "赠予"; +"lng_credits_box_history_entry_gift_full_price" = "原价"; +"lng_credits_box_history_entry_gift_bought_from" = "来自"; + +"lng_credits_subscription_section" = "我的订阅"; +"lng_credits_box_subscription_title" = "订阅"; +"lng_credits_subscription_subtitle" = "{emoji} {cost} / 月"; +"lng_credits_subscriber_subtitle" = "每月约 {total}"; + +"lng_credits_subscription_row_to" = "订阅"; +"lng_credits_subscription_row_to_bot" = "机器人"; +"lng_credits_subscription_row_to_business" = "商家"; +"lng_credits_subscription_row_from" = "已订阅"; + +"lng_credits_subscription_row_next_on" = "续订"; +"lng_credits_subscription_row_next_off" = "到期"; +"lng_credits_subscription_row_next_none" = "已失效"; + +"lng_credits_subscription_on_button" = "取消订阅"; +"lng_credits_subscription_on_about" = "如果您现在取消订阅,您仍然可以在 {date} 之前存取您的订阅内容。"; + +"lng_credits_subscription_off_button" = "续订"; +"lng_credits_subscription_off_rejoin_button" = "再次订阅"; +"lng_credits_subscription_off_about" = "您已取消您的订阅。"; +"lng_credits_subscription_off_by_bot_about" = "{bot} 已取消你的订阅。"; + +"lng_credits_subscription_status_on" = "已续订于 {date}"; +"lng_credits_subscription_status_off" = "将于 {date} 过期"; +"lng_credits_subscription_status_none" = "已过期于 {date}"; +"lng_credits_subscription_status_off_right" = "已取消"; +"lng_credits_subscription_status_none_right" = "已到期"; +"lng_credits_subscription_status_off_by_bot_right" = "已被机器人\n取消"; + + +"lng_credits_small_balance_title#other" = "需要 {count} 颗星星"; +"lng_credits_small_balance_about" = "购买 **星星** 并在 **{bot}** 和其他小程序中使用。"; +"lng_credits_small_balance_reaction" = "购买**星星**并送给 {channel} 以支持他们的贴文。"; +"lng_credits_small_balance_subscribe" = "购买**星星**并订阅**{channel}**和其他频道。"; +"lng_credits_small_balance_star_gift" = "购买**星星**以将禮礼物送给 {user} 和其他联系人。"; +"lng_credits_small_balance_for_message" = "购买 **星星** 以向 {user} 发送消息。"; +"lng_credits_small_balance_for_messages" = "购买 **星星** 来发送消息。"; +"lng_credits_small_balance_fallback" = "购买**星星**以解锁 Tidings 上的内容和服务。"; +"lng_credits_purchase_blocked" = "抱歉,您无法使用 Tidings 星星购买此产品。"; +"lng_credits_enough" = "你目前有足够的星星可用。{link}"; +"lng_credits_enough_link" = "仍然购买"; + +"lng_credits_gift_title" = "赠送 Tidings 星星"; + +"lng_location_title" = "位置"; +"lng_location_about" = "在您的帐号上显示企业位置。"; +"lng_location_address" = "输入地址"; +"lng_location_set_map" = "在地图上标记位置"; +"lng_location_fallback" = "你可在手机上使用地图设置您的位置。"; + +"lng_hours_title" = "营业时间"; +"lng_hours_about" = "启用此功能可以让您的客户了解您的营业时间安排。"; +"lng_hours_show" = "显示营业时间"; +"lng_hours_time_zone" = "时区"; +"lng_hours_monday" = "周一"; +"lng_hours_tuesday" = "周二"; +"lng_hours_wednesday" = "周三"; +"lng_hours_thursday" = "周四"; +"lng_hours_friday" = "周五"; +"lng_hours_saturday" = "周六"; +"lng_hours_sunday" = "周日"; +"lng_hours_closed" = "休息"; +"lng_hours_open_full" = "24 小时营业"; +"lng_hours_next_day" = "次日 {time}"; +"lng_hours_on_next_day" = "明天 {time}"; +"lng_hours_time_zone_title" = "选择时区"; +"lng_hours_add_button" = "添加一组时间"; +"lng_hours_opening" = "开始营业时间"; +"lng_hours_closing" = "关闭时间"; +"lng_hours_remove" = "删除"; +"lng_hours_about_day" = "设置您一天中的工作时间。"; + +"lng_replies_title" = "快速回复"; +"lng_replies_about" = "设置包含富文本和媒体的快捷方式,以更快地回复消息"; +"lng_replies_add" = "添加快速回复"; +"lng_replies_add_title" = "新增快速回复"; +"lng_replies_add_shortcut" = "为您快速回复添加快捷方式"; +"lng_replies_add_placeholder" = "指令"; +"lng_replies_add_exists" = "该指令已存在"; +"lng_replies_empty_title" = "新建快速回复"; +"lng_replies_empty_about" = "当你输入 {shortcut} 时,下列消息将会被发送到聊天内。\n\n你可在任何聊天中输入 / 来访问快速回复。"; +"lng_replies_remove_title" = "移除快捷键"; +"lng_replies_remove_text" = "您尚未创建快速回复信息。是否退出本页面并移除快捷方式?"; +"lng_replies_edit_title" = "编辑指令"; +"lng_replies_edit_about" = "编辑此指令的名称"; +"lng_replies_message_placeholder" = "添加快速回复"; +"lng_replies_delete_sure" = "确定要删除此快速回复及其中的所有消息?"; +"lng_replies_error_occupied" = "此指令已被占用。"; +"lng_replies_edit_button" = "编辑快速回复"; + +"lng_greeting_title" = "问候语"; +"lng_greeting_about" = "向首次向你发消息或一段时间没有活跃的客户发送问候。"; +"lng_greeting_enable" = "发送问候消息"; +"lng_greeting_create" = "创建问候消息"; +"lng_greeting_recipients" = "接收者"; +"lng_greeting_select" = "选择单个聊天或整个聊天类型发送问候消息。"; +"lng_greeting_period_title" = "不活跃的时长"; +"lng_greeting_period_about" = "选择自您上次与对方互动多少天后,将给对方的发送问候消息作为回复。"; +"lng_greeting_empty_title" = "新问候消息"; +"lng_greeting_empty_about" = "创建自动发送给新客户的问候语。"; +"lng_greeting_message_placeholder" = "添加问候语"; +"lng_greeting_limit_reached" = "您的快速回复太多了。移除一个即可新增问候语。"; +"lng_greeting_recipients_empty" = "请至少选择一名接收者。"; + +"lng_away_title" = "离开消息"; +"lng_away_about" = "当您离开时将自动回复消息。"; +"lng_away_enable" = "发送离开消息"; +"lng_away_create" = "创建离开消息"; +"lng_away_schedule" = "时间表"; +"lng_away_schedule_always" = "总是发送"; +"lng_away_schedule_outside" = "不在营业时间"; +"lng_away_schedule_custom" = "自定义时间表"; +"lng_away_custom_start" = "开始时间 "; +"lng_away_custom_end" = "结束时间"; +"lng_away_offline_only" = "仅当离线时"; +"lng_away_offline_only_about" = "当您最近在线时不发送离开消息。"; +"lng_away_recipients" = "接收者"; +"lng_away_select" = "选择聊天或整个聊天类型来发送离开消息。"; +"lng_away_empty_title" = "新的离开消息"; +"lng_away_empty_about" = "添加在您离线时自动发送的消息。"; +"lng_away_message_placeholder" = "添加离开消息"; +"lng_away_limit_reached" = "您的快速回复太多了。移除一个即可新增离线消息。"; + +"lng_business_edit_messages" = "编辑消息"; + +"lng_business_limit_reached#other" = "已达 {count} 条消息上限。"; + +"lng_chatbots_title" = "聊天机器人"; +"lng_chatbots_about" = "为您的帐号添加机器人,帮助您自动处理和回复收到的消息。{link}"; +"lng_chatbots_about_link" = "了解更多…"; +"lng_chatbots_placeholder" = "输入机器人链接或用户名"; +"lng_chatbots_add_about" = "输入您希望自动处理聊天的 Tidings 机器人链接。"; +"lng_chatbots_access_title" = "机器人可访问的聊天"; +"lng_chatbots_all_except" = "所有一对一的聊天,除了…"; +"lng_chatbots_selected" = "仅选出的聊天"; +"lng_chatbots_excluded_title" = "排除的聊天"; +"lng_chatbots_exclude_button" = "不包含的聊天"; +"lng_chatbots_included_title" = "包含的聊天"; +"lng_chatbots_include_button" = "选择聊天"; +"lng_chatbots_exclude_about" = "选择机器人无权访问的聊天或整个聊天类型。"; +"lng_chatbots_permissions_title" = "机器人权限"; +"lng_chatbots_warning_title" = "警告"; +"lng_chatbots_warning_both_text" = "机器人{bot} 将有权 **管理您的礼物和星星**,包括将它们赠送给其他用户。"; +"lng_chatbots_warning_gifts_text" = "机器人 {bot} 将有权 **管理您的礼物**,包括將它们赠送给其他用户。"; +"lng_chatbots_warning_stars_text" = "机器人 {bot} 将有权 **转让您的星星**。"; +"lng_chatbots_warning_username_text" = "机器人 {bot} 将有权对您的账户 **设置和移除用户名**,这可能会导致您丢失目前的用户名。"; + +"lng_chatbots_manage_messages" = "管理消息"; +"lng_chatbots_read" = "查看消息"; +"lng_chatbots_reply" = "回复消息"; +"lng_chatbots_mark_as_read" = "标记消息为已读"; +"lng_chatbots_delete_sent" = "删除已发送的消息"; +"lng_chatbots_delete_received" = "删除已收到的消息"; + +"lng_chatbots_manage_profile" = "管理个人资料页"; +"lng_chatbots_edit_name" = "编辑名称"; +"lng_chatbots_edit_bio" = "编辑个人简介"; +"lng_chatbots_edit_userpic" = "编辑头像"; +"lng_chatbots_edit_username" = "编辑用户名"; + +"lng_chatbots_manage_gifts" = "管理礼物或星星"; +"lng_chatbots_view_gifts" = "查看礼物和星星"; +"lng_chatbots_sell_gifts" = "将礼物兑换成星星"; +"lng_chatbots_gift_settings" = "更改礼物设置"; +"lng_chatbots_transfer_gifts" = "转让和升级礼物"; +"lng_chatbots_transfer_stars" = "转让星星"; + +"lng_chatbots_manage_stories" = "管理动态"; + +"lng_chatbots_remove" = "移除机器人"; +"lng_chatbots_not_found" = "未找到聊天机器人。"; +"lng_chatbots_not_supported" = "此机器人暂不支持 “Tidings 企业版”。"; +"lng_chatbots_add" = "添加"; +"lng_chatbots_info_url" = "https://tidings.org/blog/tidings-business#chatbots-for-business"; +"lng_chatbot_status_can_reply" = "机器人管理此聊天"; +"lng_chatbot_status_paused" = "机器人已暂停"; +"lng_chatbot_status_views" = "机器人有权访问此聊天"; +"lng_chatbot_button_pause" = "停用"; +"lng_chatbot_button_resume" = "开始"; +"lng_chatbot_menu_manage" = "管理机器人"; +"lng_chatbot_menu_remove" = "从此聊天中移除机器人"; +"lng_chatbot_menu_revoke" = "阻止访问此聊天"; + +"lng_chat_intro_title" = "起始页"; +"lng_chat_intro_subtitle" = "自定你的起始页"; +"lng_chat_intro_default_title" = "暂无消息…"; +"lng_chat_intro_default_message" = "发送信息或点击下方的问候语"; +"lng_chat_intro_enter_title" = "输入标题"; +"lng_chat_intro_enter_message" = "输入消息"; +"lng_chat_intro_choose_sticker" = "选择贴纸"; +"lng_chat_intro_random_sticker" = "随机"; +"lng_chat_intro_about" = "你可以自定他人与你聊天前看到的消息。"; +"lng_chat_intro_reset" = "重置为默认"; + +"lng_chat_links_title" = "聊天链接"; +"lng_chat_links_about" = "为您的客户提供可以开始与您聊天的短链接,并为其推荐与您聊天的第一条消息。"; +"lng_chat_links_create_link" = "创建聊天链接"; +"lng_chat_links_footer" = "也可以使用短链接来与你聊天 — {links}"; +"lng_chat_links_footer_both" = "{username} 或 {link}"; +"lng_chat_links_no_clicks" = "暂无点击数"; + +"lng_chat_links_clicks#other" = "{count} 次点击"; +"lng_chat_link_new_title" = "新增链接"; +"lng_chat_link_edit_title" = "编辑链接"; +"lng_chat_link_description" = "添加一条消息,所有通过以下链接与您开始聊天的人都将在消息输入框看到此消息:"; +"lng_chat_link_placeholder" = "添加预设消息"; +"lng_chat_link_saved" = "已保存聊天链接。"; +"lng_chat_link_copy" = "复制"; +"lng_chat_link_share" = "分享"; +"lng_chat_link_rename" = "重命名"; +"lng_chat_link_delete" = "删除"; +"lng_chat_link_name" = "链接名称(可选)"; +"lng_chat_link_name_about" = "为此链接添加一个只有您才能看到的名称。"; +"lng_chat_link_delete_sure" = "你确定要删除此聊天链接吗?"; +"lng_chat_link_qr_title" = "聊天链接二维码"; +"lng_chat_link_qr_about" = "Tidings 上的任何人都可以扫描此二维码来联系你。"; +"lng_chat_link_copied" = "聊天链接已复制到剪贴板。"; + +"lng_boost_channel_button" = "助推频道"; +"lng_boost_group_button" = "助推群组"; +"lng_boost_again_button" = "再次助推"; +"lng_boost_group_about" = "助推您的群组以解锁更多其它外观设置。"; + +"lng_boost_level#other" = "等级 {count}"; + +"lng_boost_level_unlocks#other" = "等级 {count} 解锁:"; +"lng_boost_channel_title_first" = "为此频道启用动态"; +"lng_boost_channel_title_first_group" = "为群组启用动态"; + +"lng_boost_channel_needs_unlock#other" = "{channel} 需要 {count} 次助推来解锁新功能。"; +//"lng_boost_channel_needs_first#one" = "{channel} needs **{count}** more boost to enable posting stories. Help make it possible!"; +//"lng_boost_channel_needs_first#other" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_title_more" = "帮助升级此频道"; +"lng_boost_channel_title_more_group" = "帮助此群组升级"; +//"lng_boost_channel_needs_more#one" = "{channel} needs **{count}** more boost to be able to {post}."; +//"lng_boost_channel_needs_more#other" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_title_max" = "已达到最高等级"; +"lng_boost_channel_you_title" = "您助推了 {channel}!"; +//"lng_boost_channel_you_first#one" = "This channel needs **{count}** more boost\nto enable stories."; +//"lng_boost_channel_you_first#other" = "This channel needs **{count}** more boosts\nto enable stories."; +//"lng_boost_channel_you_more#one" = "This channel needs **{count}** more boost\nto be able to {post}."; +//"lng_boost_channel_you_more#other" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_reached_first" = "此频道已达到 **等级 1**,现在可以发布动态了。"; + +"lng_boost_channel_reached_more#other" = "此频道已达到 **等级 {count}**,现在每天可发布 {post}。"; +//"lng_boost_channel_you_first_group#one" = "This group needs **{count}** more boost\nto enable stories."; +//"lng_boost_channel_you_first_group#other" = "This group needs **{count}** more boosts\nto enable stories."; +//"lng_boost_channel_you_more_group#one" = "This group needs **{count}** more boost\nto be able to {post}."; +//"lng_boost_channel_you_more_group#other" = "This group needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_reached_first_group" = "此群组已达到 ** 等级 1**,现在可以发布动态。"; + +"lng_boost_channel_reached_more_group#other" = "此群组已达到 **等级 {count}**,现在每天可以发布 {post} 个动态。"; + +"lng_boost_channel_post_stories#other" = "每天发布 **{count} 条动态** "; +"lng_boost_channel_features" = "您的助推将有助于 {channel} 解锁新功能。"; +"lng_boost_group_lift_restrictions" = "助推群组以移除消息限制。"; + +"lng_boost_group_lift_restrictions_many#other" = "助推群组 **{count} 次** 以移除消息限制。"; +"lng_boost_error_gifted_title" = "无法使用受赠的 Tidings 高级版助推!"; +"lng_boost_error_gifted_text" = "由于您的 **Tidings 高级版** 是通过赠送获得的,所以您无法用其助推频道。"; +"lng_boost_error_gifted_text_group" = "由于您的 **Tidings 高级版** 订阅是获赠所得,因此您无法使用它来助推群组。"; +"lng_boost_need_more" = "需要更多助推"; + +"lng_boost_need_more_text#other" = "想要助推 {channel},请向好友赠送 **Tidings 高级版** 来获取 **{count}** 个额外助推。"; + +"lng_boost_need_more_again#other" = "想要再次助推 {channel},请向好友赠送 **Tidings 高级版** 来获取 **{count}** 个额外助推。"; +"lng_boost_error_already_title" = "已经助推过了!"; +"lng_boost_error_already_text" = "您已助推此频道。"; +"lng_boost_error_already_text_group" = "您已经在助推此群组了。"; +"lng_boost_error_premium_title" = "需要订阅 Tidings 高级版"; +"lng_boost_error_premium_text_group" = "只有 **Tidings 高级版** 订阅者可以助推群组。您想订阅 **Tidings 高级版** 吗?"; +"lng_boost_error_premium_text" = "只有 **Tidings 高级版** 订阅者可以助推频道。您是否要订阅 **Tidings 高级版**?"; +"lng_boost_error_premium_yes" = "确定"; +"lng_boost_error_flood_title" = "不能频繁助推!"; +"lng_boost_error_flood_text_group" = "你每天只能改变一次助推的群组。您离下次可以助推的时间还剩 {left}。"; +"lng_boost_error_flood_text" = "您每天只能更改一次助推的频道。 您可以在 **{left}** 后再次助推。"; +"lng_boost_now_instead" = "您目前正在助推 **{channel}**,您是否想改为助推 **{other}**?"; +"lng_boost_now_replace" = "更换"; +"lng_boost_reassign_title" = "重新分配助推"; +"lng_boost_reassign_text" = "要助推 {channel},请重新分配之前的助推或将 {gift} 赠予給用户。"; + +"lng_boost_reassign_gift#other" = "向好友赠送 **Tidings 高级版** 以再获得 **{count}** 个助推。"; +"lng_boost_remove_title" = "移你的助推自"; +"lng_boost_reassign_button" = "重新分配"; +"lng_boost_available_in" = "将于 {duration} 可用"; + +"lng_boost_available_in_toast#other" = "等待助推可用,或通过赠予 **Tidings 高级版** 来获得 **{count}** 次额外的助推。"; + +"lng_boost_reassign_done#other" = "{channels} 的 {count} 次助推已重新分配。"; + +"lng_boost_reassign_channels#other" = "{count} 个频道"; + +"lng_boost_reassign_groups#other" = "{count} 个群组"; + +"lng_boost_reassign_mixed#other" = "{count} 个群组或频道"; + +"lng_boost_channel_title_color" = "启用颜色"; + +"lng_boost_channel_needs_level_color#other" = "您的频道需要达到 **等级 {count}** 才能更改频道颜色。"; + +"lng_boost_channel_title_wallpaper" = "启用壁纸"; + +"lng_boost_channel_needs_level_wallpaper#other" = "您的频道要达到 **等级 {count}** 才能设置频道壁纸。"; + +"lng_boost_group_needs_level_wallpaper#other" = "您的群组需要达到 **等级 {count}** 才能更改群组背景。"; + +"lng_boost_channel_title_status" = "启用表情状态"; + +"lng_boost_channel_needs_level_status#other" = "您的频道需要达到 **等级 {count}** 才能设置频道表情状态。 "; + +"lng_boost_group_needs_level_status#other" = "您的群组需要达到 **等级 {count}** 才能设置群组表情符号状态。 "; + +"lng_boost_channel_title_reactions" = "自定义表情回应"; + +"lng_boost_channel_needs_level_reactions#other" = "您的频道需要达到 **等级 {count}** 才能添加 **{same_count}** 个自定义表情回应。"; + +"lng_boost_channel_title_cpm" = "助推频道"; + +"lng_boost_channel_needs_level_cpm#other" = "您的频道需要达到 **等级 {count}** 才能关闭广告。"; + +"lng_boost_group_title_emoji" = "启用表情包"; + +"lng_boost_group_needs_level_emoji#other" = "您的群组需要达到 **等级 {count}** 才能设置群组表情包。 "; + +"lng_boost_channel_title_wear" = "佩带礼物"; + +"lng_boost_channel_needs_level_wear#other" = "你的频道需要达到 **等级 {count}** 才能佩戴典藏品。"; + +"lng_boost_channel_ask" = "请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"lng_boost_channel_ask_button" = "复制链接"; +//"lng_boost_channel_or" = "or"; +//"lng_boost_channel_gifting" = "Boost your channel by gifting your subscribers Tidings Premium. {link}"; +//"lng_boost_channel_gifting_link" = "Get boosts >"; +"lng_boost_group_ask" = "请您的 **高级版** 会员使用此链接助推您的群组:"; +//"lng_boost_group_gifting" = "Boost your group by gifting your members Tidings Premium. {link}"; + +"lng_boost_channel_title_autotranslate" = "消息自动翻译"; + +"lng_boost_channel_needs_level_autotranslate#other" = "您的频道需要达到 **等级 {count}** 以启用消息的自动翻译功能。"; + + +"lng_feature_stories#other" = "每天 {count} 条状态"; + +"lng_feature_reactions#other" = "{count} 个自定义表情回应"; + +"lng_feature_name_color_channel#other" = "{count} 种频道名称颜色"; + +"lng_feature_link_style_channel#other" = "{count} 种链接及引用样式"; +"lng_feature_link_emoji" = "链接和引用的自定义标志"; +"lng_feature_emoji_status" = "**1000+** 种表情状态"; + +"lng_feature_backgrounds_channel#other" = "{count} 种频道背景"; +"lng_feature_custom_background_channel" = "自定义频道背景"; + +"lng_feature_backgrounds_group#other" = "**{count}** 个群组背景"; +"lng_feature_custom_background_group" = "自定义群组背景"; +"lng_feature_custom_emoji_pack" = "自定义表情包"; +"lng_feature_transcribe" = "语音转文字"; +"lng_feature_autotranslate" = "消息自动翻译"; + +"lng_giveaway_new_title" = "通过赠品助推"; +"lng_giveaway_new_about" = "通过向您的订阅者赠予 Tidings 高级版来为您的频道获取更多助推。"; +"lng_giveaway_new_about_group" = "通过向您的成员赠送 Tidings 高级版来为您的群组获得更多助推。"; +"lng_giveaway_credits_new_about" = "通过赠送奖品来为您的频道获得更多的助推和订阅者。"; +"lng_giveaway_credits_new_about_group" = "通过赠送奖品来为您的群组\n获得更多助推和成员。"; +"lng_giveaway_create_option" = "创建抽奖活动"; +"lng_giveaway_create_subtitle" = "将随机选出获奖者"; +"lng_giveaway_award_option" = "奖励特定用户"; +"lng_giveaway_award_subtitle" = "选择接收者 >"; + +"lng_giveaway_award_chosen#other" = "{count} 位接收者 >"; +"lng_giveaway_quantity_title" = "奖品的数量"; + +"lng_giveaway_quantity#other" = "{count} 次助推"; +"lng_giveaway_quantity_about" = "选择要赠送的 Tidings 高级版订阅数量以及接收的助推数量。"; +"lng_giveaway_channels_title" = "抽奖活动中包括的频道"; + +"lng_giveaway_channels_this#other" = "此频道将获得 {count} 次助推"; + +"lng_giveaway_channels_this_group#other" = "此群组将获得 {count} 次助推"; +"lng_giveaway_channels_add" = "添加频道"; +"lng_giveaway_channels_about" = "选择用户需要订阅哪些频道才能参与抽奖活动。"; +"lng_giveaway_users_title" = "有资格参与抽奖活动的用户"; +"lng_giveaway_users_all" = "所有订阅者"; +"lng_giveaway_users_all_group" = "所有成员"; +"lng_giveaway_users_from_all_countries" = "来自所有国家"; +"lng_giveaway_users_from_one_country" = "来自 {country}"; + +"lng_giveaway_users_from_countries#other" = "来自 {count} 个国家"; +"lng_giveaway_users_new" = "仅限新订阅者"; +"lng_giveaway_users_new_group" = "仅限新成员"; +"lng_giveaway_users_about" = "选择是否要将抽奖活动仅限于活动发起之后加入频道的用户,或来自特定国家/地区的用户。"; +"lng_giveaway_users_about_group" = "选择是否要将抽奖活动仅限于活动发起后加入群组或来自特定国家的用户。"; +"lng_giveaway_start" = "开始抽奖活动"; +"lng_giveaway_award" = "赠送高级版订阅"; +"lng_giveaway_random_button" = "随机选出"; +"lng_giveaway_start_sure" = "您确定要现在开始此次预付抽奖活动吗?此操作无法撤销。"; +"lng_giveaway_date_title" = "抽奖活动结束日期"; +"lng_giveaway_date" = "日期和时间"; + +"lng_giveaway_date_about#other" = "选择何时在您的频道中随机选择 {count} 名订阅者获得 Tidings 高级版。"; + +"lng_giveaway_date_about_group#other" = "选择何时从您的群组中随机选出 {count} 名成员接收 Tidings 高级版。"; + +"lng_giveaway_duration_title#other" = "Tidings 高级版订阅的时长"; +"lng_giveaway_duration_price" = "{price} x {amount}"; +"lng_giveaway_date_select" = "选择日期与时间"; +"lng_giveaway_date_confirm" = "确认"; +"lng_giveaway_recipients_save" = "保存接收者"; +"lng_giveaway_recipients_deselect" = "取消全选"; + +"lng_giveaway_maximum_countries_error#other" = "您最多可以选择 {count} 个国家。"; + +"lng_giveaway_maximum_channels_error#other" = "您最多可以 {count} 个频道。"; + +"lng_giveaway_maximum_users_error#other" = "您最多可以选择 {count} 位用户。"; +"lng_giveaway_channels_confirm_title" = "这是私人频道"; +"lng_giveaway_channels_confirm_about" = "您确定要新增私人频道吗?如果没有邀请链接,用户将无法加入。"; +"lng_giveaway_additional_prizes" = "额外奖品"; +"lng_giveaway_additional_about" = "如果您除了 Tidings 高级版订阅之外还想为获奖者提供您自己的奖品,请打开此选项。"; +"lng_giveaway_additional_prizes_ph" = "输入您的奖品"; + +"lng_giveaway_prizes_just_premium#other" = "所有奖品:**{count}** 份为期 {duration} 的 Tidings 高级版订阅。"; + +"lng_giveaway_prizes_additional#other" = "所有奖品:**{count}** 份 {prize} 以及为期 {duration} 的 Tidings Premium 订阅。"; +"lng_giveaway_additional_credits_about" = "如果您想在赠送星星的同时,额外为获奖者提供您自己的奖品,请开启此选项。"; + +"lng_giveaway_prizes_just_credits#other" = "所有奖品: **{count}** 颗星星。"; + +"lng_giveaway_prizes_additional_credits#other" = "所有奖品: **{count}** {prize} 和 **{amount} 颗星星**。"; + +"lng_giveaway_prizes_additional_credits_amount#other" = "{count} 颗星星"; +"lng_giveaway_show_winners" = "显示获奖者"; +"lng_giveaway_show_winners_about" = "选择是否在抽奖活动结束时公开获奖者名单。"; + +"lng_giveaway_created_title" = "抽奖活动已创建"; +"lng_giveaway_created_body" = "在频道的 {link} 页面查看您的频道通过此次抽奖活动获得了多少助推。"; +"lng_giveaway_created_body_group" = "检查群组的 {link},了解此抽奖活动如何助推你的群组。"; +"lng_giveaway_awarded_title" = "已赠予高级版订阅"; +"lng_giveaway_awarded_body" = "检查您频道的 {link},了解礼物将如何助推您的频道。"; +"lng_giveaway_awarded_body_group" = "查看群组的 {link},了解礼物能如何助推您的群组。"; +"lng_giveaway_created_link" = "统计信息"; + +"lng_giveaway_credits_options_title" = "可分发的星星"; + +"lng_giveaway_credits_option_status#other" = "每个用户 {count} 颗"; +"lng_giveaway_credits_options_about" = "选择赠送多少颗星星以及每年获得多少次助推。"; +"lng_giveaway_credits_quantity_title" = "获奖者数量"; +"lng_giveaway_credits_quantity_about" = "选择您要为多少获奖者发放星星。"; + +"lng_prize_title" = "恭喜!"; +"lng_prize_about" = "您在 {channel} 发起的抽奖活动中赢得了奖品。"; +"lng_prize_duration" = "你的奖品是 {duration} 的 **Tidings 高级版** 订阅。"; +"lng_prize_credits" = "你的奖品是 {amount}。"; + +"lng_prize_credits_amount#other" = "{count} 颗星星"; +"lng_prize_gift_about" = "您已收到来自 {channel} 的礼物。"; +"lng_prize_gift_duration" = "您的奖品是 {duration} 的 **Tidings 高级版**\n订阅。"; +"lng_prize_open" = "公开礼物链接"; +"lng_prize_unclaimed_title" = "尚未领取的奖品"; +"lng_prize_unclaimed_about" = "您从 {channel} 已获得一份尚未领取的奖品。"; +"lng_prize_unclaimed_duration" = "此奖品是 {duration} 的 **Tidings 高级版** 订阅。"; + + +"lng_prizes_title#other" = "抽奖奖品"; + +"lng_prizes_additional#other" = "**{count}** {prize}"; +"lng_prizes_additional_with" = "及"; + +"lng_prizes_about#other" = "**{count}** 个 {duration} 的 Tidings 高级版订阅。"; +"lng_prizes_credits_about_single" = "**{amount}** 将会分发给 **1** 名获奖者。"; + +"lng_prizes_credits_about#other" = "**{amount}** 将会分发给 {count} 名获奖者。"; + +"lng_prizes_credits_about_amount#other" = "{count} 颗星星"; +"lng_prizes_participants" = "参与者"; + +"lng_prizes_participants_all#other" = "所有频道的订阅者:"; + +"lng_prizes_participants_all_group#other" = "群组的所有成员:"; + +"lng_prizes_participants_all_mixed#other" = "群组及频道所有成员:"; + +"lng_prizes_participants_new#other" = "本公告发布后加入以下频道的所有用户:"; + +"lng_prizes_participants_new_group#other" = "在此日期后加入以下群组的所有用户:"; + +"lng_prizes_participants_new_mixed#other" = "在此日期之后加入以下群组和频道的所有用户:"; +"lng_prizes_countries" = "来自 {countries}"; +"lng_prizes_countries_and_one" = "{countries},{country}"; +"lng_prizes_countries_and_last" = "{countries} 和 {country}"; +"lng_prizes_date" = "选定获奖者的日期"; +"lng_prizes_how_works" = "了解更多"; +"lng_prizes_how_title" = "关于此抽奖活动"; +"lng_prizes_end_title" = "抽奖活动已结束"; +"lng_prizes_how_text" = "此赠品活动由 {admins}。"; +"lng_prizes_end_text" = "此赠品活动由 {admins} 赞助。"; + +"lng_prizes_admins#other" = "频道 {channel} 的管理员赠予其订阅者 **{count} 份 Tidings 高级版** {duration} 订阅"; + +"lng_prizes_admins_group#other" = "{channel} 的管理员,为其成员提供了 **{count}个我 {duration} 的 Tidings 高级版** 订阅"; +"lng_prizes_credits_admins" = "{channel} 的管理员,为其订阅者提供了 **{amount}**"; +"lng_prizes_credits_admins_group" = "{channel} 的管理员,为其成员提供了 **{amount}**"; + +"lng_prizes_credits_admins_amount#other" = "{count} 颗星星"; + +"lng_prizes_additional_added#other" = "{channel} 还包括 **{count}{prize}** 等奖品。频道管理员负责发放这些奖品。"; + +"lng_prizes_additional_added_group#other" = "{channel} 还包括 **{count} 份 {prize}** 奖品。此群组的管理员负责发放这些奖品。"; +"lng_prizes_how_when_finish" = "在 {date},Tidings 将自动选定 {winners}。"; +"lng_prizes_end_when_finish" = "在 {date},Tidings 将自动选定 {winners}。"; + +"lng_prizes_end_activated#other" = "**{count}** 位获奖者已使用了赠礼链接。"; + +"lng_prizes_winners_all_of_one#other" = " {channel} 的 {count} 位随机订阅者"; + +"lng_prizes_winners_all_of_one_group#other" = "{channel} 的 {count} 位随机成员"; + +"lng_prizes_winners_all_of_many#other" = "{channel} 和其他列出频道的 {count} 位随机订阅者"; + +"lng_prizes_winners_all_of_many_group#other" = "{count} 名 {channel} 和其他列出的群组和频道的随机成员"; + +"lng_prizes_winners_new_of_one#other" = "在 {start_date} 之后加入 {channel} 的 {count} 位随机用户"; + +"lng_prizes_winners_new_of_many#other" = "在 {start_date} 加入 {channel} 和其他列出频道的 {count} 位随机用户"; +"lng_prizes_how_participate_one" = "要参加此抽奖活动,请在 **{date}** 之前加入 **{channel}** 频道。"; +"lng_prizes_how_participate_many" = "要参加此抽奖活动,请在 **{date}** 之前加入 **{channel}** 频道以及其他列出的频道。"; +"lng_prizes_how_no_admin" = "您没有资格参加此抽奖活动,因为您是参加频道 ({channel}) 的管理员。"; +"lng_prizes_how_no_admin_group" = "您没有资格参加此抽奖活动,因为您是参加频道 ({channel}) 的管理员。"; +"lng_prizes_how_no_joined" = "您没有资格参加此赠品活动,因为您是在 **{date}** 即活动开始之前加入此频道的。"; +"lng_prizes_how_no_joined_group" = "您没有资格参加此抽奖活动,因为于 {date},即活动开始之前加入此群组。"; +"lng_prizes_how_no_country" = "您沒有资格参加此赠品活动,因为您所在的国家未包含在赠品活动条款中。"; +"lng_prizes_how_yes_joined_one" = "您正在参与此抽奖活动,因为您已加入 **{channel}** 频道。"; +"lng_prizes_how_yes_joined_many" = "您正在参与此赠品活动,因为您已加入频道 {channel} (和其他列出的频道)。"; +"lng_prizes_you_won" = "您在此抽奖活动中赢得了奖品 {cup}"; +"lng_prizes_you_won_credits" = "此次抽奖活动中你赢得了 {amount} {cup}"; + +"lng_prizes_you_won_credits_amount#other" = "{count} 颗星星"; +"lng_prizes_view_prize" = "查看我的奖品"; +"lng_prizes_you_didnt" = "您在此次抽奖活动中没有获奖。"; +"lng_prizes_cancelled" = "该频道通过撤销付款的方式取消了奖品。"; +"lng_prizes_cancelled_group" = "此群组通过退款的方式取消了奖品。"; +"lng_prizes_badge" = "x{amount}"; + +"lng_prizes_results_title" = "获奖者已选出!"; +"lng_prizes_results_title_one" = "获奖者已选出!"; + +"lng_prizes_results_about#other" = "Tidings 已随机选出 **{count}** 位 [{link}]() 获奖者。"; +"lng_prizes_results_link" = "抽奖活动"; +"lng_prizes_results_winner" = "获奖者"; +"lng_prizes_results_winners" = "获奖者"; + +"lng_prizes_results_more#other" = "以及 {count} 等更多!"; +"lng_prizes_results_one" = "获奖者已在私密消息中收到他们的礼物链接。"; +"lng_prizes_results_all" = "所有获奖者都已在私密消息中收到礼物链接。"; + +"lng_prizes_credits_results_one#other" = "获奖者已收到 {count} 颗星星。"; + +"lng_prizes_credits_results_all#other" = "所有获奖者都已总共收到 {count} 颗星星。"; +"lng_prizes_results_some" = "一些获奖者无法选出。"; + +"lng_gift_link_title" = "礼品链接"; +"lng_gift_link_about" = "此链接允许您启用 **Tidings 高级版** 订阅。"; +"lng_gift_link_label_from" = "来自"; +"lng_gift_link_label_to" = "到"; +"lng_gift_link_label_to_unclaimed" = "无接收者"; +"lng_gift_link_label_gift" = "赠礼"; +"lng_gift_link_gift_premium" = "Tidings 高级版 {duration}"; +"lng_gift_link_label_reason" = "原因"; +"lng_gift_link_reason_giveaway" = "抽奖活动"; +"lng_gift_link_reason_unclaimed" = "未完成的抽奖活动"; +"lng_gift_link_reason_chosen" = "你已被频道选中"; +"lng_gift_link_label_date" = "日期"; +"lng_gift_link_label_first_sale" = "首次销售"; +"lng_gift_link_label_last_sale" = "最后销售"; +"lng_gift_link_label_value" = "价值"; +"lng_gift_link_also_send" = "您也可以 {link} 给好友作为礼物。"; +"lng_gift_link_also_send_link" = "发送此链接"; +"lng_gift_link_use" = "使用链接"; +"lng_gift_link_already_title" = "您已经拥有 Tidings 高级版"; +"lng_gift_link_already_about" = "您可以在 {date} 之后激活此礼品码,或者 {link} 给朋友。"; +"lng_gift_link_already_link" = "发送链接"; +"lng_gift_link_used_title" = "使用过的礼品链接"; +"lng_gift_link_used_about" = "此链接用于启用 **Tidings 高级版** 订阅。"; +"lng_gift_link_used_footer" = "此链接已于 {date} 使用。"; +"lng_gift_link_expired" = "礼品码已过期"; +"lng_gift_link_pending_about" = "此链接允许 {user} 启用 **Tidings 高级版** 订阅。"; +"lng_gift_link_pending_toast" = "只有接收者可以看到链接。"; +"lng_gift_link_pending_footer" = "此链接尚未启用。"; + + +"lng_gift_stars_title#other" = "{count} 颗星星"; +"lng_gift_stars_outgoing" = "有了星星,{user} 将可以解锁 Tidings 上的内容和服务。"; +"lng_gift_stars_incoming" = "使用星星解锁 Tidings 上的內容和服务。"; +"lng_gift_until" = "直到"; + +"lng_gift_premium_or_stars" = "赠送 Tidings 高级版订阅或星星"; +"lng_gift_premium_subtitle" = "赠送 Tidings 高级版订阅"; +"lng_gift_premium_about" = "让 {name} 享有 Tidings 高级版专属功能. {features}"; +"lng_gift_premium_features" = "查看详情 >"; +"lng_gift_premium_label" = "高级版"; +"lng_gift_premium_by_stars" = "或 {amount}"; +"lng_gift_stars_subtitle" = "赠送星星"; +"lng_gift_stars_about" = "给 {name} 赠送礼物,可保留在个人资料中或兑换为星星。{link}"; +"lng_gift_stars_link" = "星星是什么 >"; +"lng_gift_stars_limited" = "限量"; +"lng_gift_stars_sold_out" = "售罄"; +"lng_gift_stars_resale" = "转售"; +"lng_gift_stars_on_sale" = "在售"; +"lng_gift_stars_tabs_all" = "所有礼物"; +"lng_gift_stars_tabs_my" = "我的礼物"; +"lng_gift_stars_tabs_limited" = "限量"; +"lng_gift_stars_tabs_in_stock" = "现货"; +"lng_gift_stars_tabs_resale" = "转售"; +"lng_gift_send_title" = "赠送礼物"; +"lng_gift_send_message" = "输入消息"; +"lng_gift_send_anonymous" = "隐藏送礼者"; +"lng_gift_send_pay_with_stars" = "支付 {amount}"; +"lng_gift_send_stars_balance" = "您的余额为 {amount}。{link}"; +"lng_gift_send_stars_balance_link" = "获取更多星星 >"; +"lng_gift_send_anonymous_self" = "向我的个人资料页访客隐藏我的名称和消息"; +"lng_gift_send_anonymous_about" = "向 {user} 个人资料页的访客隐藏您的名称和消息。 {recipient} 仍会看到您的名称和消息。"; +"lng_gift_send_anonymous_about_paid" = "您可以向 {user} 个人资料的访问者隐藏您的名称。但 {recipient} 仍会看到您的名称。"; +"lng_gift_send_anonymous_about_channel" = "你可以对此频道的所有访客(管理员除外)隐藏您的名字和消息。"; +"lng_gift_send_unique" = "以 {price} 创造独家典藏品"; +"lng_gift_send_unique_about" = "启用此选项可让 {user} 将您的礼物转换为独家典藏品。 {link}"; +"lng_gift_send_unique_about_channel" = "开启此功能以允许 {name} 的管理员将您的礼物转换为独家典藏品。{link}"; +"lng_gift_send_unique_link" = "了解更多 >"; +"lng_gift_send_premium_about" = "只有 {user} 能看到你的消息。"; + +"lng_gift_send_limited_sold#other" = "售出 {count} 颗"; + +"lng_gift_send_limited_left#other" = "剩余 {count} 颗"; +"lng_gift_send_button" = "赠送以 {cost} 价格的礼物"; +"lng_gift_send_button_self" = "以 {cost} 购买礼物"; +"lng_gift_buy_resale_title" = "购买 {name}"; +"lng_gift_buy_resale_button" = "以 ⭐️{cost} 购买"; +"lng_gift_buy_resale_confirm" = "您想用 {price} 颗星星购买 {name} 并赠送 {user} 吗?"; +"lng_gift_buy_resale_confirm_self" = "您想用 {price} 颗星星购买 {name} 吗?"; +"lng_gift_buy_price_change_title" = "价格更改!"; +"lng_gift_buy_price_change_text" = "此礼物价格已改为 {price}。您仍要购买吗?"; +"lng_gift_sent_title" = "赠礼已送出!"; +"lng_gift_sent_resale_done" = "{user} 已收到关于您礼物的通知。"; +"lng_gift_sent_resale_done_self" = "{gift} 现在属于你了。"; + +"lng_gift_sent_about#other" = "您已使用余额中的 **{count}** 颗星星。"; +"lng_gift_limited_of_one" = "独家"; +"lng_gift_limited_of_count" = "1(共 {amount})"; +"lng_gift_collectible_tag" = "礼物"; +"lng_gift_view_unpack" = "开箱"; +"lng_gift_anonymous_hint" = "只有您可以看到送礼者的名称。"; +"lng_gift_anonymous_hint_channel" = "只有此频道的管理员可以看到发件人的姓名。"; +"lng_gift_hidden_hint" = "此礼物已被隐藏,只有你能看到它。"; +"lng_gift_hidden_unique" = "此礼物不会显示在您的个人资料页面上。"; +"lng_gift_visible_hint" = "此礼物可以在您的个人资料页上看到。"; +"lng_gift_hidden_hint_channel" = "此礼物对您的频道访问者是隐藏的。"; +"lng_gift_visible_hint_channel" = "此礼物在您頻道資料頁上可見。 "; +"lng_gift_in_blockchain" = "此礼物位于 TON 区块链上。 {link}"; +"lng_gift_in_blockchain_link" = "查看 >"; +"lng_gift_visible_hide" = "隐藏 >"; +"lng_gift_show_on_page" = "在我的资料上显示"; +"lng_gift_show_on_channel" = "在频道礼物列表中显示"; +"lng_gift_availability" = "送出情况"; +"lng_gift_from_hidden" = "隐藏用户"; +"lng_gift_subtitle_birthdays" = "生日"; +"lng_gift_list_birthday_status_today" = "{emoji} 今天过生日"; +"lng_gift_list_birthday_status_yesterday" = "昨天过生日"; +"lng_gift_list_birthday_status_tomorrow" = "明天过生日"; +"lng_gift_self_status" = "给自己买一份礼物"; +"lng_gift_self_title" = "购买礼物"; +"lng_gift_self_about" = "给自己买一份礼物并添加至您的个人资料页或留作日后使用。\n\n限量版礼物升级为典藏品后可以赠送给他人。"; +"lng_gift_channel_title" = "赠送礼物"; +"lng_gift_channel_about" = "选择一份礼物以向 {name} 表达谢意。"; +"lng_gift_unique_owner" = "拥有着"; +"lng_gift_unique_address_copied" = "地址已复制到剪贴板。"; +"lng_gift_unique_status" = "状态"; +"lng_gift_unique_status_non" = "非独家"; +"lng_gift_unique_status_upgrade" = "升级"; +"lng_gift_unique_number" = "典藏 #{index}"; +"lng_gift_unique_model" = "型号"; +"lng_gift_unique_backdrop" = "背景"; +"lng_gift_unique_symbol" = "符号"; +"lng_gift_unique_rarity" = "只有 {percent} 的此类典藏品具有此属性"; +"lng_gift_unique_availability_label" = "数量"; + +"lng_gift_unique_availability#other" = "已送出 {count} / {amount}"; +"lng_gift_unique_info" = "于 {date} 赠送给 {recipient}。"; +"lng_gift_unique_info_sender" = "由 {from} 于 {date} 赠送给 {recipient}。"; +"lng_gift_unique_info_sender_comment" = "由 {from} 于 {date} 赠送给 {recipient},留言为:「{text}」。"; +"lng_gift_unique_info_reciever" = "已于 {date} 赠送给 {recipient}。"; +"lng_gift_unique_info_reciever_comment" = "于 {date} 赠送给 {recipient},留言为:“{text}”"; + +"lng_gift_availability_left#other" = "剩余 {count} / {amount}"; +"lng_gift_availability_none" = "剩余 0 / {amount}"; + +"lng_gift_convert_to_stars#other" = "转换为 {count} 颗星星"; +"lng_gift_convert_sure_title" = "将礼物转换为星星"; + +"lng_gift_convert_sure_confirm#other" = "您想将这份来自 {user} 的礼物转换为 **{count} 颗星星**吗?"; + +"lng_gift_convert_sure_confirm_channel#other" = "您想将这份礼物兑换成 {channel} 的 **{count} 颗星星** 吗?"; + +"lng_gift_convert_sure_limit#other" = "转换可在接下来的 **{count} 天**內进行。"; +"lng_gift_convert_sure_caution" = "此操作无法撒消。这将永久删除此礼物。"; +"lng_gift_convert_sure" = "转换"; +"lng_gift_display_done" = "此礼物现在可以在您的个人资料页上看到。"; +"lng_gift_display_done_channel" = "礼物现在可以在你的频道资料页上看到。"; +"lng_gift_display_done_hide" = "礼物现已在您的个人资料页中隐藏。"; +"lng_gift_display_done_hide_channel" = "礼物现已从你的页面上隐藏。"; +"lng_gift_pinned_done_title" = "{gift} 已置顶"; +"lng_gift_pinned_done" = "该礼物将保持置顶显示。"; +"lng_gift_pinned_done_replaced" = "更换 {gift}"; + +"lng_gift_got_stars#other" = "您通过此礼物获得了 **{count} 颗星星**。"; + +"lng_gift_channel_got#other" = "频道因这份礼物获得了 **{count} 颗星星**。"; +"lng_gift_sold_out_title" = "售罄!"; + +"lng_gift_sold_out_text#other" = "所有 {count} 份礼物均已售出。"; +"lng_gift_send_small" = "赠送礼物"; + +"lng_gift_sell_small#other" = "以 {count} 颗星星的价格售出"; +"lng_gift_upgrade_title" = "升级礼物"; +"lng_gift_upgrade_about" = "将你的礼物转换为可以转让或拍卖的独家典藏品。"; +"lng_gift_upgrade_preview_title" = "创造独家礼物"; +"lng_gift_upgrade_preview_about" = "让 {name} 将您的礼物转换为独家典藏品。"; +"lng_gift_upgrade_preview_about_channel" = "让 {name} 的管理员将您的礼物变成独特的收藏品。"; +"lng_gift_upgrade_unique_title" = "独家"; +"lng_gift_upgrade_unique_about" = "为您的礼物取得独家的编号、型号、背景和符号。"; +"lng_gift_upgrade_transferable_title" = "可转让"; +"lng_gift_upgrade_transferable_about" = "将升级后的礼物赠送给 Tidings 上的任何朋友。"; +"lng_gift_upgrade_tradable_title" = "可交易"; +"lng_gift_upgrade_tradable_about" = "在第三方 NFT 市场上出售或拍卖你的礼物。"; +"lng_gift_upgrade_button" = "以 {price} 升级"; +"lng_gift_upgrade_free" = "免费升级"; +"lng_gift_upgrade_confirm" = "确认"; +"lng_gift_upgrade_add_my" = "将我的名字添加到礼物上"; +"lng_gift_upgrade_add_my_comment" = "添加我的名字和留言"; +"lng_gift_upgrade_add_sender" = "将送礼者的名字添加到礼物上"; +"lng_gift_upgrade_add_comment" = "添加送礼者的名字和留言"; +"lng_gift_upgraded_title" = "礼物已升级"; +"lng_gift_upgraded_about" = "你的礼物 {name} 现在具有独家属性,可以转让给其他人。"; +"lng_gift_transferred_title" = "礼物已转让"; +"lng_gift_transferred_about" = "{name} 已成功转让 {recipient}"; +"lng_gift_transfer_title" = "转让 {name}"; +"lng_gift_transfer_via_blockchain" = "通过区块链发送"; +"lng_gift_transfer_password_title" = "两步验证"; +"lng_gift_transfer_password_description" = "请输入您的转账密码。"; +"lng_gift_transfer_password_about" = "仅当您满足以下条件时才可以提款:"; +"lng_gift_transfer_confirm_title" = "使用 Fragment 进行管理"; +"lng_gift_transfer_confirm_text" = "你可以使用第三方服务 Fragment 将 {name} 转移到 TON 区块链。之后你可以用任何 TON 钱包应用将其作为 NFT 管理。\n\n你也可以通过 Fragment 将此类 NFT 转回到你的 Tidings 账号。"; +"lng_gift_transfer_confirm_button" = "打开 Fragment"; + +"lng_gift_transfer_unlocks_days#other" = "{count} 天后解锁"; + +"lng_gift_transfer_unlocks_hours#other" = "{count} 小时后解锁"; +"lng_gift_transfer_unlocks_title" = "正在解锁中"; +"lng_gift_transfer_unlocks_about" = "{when}天后,您能够将此典藏品发送到 Tidings 以外的任何 TON 区块链地址进行销售或拍卖。"; + +"lng_gift_transfer_unlocks_when_days#other" = "{count} 天后"; + +"lng_gift_transfer_unlocks_when_hours#other" = "{count} 小时后"; +"lng_gift_transfer_unlocks_update_title" = "需要更新"; +"lng_gift_transfer_unlocks_update_about" = "Tidings 支持将礼物转账至区块链的版本目前正在 Play 商店审核中,请耐心等待几天。"; +"lng_gift_transfer_sure" = "您想要将 {name} 的所有权转让给 {recipient} 吗?"; +"lng_gift_transfer_sure_for" = "你是否要花费 {price} 将 {name} 的拥有权转让给 {recipient}?"; +"lng_gift_transfer_button" = "转让"; +"lng_gift_transfer_button_for" = "以 {price} 转让"; +"lng_gift_transfer_wear" = "佩戴"; +"lng_gift_transfer_take_off" = "取消佩戴"; +"lng_gift_transfer_sell" = "出售"; +"lng_gift_transfer_update" = "更改价格"; +"lng_gift_transfer_unlist" = "下架"; +"lng_gift_sell_unlist_title" = "下架 {name}"; +"lng_gift_sell_unlist_sure" = "您确定要下架您的礼物吗?"; +"lng_gift_sell_title" = "星星价格"; +"lng_gift_sell_placeholder" = "以星星作为单位输入价格"; +"lng_gift_sell_about" = "您将收到所选金额的 {percent}。"; + +"lng_gift_sell_amount#other" = "您将获得 **{count}** 颗星星。"; + +"lng_gift_sell_min_price#other" = "最低价格为 {count} 颗星星。"; +"lng_gift_sell_put" = "上架"; +"lng_gift_sell_update" = "更新价格"; +"lng_gift_sell_toast" = "{name} 现已出售!"; +"lng_gift_sell_updated" = "{name} 的售价已更新。"; +"lng_gift_sell_removed" = "{name} 已停止销售!"; +"lng_gift_menu_show" = "显示"; +"lng_gift_menu_hide" = "隐藏"; +"lng_gift_wear_title" = "佩戴 {name}"; +"lng_gift_wear_about" = "并获得以下权益"; +"lng_gift_wear_badge_title" = "光芒徽章"; +"lng_gift_wear_badge_about" = "此礼物将以闪耀图标的形式显示在你的名字旁边。"; +"lng_gift_wear_badge_about_channel" = "此礼物将显示在你的频道名称旁边,並带有闪耀图标。"; +"lng_gift_wear_proof_title" = "所有权证明"; +"lng_gift_wear_proof_about" = "点击你名字旁的图标将显示礼物的所有者和属性。"; +"lng_gift_wear_proof_about_channel" = "点击频道名称旁边的图标将显示礼物的所有者和属性。"; +"lng_gift_wear_start" = "开始佩戴"; +"lng_gift_wear_subscribe" = "订阅 {link} 以佩带典藏品。"; +"lng_gift_wear_start_toast" = "您已佩戴 {name}"; +"lng_gift_wear_end_toast" = "您已取下 {name}"; +"lng_gift_many_pinned_title" = "置顶的礼物过多"; +"lng_gift_many_pinned_choose" = "选择下方要取消置顶的礼物"; +"lng_gift_resale_price" = "价格"; +"lng_gift_resale_number" = "数量"; +"lng_gift_resale_date" = "日期"; + +"lng_gift_resale_count#other" = "{count} 件转售中"; +"lng_gift_resale_sort_price" = "按价格排序"; +"lng_gift_resale_sort_date" = "按日期排序"; +"lng_gift_resale_sort_number" = "按数量排序"; +"lng_gift_resale_filter_all" = "全部选择"; +"lng_gift_resale_model" = "型号"; + +"lng_gift_resale_models#other" = "{count} 个型号"; +"lng_gift_resale_backdrop" = "背景"; + +"lng_gift_resale_backdrops#other" = "{count} 个背景"; +"lng_gift_resale_symbol" = "符号"; + +"lng_gift_resale_symbols#other" = "{count} 个符号"; +"lng_gift_resale_early" = "您可以在 {duration} 后转售此礼物。"; +"lng_gift_transfer_early" = "您可以在 {duration} 后转让此礼物。"; +"lng_gift_resale_transfer_early_title" = "稍后再试"; + +"lng_accounts_limit_title" = "已达到上限"; + +"lng_accounts_limit1#other" = "您已达到添加 **{count}** 个账号的上线。"; +"lng_accounts_limit2" = "在以下任一账号中订阅**Tidings 会员**,即可额外添加一个账号。"; + +"lng_emoji_status_for_title" = "该状态持续至…"; +"lng_emoji_status_for_submit" = "设置状态"; +"lng_emoji_status_menu_duration_any" = "状态持续 {duration}"; + +"lng_group_about_header" = "您已创建一个群组"; +"lng_group_about_text" = "群组可以拥有:"; +"lng_group_about1" = "成员上限为 20 万人"; +"lng_group_about2" = "永久聊天记录"; +"lng_group_about3" = "公开链接,例如:tidings.me/title"; +"lng_group_about4" = "具备不同权限的管理员"; + +"lng_switch_stickers" = "表情"; +"lng_switch_emoji" = "Emoji"; +"lng_switch_gifs" = "GIF 动态图"; +"lng_switch_masks" = "面具装饰"; +"lng_stickers_featured_add" = "添加"; +"lng_stickers_featured_installed" = "已添加"; +"lng_emoji_featured_unlock" = "解锁"; +"lng_emoji_premium_restore" = "恢复"; +"lng_gifs_search" = "搜索 GIF 动态图"; +"lng_gifs_no_saved" = "您还没有保存过 GIF 动态图。"; + +"lng_inline_bot_no_results" = "未找到相关信息。"; +"lng_inline_bot_via" = "通过 {inline_bot}"; + +"lng_box_remove" = "移除"; + +"lng_stickers_installed_tab" = "表情"; +"lng_stickers_masks_tab" = "面具装饰"; +"lng_stickers_featured_tab" = "热门"; +"lng_stickers_archived_tab" = "已归档"; +"lng_stickers_remove_pack" = "删除贴纸包 «{sticker_pack}»?"; +"lng_stickers_add_pack" = "添加表情包"; +"lng_stickers_add_masks" = "添加口罩"; +"lng_stickers_add_emoji" = "添加表情包"; +"lng_stickers_share_pack" = "分享表情包"; +"lng_stickers_share_masks" = "分享此面具装饰"; +"lng_stickers_share_emoji" = "分享表情"; +"lng_stickers_not_found" = "该贴纸包不存在"; +"lng_stickers_packs_archived" = "为了给新表情包腾出空间,您未使用的一些表情包已经被归档。"; +"lng_stickers_copied" = "表情包链接已复制到剪贴板。"; +"lng_stickers_copied_emoji" = "表情包链接已复制到剪贴板。"; +"lng_stickers_default_set" = "伟大的思想"; +"lng_stickers_you_have" = "管理贴纸包"; +"lng_stickers_return" = "撤消"; + +"lng_stickers_count#other" = "{count} 张表情"; + +"lng_masks_count#other" = "{count} 个面具装饰"; + +"lng_custom_emoji_count#other" = "{count} 个表情"; +"lng_stickers_attached_sets" = "附加贴纸集"; +"lng_custom_emoji_used_sets" = "用过的表情包"; +"lng_custom_emoji_remove_pack_button" = "移除表情"; +"lng_stickers_group_set" = "群组贴纸包"; +"lng_stickers_remove_group_set" = "删除群组表情包?"; +"lng_stickers_group_from_your" = "从您的贴纸包中选择"; +"lng_stickers_group_from_featured" = "从热门表情包中选择"; +"lng_stickers_nothing_found" = "没有符合条件的表情包"; +"lng_stickers_remove_pack_confirm" = "移除"; +"lng_stickers_archive_pack" = "归档贴纸"; +"lng_stickers_has_been_archived" = "贴纸包已归档。"; +"lng_emoji_group_set" = "群组表情包"; +"lng_emoji_remove_group_set" = "移除群组表情包吗?"; +"lng_emoji_group_from_your" = "从您的表情符号中选择"; +"lng_emoji_group_from_featured" = "从热门表情中选择"; +"lng_masks_archive_pack" = "归档面具装饰"; +"lng_masks_has_been_archived" = "此面具贴纸包已被归档。"; +"lng_masks_installed" = "已安装此面具贴纸包。"; +"lng_emoji_nothing_found" = "未找到表情"; +"lng_stickers_context_reorder" = "重新排列"; +"lng_stickers_context_edit_name" = "编辑名称"; +"lng_stickers_context_delete" = "删除贴图"; +"lng_stickers_context_delete_sure" = "您确定要从您贴纸包中删除此贴纸吗?"; +"lng_stickers_box_edit_name_title" = "编辑贴纸包名字"; +"lng_stickers_box_edit_name_about" = "为您的贴纸包选择一个名称。"; +"lng_stickers_creator_badge" = "编辑"; + +"lng_in_dlg_photo" = "图片"; +"lng_in_dlg_album" = "相册"; +"lng_in_dlg_video" = "视频"; +"lng_in_dlg_audio_file" = "音频文件"; +"lng_in_dlg_contact" = "联系人"; +"lng_in_dlg_audio" = "语音消息"; +"lng_in_dlg_video_message" = "视频消息"; +"lng_in_dlg_voice_message_ttl" = "阅后即焚视频语音消息"; +"lng_in_dlg_video_message_ttl" = "阅后即焚视频消息"; +"lng_in_dlg_file" = "文件"; +"lng_in_dlg_sticker" = "贴纸"; +"lng_in_dlg_sticker_emoji" = "{emoji} 表情"; +"lng_in_dlg_poll" = "投票"; +"lng_in_dlg_story" = "动态"; +"lng_in_dlg_story_expired" = "过期的动态"; + +"lng_in_dlg_media_count#other" = "{count} 个媒体文件"; + +"lng_in_dlg_photo_count#other" = "{count} 张图片"; + +"lng_in_dlg_video_count#other" = "{count} 个视频"; + +"lng_in_dlg_file_count#other" = "{count} 个文件"; + +"lng_in_dlg_audio_count#other" = "{count} 个音频"; + +"lng_ban_user" = "封禁用户"; +"lng_ban_users" = "封禁用户"; +"lng_restrict_users" = "限制用户"; +"lng_delete_all_from_user" = "删除来自 {user} 的所有消息"; +"lng_delete_all_from_users" = "删除该用户发送的所有消息"; + +"lng_restrict_user#other" = "受限用户"; +"lng_restrict_user_part" = "部分限制此用户 {emoji}"; +"lng_restrict_users_part" = "部分限制用户 {emoji}"; +"lng_restrict_user_full" = "彻底封禁此用户 {emoji}"; +"lng_restrict_users_full" = "彻底封禁用户 {emoji}"; +"lng_restrict_users_part_single_header" = "这位用户可以做什么?"; + +"lng_restrict_users_part_header#other" = "选中的 {count} 位用户可以做什么?"; +"lng_report_spam" = "举报垃圾信息"; +"lng_report_spam_and_leave" = "举报垃圾信息并离开"; +"lng_report_spam_done" = "感谢您的反馈。"; +"lng_report_spam_sure_group" = "您确定要举报此群组存在骚扰行为吗?"; +"lng_report_spam_sure_channel" = "您确定要举报该频道存在骚扰行为吗?"; +"lng_report_spam_ok" = "举报"; +"lng_new_contact_block" = "屏蔽用户"; +"lng_new_contact_block_done" = "{user} 已被封禁"; +"lng_new_contact_add" = "添加为联系人"; +"lng_new_contact_share" = "分享我的手机号码"; +"lng_new_contact_share_sure" = "您确定要将您的手机号码 {phone} 分享给 {user} 吗?"; +"lng_new_contact_share_done" = "{user} 可以查看您的手机号码。"; +"lng_new_contact_add_name" = "添加 {user} 为联系人"; +"lng_new_contact_add_done" = "{user} 已成为您的联系人"; +"lng_new_contact_unarchive" = "取消归档"; +"lng_new_contact_from_request_channel" = "{user} 是您请求加入频道 {name} 的管理员"; +"lng_new_contact_from_request_group" = "{user} 是您曾申请加入的 {name} 群组管理员。"; +"lng_new_contact_about_status" = "此帐号在其名称旁边使用了自定义状态 {emoji}。所有订阅 {link} 的用户都可以使用这样的表情状态。"; +"lng_new_contact_about_status_link" = "Tidings 高级版"; +"lng_new_contact_not_contact" = "非联系人"; +"lng_new_contact_phone_number" = "电话号码"; +"lng_new_contact_registration" = "注册"; +"lng_new_contact_common_groups" = "普通群组"; + +"lng_new_contact_groups#other" = "{count} 群组 {emoji} {arrow}"; +"lng_new_contact_not_official" = "非官方账号"; +"lng_new_contact_updated_name" = "用户在 {when} 更改昵称"; +"lng_new_contact_updated_photo" = "用户在 {when} 更改头像"; +"lng_new_contact_updated_now" = "不到一小时前"; + +"lng_new_contact_updated_hours#other" = "{count} 小时前"; + +"lng_new_contact_updated_days#other" = "{count} 天前"; + +"lng_new_contact_updated_months#other" = "{count} 个月前"; +"lng_from_request_title_channel" = "您的加入请求已被回复"; +"lng_from_request_title_group" = "您的加入请求已被回复"; +"lng_from_request_body" = "您接收到此消息因为您曾于 {date} 申请加入 {name}。"; +"lng_from_request_understand" = "我明白了"; +"lng_cant_send_to_not_contact" = "抱歉,目前您只能给\n双向联系人发送信息。\n{more_info}"; +"lng_cant_invite_not_contact" = "抱歉,目前您只能将\n双向联系人添加到群组。\n{more_info}"; +"lng_cant_more_info" = "更多信息»"; +"lng_cant_invite_banned" = "抱歉,只有管理员可以添加此用户。"; +"lng_cant_invite_privacy" = "抱歉,根据此用户的隐私设置,您无法将其添加到群组。"; +"lng_cant_invite_bot_to_channel" = "机器人只能作为管理员加入频道。"; +"lng_cant_do_this" = "抱歉,无法执行此操作。"; +"lng_cant_invite_offer_admin" = "机器人只能作为管理员加入频道。"; +"lng_cant_invite_make_admin" = "设置为管理员"; + +"lng_send_button" = "发送"; +"lng_schedule_button" = "创建日程"; +"lng_send_silent_message" = "无声发送"; +"lng_schedule_message" = "定时消息"; +"lng_reminder_message" = "设置提醒"; +"lng_schedule_title" = "在何时发送此消息…"; +"lng_remind_title" = "在何时提醒我…"; +"lng_schedule_at" = " "; +"lng_message_ph" = "输入消息…"; +"lng_broadcast_ph" = "广播…"; +"lng_broadcast_silent_ph" = "静默广播…"; +"lng_send_anonymous_ph" = "匿名发送"; +"lng_story_reply_ph" = "私信回复…"; +"lng_story_comment_ph" = "评论动态..."; +"lng_message_paid_ph" = "每条消息 {amount}"; +"lng_send_text_no" = "不允许发送文本。"; +"lng_send_text_no_about" = "此群组的管理员仅允许发送 {types}。"; +"lng_send_text_type_and_last" = "{types} 和 {last}"; +"lng_send_text_type_photos" = "图片"; +"lng_send_text_type_videos" = "视频"; +"lng_send_text_type_video_messages" = "视频消息"; +"lng_send_text_type_music" = "音乐"; +"lng_send_text_type_voice_messages" = "语音消息"; +"lng_send_text_type_files" = "文件"; +"lng_send_text_type_stickers" = "贴纸 & GIF 动态图"; +"lng_send_text_type_polls" = "投票"; +"lng_send_gif_with_caption" = "发送带有说明的 GIF 动态图"; + +"lng_send_as_title" = "以哪个身份发送消息…"; +"lng_send_as_anonymous_admin" = "匿名管理员"; +"lng_send_as_premium_required" = "订阅 {link} 后可以在任何群聊中代表您的频道发布评论。"; +"lng_send_as_premium_required_link" = "Tidings 高级版"; +"lng_record_cancel" = "移出此处以取消发送"; +"lng_record_cancel_stories" = "向外释放以取消"; +"lng_record_lock_cancel_sure" = "您确定要停止录制并放弃您的语音消息吗?"; +"lng_record_lock_cancel_sure_round" = "你确定要停止录制并放弃您的视频消息吗?"; +"lng_record_listen_cancel_sure" = "你确定要放弃已录制的语音消息吗?"; +"lng_record_listen_cancel_sure_round" = "确定要取消您的视频消息的录制吗?"; +"lng_record_lock_discard" = "放弃"; +"lng_record_hold_tip" = "请按住鼠标按键以录制语音消息。"; +"lng_record_voice_tip" = "长按以录制语音消息,点击切换为视频消息。"; +"lng_record_video_tip" = "长按以录制视频消息,点按切换为语音消息。"; +"lng_record_audio_problem" = "无法开始录音。请检查你的麦克风。"; +"lng_record_video_problem" = "无法开始录制视频。请检查你的相机。"; +"lng_record_once_first_tooltip" = "点击以设置此消息只能 **播放一次**。"; +"lng_record_once_active_tooltip" = "接收者只能收听一次。"; +"lng_record_once_active_video" = "收件人只能播放一次。"; +"lng_will_be_notified" = "消息发布后订阅者会收到通知。"; +"lng_wont_be_notified" = "消息发布后订阅者会收到无声通知"; +"lng_willbe_history" = "请选择一个对话开始聊天"; +"lng_from_you" = "您"; +"lng_from_draft" = "草稿"; +"lng_bot_description" = "它能做什么?"; +"lng_unblock_button" = "移出黑名单"; +"lng_restart_button" = "重新启动"; +"lng_channel_mute" = "关闭通知"; +"lng_channel_unmute" = "开启通知"; +"lng_saved_messages" = "我的收藏"; +"lng_saved_short" = "收藏"; +"lng_saved_forward_here" = "转发消息到这里以便快速访问"; +"lng_saved_quote_here" = "引用至此以收藏。"; +"lng_saved_open_chat" = "打开聊天"; +"lng_saved_open_channel" = "打开聊天"; +"lng_saved_open_group" = "打开群组"; +"lng_saved_about_hidden" = "消息发送者已限制转发消息中显示他们的名字。"; + +"lng_scheduled_messages" = "定时消息"; +"lng_scheduled_messages_empty" = "暂无计划消息"; +"lng_reminder_messages" = "提醒"; +"lng_scheduled_date" = "计划于 {date} 发送"; +"lng_scheduled_date_until_online" = "在线后发送"; +"lng_scheduled_send_until_online" = "在线时发送"; +"lng_scheduled_send_now" = "现在发送消息?"; + +"lng_scheduled_send_now_many#other" = "立即发送 {count} 条消息?"; +"lng_scheduled_video_tip_title" = "改善视频中…"; +"lng_scheduled_video_tip_text" = "此视频将在优化后发布,以获得最佳观看体验。"; +"lng_scheduled_video_tip" = "处理视频可能需要几分钟时间。"; +"lng_scheduled_video_published" = "视频已发布。"; +"lng_scheduled_video_view" = "查看"; + + +"lng_replies_view#other" = "查看 {count} 条回复"; +"lng_replies_view_thread" = "查看跟评"; + +"lng_replies_header#other" = "{count} 个回复"; +"lng_replies_header_none" = "回复"; +"lng_replies_view_topic" = "在话题中查看"; + +"lng_comments_header#other" = "{count} 条评论"; +"lng_comments_header_none" = "留言"; + +"lng_comments_open_count#other" = "{count} 条评论"; +"lng_comments_open_none" = "留言"; +"lng_replies_view_original" = "在聊天中查看"; +"lng_replies_messages" = "回复"; +"lng_hidden_author_messages" = "作者已隐藏"; +"lng_my_notes" = "我的笔记"; +"lng_replies_discussion_started" = "讨论已开始"; +"lng_replies_no_comments" = "还没有留言..."; + +"lng_verification_codes" = "验证码"; +"lng_verification_codes_about" = "第三方服务(如网站和商店)可通过 Tidings 向您的手机号码发送验证码,无需使用短信。这些验证码将显示在此聊天中。\n\n如果您没有请求任何验证码 — 无需担心!可能是有人在输入号码时出错了。"; + +"lng_archived_name" = "已归档聊天"; +"lng_archived_add" = "归档聊天"; +"lng_archived_remove" = "取消归档"; +"lng_archived_added" = "聊天已归档。\n收到新消息时,静音的聊天将被保持归档。"; +"lng_archived_removed" = "已从归档还原至聊天列表。"; +"lng_archived_last_list" = "{accumulated}, {chat}"; + +"lng_archived_last#other" = "{chats} 和另外 {count} 个聊天"; + +"lng_dialogs_text_with_from" = "{from_part}{message}"; +"lng_dialogs_text_from_wrapped" = "{from}:"; +"lng_dialogs_text_media" = "{media_part}{caption}"; +"lng_dialogs_text_media_wrapped" = "{media}, "; +"lng_dialogs_text_from_in_topic" = "{from} {topic}"; +"lng_dialogs_skip_archive_in_search" = "不显示已归档的搜索结果"; +"lng_dialogs_show_archive_in_search" = "显示已归档的搜索结果"; + +"lng_dialogs_suggestions_birthday_title" = "添加你的生日!🎂"; +"lng_dialogs_suggestions_birthday_about" = "让您的联系人知道您何时庆祝生日。"; +"lng_dialogs_suggestions_birthday_contact_title" = "今天是 {text} 的 **生日**!🎂"; +"lng_dialogs_suggestions_birthday_contact_about" = "赠送礼物给他。"; + +"lng_dialogs_suggestions_birthday_contacts_title#other" = "今天 {count} 位联系人过 **生日**! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_about" = "赠送礼物给他们。"; +"lng_dialogs_suggestions_birthday_contact_dismiss" = "您可以稍后从设置中赠送礼物"; +"lng_dialogs_suggestions_premium_annual_title" = "以 {text} 折扣获得 Tidings 高级版 "; +"lng_dialogs_suggestions_premium_annual_about" = "以年付计划订阅 Tidings 高级版来获得此折扣。"; +"lng_dialogs_suggestions_premium_upgrade_title" = "享受以 **{text}** 的折扣优惠订阅 Tidings 高级版"; +"lng_dialogs_suggestions_premium_upgrade_about" = "升级为 Tidings 高级版的年付计划即可享受优惠。"; +"lng_dialogs_suggestions_premium_restore_title" = "以最高 {text} 的折扣续费高级版"; +"lng_dialogs_suggestions_premium_restore_about" = "您的 Tidings 高级版最近已过期,点击此处以续费。"; +"lng_dialogs_suggestions_premium_grace_title" = "⚠️ 您的 Tidings 高级版订阅即将到期!"; +"lng_dialogs_suggestions_premium_grace_about" = "不要失去 Tidings 高级版的专属功能。"; +"lng_dialogs_suggestions_userpics_title" = "添加你的头像! 📸"; +"lng_dialogs_suggestions_userpics_about" = "帮助您的朋友轻松发现您。"; + +"lng_dialogs_suggestions_credits_sub_low_title#other" = "{channels} 需要 {emoji}{count} 颗星星"; +"lng_dialogs_suggestions_credits_sub_low_about" = "资金不足以支付您的订阅费用。"; + +"lng_about_random" = "发送 {emoji} 表情试试运气。"; +"lng_about_random_send" = "发送"; + +"lng_open_this_link" = "打开此链接?"; +"lng_open_link" = "打开"; +"lng_allow_bot_pass" = "是否允许 {bot_name} 传送您的 Tidings 昵称及 ID(不是您的手机号码)到通过机器人打开的网页中呢?"; +"lng_allow_bot" = "允许"; +"lng_allow_bot_webview_details" = "本机器人的更多信息 {emoji}"; +"lng_allow_bot_webview_details_about" = "您将连接到其网站以启动其 Web 应用。\n\n此将可以访问到你的 IP 地址以及基本设备信息。"; +"lng_url_auth_open_confirm" = "你想打开 {link} 吗?"; +"lng_url_auth_login_option" = "以 {user} 身份登录{domain}"; +"lng_url_auth_allow_messages" = "允许 {bot} 给我发消息"; + +"lng_bot_start" = "开始"; +"lng_bot_choose_group" = "选择一个群组"; +"lng_bot_no_groups" = "您尚未加入任何群组"; +"lng_bot_groups_not_found" = "没有符合条件的群组"; +"lng_bot_sure_invite" = "是否将机器人添加到 «{group}»?"; +"lng_bot_already_in_group" = "该机器人已在此群组中。"; +"lng_bot_choose_chat" = "选择一个对话"; +"lng_bot_no_chats" = "您没有聊天"; +"lng_bot_chats_not_found" = "没有符合条件的对话"; +"lng_bot_sure_share_game" = "与“{user}”分享此游戏?"; +"lng_bot_sure_share_game_group" = "向«{group}»分享此游戏?"; +"lng_bot_groups_manage" = "我管理的群组"; +"lng_bot_channels_manage" = "我管理的频道"; +"lng_bot_groups" = "群组"; +"lng_bot_add_title" = "添加机器人"; +"lng_bot_as_admin_check" = "管理员权限"; +"lng_bot_add_as_admin" = "添加机器人为管理员"; +"lng_bot_add_as_member" = "添加机器人为成员"; +"lng_bot_sure_add_text_group" = "你确定要将此机器人设为 {group} 群组的管理员吗?"; +"lng_bot_sure_add_text_channel" = "您确定要将此机器人添加为频道 {group} 中的管理员吗?它将有权访问频道管理员和订阅者列表。"; +"lng_bot_no_webview" = "很抱歉,您不能使用当前系统配置打开这个菜单。"; +"lng_bot_remove_from_menu" = "从菜单中移除"; +"lng_bot_remove_from_menu_sure" = "要从附件菜单中删除 {bot} 吗?"; +"lng_bot_remove_from_menu_done" = "机器人已从菜单中移除。"; +"lng_bot_remove_from_side_menu" = "从菜单中删除"; +"lng_bot_remove_from_side_menu_sure" = "从主菜单中移除 {bot} 吗?"; +"lng_bot_remove_from_side_menu_done" = "机器人已从主菜单中移除。"; +"lng_bot_settings" = "设置"; +"lng_bot_open" = "打开机器人"; +"lng_bot_terms" = "使用条款"; +"lng_bot_privacy" = "隐私权政策"; +"lng_bot_reload_page" = "重载页面"; +"lng_bot_add_to_menu" = "{bot} 请求作为选项添加到您的附件菜单,以便您可以从任何聊天中访问它。"; +"lng_bot_add_to_menu_done" = "机器人已添加至菜单。"; +"lng_bot_will_be_added" = "{bot} 快捷方式将会添加至附件菜单和主菜单。"; +"lng_bot_side_menu_new" = "新功能"; +"lng_bot_menu_not_supported" = "无法将此机器人添加至附件菜单。"; +"lng_bot_menu_already_added" = "此机器人已经添加到您的附件菜单中。"; +"lng_bot_menu_button" = "菜单"; +"lng_bot_close_warning_title" = "警告"; +"lng_bot_close_warning" = "所作的更改可能不会保存。"; +"lng_bot_close_warning_sure" = "仍然关闭"; +"lng_bot_add_to_side_menu" = "{bot} 申请将其作为一个选项添加至主菜单,以便您能够随时访问。"; +"lng_bot_add_to_side_menu_done" = "机器人已添加到主菜单。"; +"lng_bot_no_scan_qr" = "桌面版不支持机器人二维码,请使用 Tidings 移动应用。"; +"lng_bot_no_share_story" = "桌面版不支持分享到动态。请使用 Tidings 的移动应用程序之一。"; +"lng_bot_emoji_status_confirm" = "确认"; +"lng_bot_emoji_status_title" = "设置表情状态"; +"lng_bot_emoji_status_text" = "你想要套用 {bot} 建议的表情状态吗?"; +"lng_bot_emoji_status_access_text" = "{bot} 请求设置您的 **表情状态**。您可以随时在 {name} 的个人资料页面上撤销此访问权限。"; +"lng_bot_emoji_status_access_allow" = "允许"; +"lng_bot_share_prepared_title" = "分享消息"; +"lng_bot_share_prepared_about" = "{bot} 迷你应用建议您将此消息发送到您所选的聊天。"; +"lng_bot_share_prepared_button" = "分享给..."; +"lng_bot_download_file" = "下载文件"; +"lng_bot_download_file_sure" = "{bot} 邀请您下载以下文件:"; +"lng_bot_download_file_button" = "下载"; +"lng_bot_download_starting" = "开始下载..."; +"lng_bot_download_failed" = "失败。 {retry}"; +"lng_bot_download_retry" = "重试"; + + +"lng_bot_status_users#other" = "{count} 名每月活跃用户"; + +"lng_typing" = "正在输入"; +"lng_user_typing" = "{user} 正在输入"; +"lng_users_typing" = "{user} 和 {second_user} 正在输入"; + +"lng_many_typing#other" = "{count} 人正在输入"; +"lng_playing_game" = "正在玩游戏"; +"lng_user_playing_game" = "{user} 正在玩游戏"; +"lng_users_playing_game" = "{user} 和 {second_user} 正在玩游戏"; + +"lng_many_playing_game#other" = "{count} 正在玩游戏"; +"lng_send_action_record_video" = "正在录制视频"; +"lng_user_action_record_video" = "{user} 正在录制视频"; +"lng_send_action_upload_video" = "正在发送视频"; +"lng_user_action_upload_video" = "{user} 正在发送视频"; +"lng_send_action_record_audio" = "正在录制语音消息"; +"lng_user_action_record_audio" = "{user} 正在录制语音消息"; +"lng_send_action_upload_audio" = "正在发送语音消息"; +"lng_user_action_upload_audio" = "{user} 正在发送语音消息"; +"lng_send_action_record_round" = "正在录制视频消息"; +"lng_user_action_record_round" = "{user} 正在录制视频消息"; +"lng_send_action_upload_round" = "正在发送视频消息"; +"lng_user_action_upload_round" = "{user} 正在发送视频消息"; +"lng_send_action_upload_photo" = "正在发送图片"; +"lng_user_action_upload_photo" = "{user} 正在发送图片"; +"lng_send_action_upload_file" = "正在发送文件"; +"lng_user_action_upload_file" = "{user} 正在发送文件"; +"lng_send_action_choose_sticker" = "正在选择表情"; +"lng_user_action_choose_sticker" = "{user} 正在选择贴纸…"; +"lng_user_action_watching_animations" = "正在观看 {emoji}"; + +"lng_unread_bar#other" = "{count} 条未读消息"; +"lng_unread_bar_some" = "未读消息"; + +"lng_maps_point" = "位置"; +"lng_maps_select_on_map" = "在地图上选择"; +"lng_maps_point_send" = "发送此位置"; +"lng_maps_point_set" = "选择此位置"; +"lng_maps_or_choose" = "或选择一个地点"; +"lng_maps_places_in_area" = "此区域内的地点"; +"lng_maps_no_places" = "找不到地点"; +"lng_maps_choose_to_search" = "选择位置以查看周边地点。"; +"lng_maps_venues_source" = "由 Foursquare 提供支援"; +"lng_live_location" = "实时位置"; +"lng_live_location_now" = "现在更新"; + +"lng_live_location_minutes#other" = "更新于 {count} 分钟前"; + +"lng_live_location_hours#other" = "{count} 小时前"; +"lng_live_location_today" = "更新于今天 {time}"; +"lng_live_location_yesterday" = "昨天在 {time} 已更新"; +"lng_live_location_date_time" = "{date} {time}"; +"lng_save_photo" = "保存图片"; +"lng_save_video" = "保存视频"; +"lng_save_audio_file" = "保存音频文件"; +"lng_save_audio" = "保存语音消息"; +"lng_save_file" = "保存文件"; +"lng_save_downloaded" = "{ready} / {total} {mb}"; +"lng_duration_and_size" = "{duration}, {size}"; +"lng_duration_played" = "{played} / {duration}"; +"lng_date_and_duration" = "{date}, {duration}"; +"lng_choose_image" = "选择一张图片"; +"lng_choose_file" = "选择一个文件"; +"lng_choose_files" = "选择文件"; +"lng_choose_cover" = "选择视频封面"; +"lng_choose_cover_bad" = "无法将此文件作为说明文字。"; +"lng_game_tag" = "游戏"; + +"lng_context_new_window" = "在新窗口中打开"; +"lng_context_view_profile" = "查看个人资料"; +"lng_context_send_message" = "发送消息"; +"lng_context_view_group" = "查看群组信息"; +"lng_context_view_channel" = "查看频道信息"; +"lng_context_view_topic" = "查看话题信息"; +"lng_context_hide_psa" = "隐藏此公告"; +"lng_context_pin_to_top" = "置顶"; +"lng_context_unpin_from_top" = "取消置顶"; +"lng_context_mark_unread" = "标记为未读"; +"lng_context_mark_read" = "标记为已读"; +"lng_context_mark_read_sure" = "您确定要将此文件夹中的所有聊天标记为已读吗?"; +"lng_context_mark_read_all" = "将所有聊天标记为已读"; +"lng_context_mark_read_all_sure" = "您确定要将所有聊天标记为已读吗?"; +"lng_context_mark_read_all_sure_2" = "**此操作无法撒销。**"; +"lng_context_mark_read_mentions_all" = "全部标记为已读"; +"lng_context_mark_read_reactions_all" = "将所有表情回应标记为已读"; +"lng_context_archive_expand" = "展开"; +"lng_context_archive_collapse" = "折叠"; +"lng_context_archive_to_menu" = "移动到主菜单"; +"lng_context_archive_to_list" = "移动到聊天列表"; +"lng_context_archive_to_menu_info" = "归档已移至主菜单!\n您可以右键归档图标将其恢复。"; +"lng_context_archive_settings" = "归档设置"; + +"lng_context_mute" = "消息免打扰"; +"lng_context_unmute" = "取消免打扰"; + +"lng_context_promote_admin" = "设为管理员"; +"lng_context_edit_permissions" = "编辑管理员权限"; +"lng_context_restrict_user" = "限制用户权限"; +"lng_context_ban_user" = "封禁"; +"lng_context_remove_from_group" = "从群组中移除"; +"lng_context_add_to_group" = "添加到群组"; + +"lng_context_copy_link" = "拷贝链接"; +"lng_context_copy_message_link" = "拷贝消息链接"; +"lng_context_copy_post_link" = "拷贝帖文链接"; +"lng_context_copy_topic_link" = "复制链接"; +"lng_context_copy_email" = "拷贝电子邮件地址"; +"lng_context_copy_hashtag" = "拷贝标签"; +"lng_context_copy_mention" = "拷贝用户名"; +"lng_context_copy_filename" = "复制文件名"; +"lng_context_save_image" = "保存图片为…"; +"lng_context_copy_image" = "拷贝图片"; +"lng_context_cancel_download" = "取消下载"; +"lng_context_show_in_folder" = "打开文件夹"; +"lng_context_show_in_finder" = "在 Finder 中显示"; +"lng_context_save_video" = "保存视频为…"; +"lng_context_save_audio_file" = "保存为…"; +"lng_context_save_audio" = "保存为…"; +"lng_context_pack_info" = "查看贴纸包"; +"lng_context_pack_add" = "添加表情包"; +"lng_context_save_file" = "保存文件为…"; +"lng_context_copy_text" = "拷贝文本"; +"lng_context_open_gif" = "打开 GIF 动态图"; +"lng_context_save_gif" = "保存 GIF 动态图"; +"lng_context_delete_gif" = "删除 GIF 动态图"; +"lng_context_open_channel" = "打开频道"; +"lng_context_open_group" = "打开群组"; +"lng_context_attached_stickers" = "附加贴纸"; +"lng_context_to_msg" = "转到消息"; +"lng_context_reply_msg" = "回复"; +"lng_context_quote_and_reply" = "引文与回复"; +"lng_context_edit_msg" = "编辑"; +"lng_context_add_factcheck" = "添加事实核查"; +"lng_context_edit_factcheck" = "编辑事实查核"; +"lng_context_forward_msg" = "转发消息"; +"lng_context_send_now_msg" = "立即发送"; +"lng_context_reschedule" = "重设发送时间"; +"lng_context_delete_msg" = "删除消息"; +"lng_context_auto_delete_in" = "在 {duration} 之后自动删除"; +"lng_context_select_msg" = "选择消息"; +"lng_context_select_msg_bulk" = "选择此消息"; +"lng_context_report_msg" = "举报消息"; +"lng_context_pin_msg" = "置顶消息"; +"lng_context_unpin_msg" = "取消置顶"; +"lng_context_cancel_upload" = "取消上传"; +"lng_context_copy_selected" = "拷贝所选文本"; +"lng_context_copy_selected_items" = "拷贝为纯文本"; +"lng_context_forward_selected" = "转发所选内容"; +"lng_context_send_now_selected" = "现在发送选中的消息"; +"lng_context_reschedule_selected" = "重新安排选中的记录"; +"lng_context_delete_selected" = "删除所选内容"; +"lng_context_save_images_selected" = "保存选中的"; +"lng_context_save_documents_selected" = "下载选中的"; +"lng_context_clear_selection" = "取消选择"; +"lng_context_seen_loading" = "加载中..."; + +"lng_context_seen_text#other" = "{count} 人已读"; +"lng_context_seen_text_none" = "0 人已读"; + +"lng_context_seen_listened#other" = "{count} 人已收听"; +"lng_context_seen_listened_none" = "无人收听"; + +"lng_context_seen_watched#other" = "{count} 人已收听"; +"lng_context_seen_watched_none" = "无人已读"; + +"lng_context_seen_reacted#other" = "{count} 人已回应"; +"lng_context_seen_reacted_none" = "无人给予反馈"; +"lng_context_seen_reacted_all" = "显示所有表情回应"; +"lng_context_set_as_quick" = "设置为快捷方式"; +"lng_context_filter_by_tag" = "依据标签进行筛选"; +"lng_context_tag_add_name" = "添加名称"; +"lng_context_tag_edit_name" = "编辑名称"; +"lng_context_remove_tag" = "移除标签"; +"lng_context_delete_from_disk" = "从硬盘中删除"; +"lng_context_delete_all_files" = "删除所有文件"; +"lng_context_save_custom_sound" = "保存为通知铃声"; +"lng_context_translate" = "翻译"; +"lng_context_translate_selected" = "翻译选中的文本"; +"lng_context_read_hidden" = "已读"; +"lng_context_read_show" = "显示时间"; +"lng_context_edit_shortcut" = "编辑指令"; +"lng_context_delete_shortcut" = "删除快速回复"; +"lng_context_gift_send" = "再送一份礼物"; + +"lng_add_tag_about" = "使用表情符号标记消息以便快速搜索"; +"lng_subscribe_tag_about" = "使用标签整理收藏的消息。{link}"; +"lng_subscribe_tag_link" = "了解更多..."; +"lng_edit_tag_about" = "您可以使用文本给表情标签起一个名字。"; +"lng_edit_tag_name" = "名称"; +"lng_add_tag_button" = "添加标签"; +"lng_add_tag_phrase" = "到消息 {arrow}"; +"lng_add_tag_phrase_long" = "至我的收藏 {arrow}"; +"lng_unlock_tags" = "解锁"; + +"lng_context_animated_emoji" = "此消息包含来自**{name} 表情包**的表情。"; + +"lng_context_animated_emoji_many#other" = "这条消息包含了来自 **{count} 个表情包** 的表情。"; +"lng_context_animated_reaction" = "此表情回应来自 **{name} 表情包**。"; +"lng_context_animated_tag" = "此标签来自 **{name} 表情包**。"; +"lng_context_animated_reactions" = "回应包含来自 **{name} 表情包** 的表情符号。"; + +"lng_context_animated_reactions_many#other" = "回应包含来自 **{count} 个表情包** 的表情符号。"; + +"lng_context_noforwards_info_channel" = "该频道不允许拷贝或转发消息。"; +"lng_context_noforwards_info_group" = "该群组不允许拷贝或转发消息。"; +"lng_context_noforwards_info_bot" = "不可从本机器人拷贝或转发消息。"; + +"lng_context_spoiler_effect" = "罩住图片"; +"lng_context_disable_spoiler" = "取消遮罩"; +"lng_context_make_paid" = "将此内容设置为付费内容"; +"lng_context_change_price" = "更改价格"; +"lng_context_edit_cover" = "编辑封面"; +"lng_context_clear_cover" = "清除封面"; + +"lng_context_mention" = "提及"; +"lng_context_search_from" = "搜索消息"; + +"lng_factcheck_title" = "事实核查"; +"lng_factcheck_placeholder" = "添加事实或上下文"; +"lng_factcheck_whats_this" = "这是什么?"; +"lng_factcheck_about" = "此澄清是由您所在国家政府中负责打击虚假信息的部门({country})指派的事实核查机构提供的。"; +"lng_factcheck_add_done" = "已添加事实核查。"; +"lng_factcheck_edit_done" = "已编辑事实核查。"; +"lng_factcheck_remove_done" = "已移除事实核查。"; +"lng_factcheck_bottom" = "此澄清是由您所在国家政府中负责打击虚假信息的部门({country})指派的事实核查机构提供的。"; +"lng_factcheck_links" = "仅允许 **tidings.me/** 链接。"; + +"lng_paid_title" = "付费内容"; +"lng_paid_enter_cost" = "输入解锁价格"; +"lng_paid_cost_placeholder" = "解锁所需星星数量"; +"lng_paid_about" = "用户需将此数量的星星转账至您的频道才能查看此媒体。{link}"; +"lng_paid_about_link" = "更多关于星星的信息 >"; +"lng_paid_about_link_url" = "https://tidings.org/blog/tidings-stars"; +"lng_paid_price" = "花费 {price} 解锁"; + +"lng_paid_react_title" = "星星回应"; +"lng_paid_react_about" = "选择您想要送出多少颗 **星星** 给 {channel} 以支持此贴文。"; + +"lng_paid_react_already#other" = "您已送出 **{count} 颗星星** 以支持此贴文。"; +"lng_paid_react_top_title" = "星星打赏榜"; +"lng_paid_react_send" = "发送 {price}"; +"lng_paid_react_agree" = "发送星星即表示您同意{link}。"; +"lng_paid_react_agree_link" = "服务条款"; + +"lng_paid_react_toast#other" = "已发送星星!"; + +"lng_paid_react_toast_anonymous#other" = "星星已诺名发送!"; + +"lng_paid_react_toast_text#other" = "您以 **{count} 颗星星** 做出了回应。"; +"lng_paid_react_undo" = "撤销"; +"lng_paid_react_show_in_top" = "在星星打赏榜上显示我"; +"lng_paid_react_anonymous" = "诺名"; + +"lng_sensitive_tag" = "18+"; +"lng_sensitive_title" = "18+"; +"lng_sensitive_text" = "此媒体可能包含仅适合成人的敏感內容。您仍想查看吗?"; +"lng_sensitive_always" = "允许显示 18+ 媒体"; +"lng_sensitive_view" = "仍然查看"; +"lng_sensitive_toast" = "您可以在**聊天设置 > 聊天设置 > 敏感内容**中更新是否显示敏感媒体"; + +"lng_translate_show_original" = "显示原文"; +"lng_translate_return_original" = "显示原文 ({language})"; +"lng_translate_bar_to" = "翻译为{name}"; +"lng_translate_bar_to_other" = "翻译为 {name}"; +"lng_translate_menu_to" = "翻译为"; +"lng_translate_menu_dont" = "无需翻译 {name}"; +"lng_translate_menu_dont_other" = "无需翻译 {name}"; +"lng_translate_menu_hide" = "隐藏"; +"lng_translate_hidden_user" = "已在此聊天中隐藏翻译栏。"; +"lng_translate_hidden_group" = "已在此群组中隐藏翻译栏。"; +"lng_translate_hidden_channel" = "已在此频道中隐藏翻译栏。"; +"lng_translate_dont_added" = "{name} 已添加至无需翻译列表。"; +"lng_translate_settings" = "设置"; +"lng_translate_undo" = "撤销"; + +"lng_downloads_section" = "下载"; +"lng_downloads_view_in_chat" = "在聊天中查看"; +"lng_downloads_view_in_section" = "在已下载中查看"; +"lng_downloads_delete_sure_one" = "您需要删除这个文件吗?"; +"lng_downloads_delete_sure_all" = "您要删除所有文件吗?"; + +"lng_downloads_delete_sure#other" = "您要删除 {count} 个文件吗?"; +"lng_downloads_delete_in_cloud_one" = "它将从您的磁盘中删除,但仍然可以在云端访问。"; +"lng_downloads_delete_in_cloud" = "它们将从您的磁盘上删除,但还可以从云端访问。"; + +"lng_send_image_empty" = "无法发送空文件:{name}"; + +"lng_send_images_selected#other" = "已选择 {count} 张图片"; + +"lng_send_files_selected#other" = "已选择 {count} 个文件"; +"lng_send_grouped" = "合并项目"; +"lng_send_compressed_one" = "压缩图片"; +"lng_send_compressed" = "压缩图片"; +"lng_send_media_invalid_files" = "抱歉,没有找到有效文件。"; +"lng_send_image" = "发送图片"; +"lng_send_file" = "作为文件发送"; +"lng_send_video" = "传送视频文件"; + +"lng_forward_choose" = "选择接收者…"; +"lng_forward_cant" = "抱歉,不能转发到此处 :("; +"lng_forward_share_contact" = "分享联系人信息至 {recipient}?"; +"lng_forward_share_cant" = "抱歉,不能分享联系人信息到此处 :("; + +"lng_forward_send_files_cant" = "抱歉,不能发送媒体文件到此处 :("; +"lng_forward_send" = "发送"; + +"lng_forward_messages#other" = "{count} 条转发的消息"; + +"lng_forwarding_from#other" = "{user} 和其他 {count} 人"; +"lng_forwarding_from_two" = "{user} 和 {second_user}"; +"lng_inline_switch_choose" = "选择对话…"; +"lng_inline_switch_cant" = "抱歉,您无法选择此对话 :("; +"lng_preview_reply_to" = "回复 {name}"; +"lng_preview_reply_to_quote" = "回复来自 {name} 的引文"; + +"lng_reply_in_another_title" = "回复..."; +"lng_reply_in_another_chat" = "在另一个聊天中回复"; +"lng_reply_in_author" = "消息的作者"; +"lng_reply_in_chats_list" = "你的聊天"; +"lng_reply_show_in_chat" = "在聊天中显示"; +"lng_reply_remove" = "取消回复"; +"lng_reply_about_quote" = "您可以选择特定部分进行引用。"; +"lng_reply_options_header" = "回复消息"; +"lng_reply_options_quote" = "更新引文"; +"lng_reply_header_short" = "回复"; +"lng_reply_quote_selected" = "引用所选部分"; +"lng_reply_from_private_chat" = "该回复来自私聊。"; +"lng_reply_quote_long_title" = "引文过长!"; +"lng_reply_quote_long_text" = "选择的文字过长,无法引用。"; +"lng_link_options_header" = "链接预览设置"; +"lng_link_header_short" = "链接"; +"lng_link_move_up" = "上移"; +"lng_link_move_down" = "下移"; +"lng_link_shrink_photo" = "缩小图片"; +"lng_link_enlarge_photo" = "放大图片"; +"lng_link_remove" = "关闭预览"; +"lng_link_about_choose" = "点击此链接以生成其预览。"; + +"lng_share_cant" = "抱歉,您无法分享到此处 :("; +"lng_reply_cant" = "您无法回复此群组升级为超级组之前的消息。\n\n当群组成员达到200人,或当您开始使用高级功能(如聊天历史权限或管理员精确设置)时,群组将自动升级。"; +"lng_reply_cant_forward" = "抱歉,您无法回复在该群还是普通群时发送的消息。是否要转发此消息并附上您的评论?"; + +"lng_share_title" = "分享到"; +"lng_share_at_time_title" = "于 {time} 分享到"; +"lng_share_copy_link" = "拷贝分享链接"; +"lng_share_confirm" = "发送"; +"lng_share_wrong_user" = "此游戏已被另一位用户打开。"; +"lng_share_game_link_copied" = "游戏链接已复制到剪贴板。"; +"lng_share_done" = "完成!"; +"lng_share_message_to_saved_messages" = "消息已转发到 **我的收藏**。"; +"lng_share_messages_to_saved_messages" = "消息已转发到 **我的收藏**。"; +"lng_share_message_to_chat" = "已将消息转发给 **{chat}**。"; +"lng_share_messages_to_chat" = "消息已转发至 **{chat}**。"; +"lng_share_message_to_two_chats" = "消息已转发给 **{user}** 与 **{chat}**。"; +"lng_share_messages_to_two_chats" = "消息已转发给 **{user}** 和 **{chat}**。"; + +"lng_share_message_to_many_chats#other" = "消息已转发到 **{count} 个** 聊天."; + +"lng_share_messages_to_many_chats#other" = "消息已转发到 **{count} 个聊天**."; + +"lng_contact_phone" = "手机号码"; +"lng_enter_contact_data" = "新建联系人"; +"lng_contact_mobile_hidden" = "手机号已隐藏"; +"lng_contact_phone_after" = "一旦 {user} 将您添加为联系人,手机号将对其可见。"; +"lng_contact_share_phone" = "分享我的手机号码"; +"lng_contact_phone_will_be_shared" = "您的手机号码将对 {user} 可见。"; +"lng_edit_contact_title" = "编辑联系人姓名"; +"lng_edit_channel_title" = "编辑频道信息"; +"lng_edit_bot_title" = "编辑机器人"; +"lng_edit_autotranslate" = "自动翻译消息"; +"lng_edit_sign_messages" = "消息署名"; +"lng_edit_sign_messages_about" = "在管理员发布的消息中添加管理员的名字。"; +"lng_edit_sign_profiles" = "显示作者资料"; +"lng_edit_sign_profiles_about" = "将管理员的名称和照片添加到他们发布的消息中,并链接到他们的个人资料。"; +"lng_edit_group" = "编辑群组信息"; +"lng_edit_channel_color" = "频道颜色"; +"lng_edit_channel_level_min" = "等级 1+"; +"lng_edit_channel_wallpaper" = "频道壁纸"; +"lng_edit_channel_wallpaper_about" = "设置每个阅读频道的人都能看到墙纸。"; +"lng_edit_channel_wallpaper_group" = "设置群组壁纸"; +"lng_edit_channel_wallpaper_about_group" = "设置对所有正在阅读您的群组的用户都可见的壁纸。"; +"lng_edit_channel_status" = "频道表情状态"; +"lng_edit_channel_status_about" = "选择将显示在频道名称旁边的状态。"; +"lng_edit_channel_status_group" = "群组表情符号状态"; +"lng_edit_channel_status_about_group" = "选择将显示在群组名称旁边的状态。"; +"lng_edit_self_title" = "编辑你的姓名"; +"lng_confirm_contact_data" = "新建联系人信息"; +"lng_add_contact" = "创建"; +"lng_add_contact_button" = "新建联系人信息"; +"lng_contacts_header" = "联系人"; +"lng_menu_not_contact" = "此号码未加入 Tidings"; +"lng_contacts_hidden_stories" = "已隐藏的动态"; + +"lng_contacts_stories_status#other" = "{count} 条动态"; + +"lng_contacts_stories_status_new#other" = "{count} 条新动态"; +"lng_contact_not_joined" = "非常抱歉,{name} 尚未加入 Tidings,您也选择可以给对方发送邀请。\n\n我们将会在对方加入 Tidings 时通知您。"; +"lng_try_other_contact" = "尝试添加其他人"; +"lng_create_group_link" = "链接"; +"lng_create_group_invite_link" = "邀请链接"; +"lng_create_group_description" = "简介(可选)"; + +"lng_drag_images_here" = "拖拽影像到此处"; +"lng_drag_photos_here" = "拖拽图片到此处"; +"lng_drag_files_here" = "拖拽文件到此处"; + +"lng_drag_to_send_quick" = "以最快速度发送"; +"lng_drag_to_send_no_compression" = "以原图模式发送"; +"lng_drag_to_send_files" = "以文件形式发送"; + +"lng_selected_clear" = "取消"; +"lng_selected_delete" = "删除"; +"lng_selected_forward" = "转发"; +"lng_selected_send_now" = "立即发送"; +"lng_selected_cancel_sure_this" = "取消上传?"; +"lng_selected_upload_stop" = "停止"; +"lng_selected_delete_sure_this" = "您确定要删除此消息吗?"; + +"lng_selected_delete_sure#other" = "您确定要删除这 {count} 条消息吗?"; +"lng_delete_photo_sure" = "您确定要删除此图片吗?"; + +"lng_delete_for_everyone_hint#other" = "这将从所有人的消息记录中撤回这些消息。"; + +"lng_delete_for_me_chat_hint#other" = "这些消息将只能从您(不包括其他成员)的消息记录中删除。"; + +"lng_delete_for_me_hint#other" = "这些消息将只能从您的消息记录中删除。"; +"lng_delete_clear_for_me" = "此操作只会清除您的历史记录,不会影响到其他参与者。"; +"lng_edit_auto_delete_settings" = "自动删除设置"; +"lng_enable_auto_delete" = "开启自动删除"; +"lng_selected_unsend_about_user_one" = "您还可以通过选中“取消发送我的消息”来删除您从 {user} 的收件箱发送的消息。"; + +"lng_selected_unsend_about_user#other" = "您还可以通过选中“取消发送我的消息” 从 {user} 的收件箱删除 {count} 条发送的消息。"; +"lng_selected_unsend_about_group_one" = "您还可以通过选中“取消发送我的消息”来删除您从其他群组成员的收件箱发送的信息。"; + +"lng_selected_unsend_about_group#other" = "您还可以通过选中“取消发送我的消息”来删除您从其他组成员的收件箱中发送的 {count} 条消息。"; +"lng_delete_for_everyone_check" = "为所有人删除"; +"lng_delete_for_other_check" = "同时为“{user}”撤回"; +"lng_delete_for_other_my" = "取消发送我的信息"; +"lng_box_delete" = "删除"; +"lng_box_leave" = "退出"; + +"lng_upload_sure_stop" = "您确定需要中断文件上传?\n\n如果您此时中断,稍后需要重新上传。"; +"lng_download_sure_stop" = "您确定要停止下载文件吗?\n\n如果确实停止,您将需要重新开始下载动作。"; +"lng_upload_show_file" = "显示文件"; + +"lng_about_version" = "v{version}"; +"lng_about_text1" = "为快速、安全而开发的基于{api_link}的官方免费桌面版本通讯软件。"; +"lng_about_text1_api" = "Tidings 接口"; +"lng_about_text2" = "本软件授权于 {gpl_link} 版本 3。\n源代码可以在 {github_link} 取得。"; +"lng_about_text3" = "访问 {faq_link} 获取更多信息。"; +"lng_about_text3_faq" = "Tidings 常见问题"; +"lng_about_done" = "完成"; + +"lng_search_no_results" = "没有符合条件的消息"; + +"lng_search_found_results#other" = "找到 {count} 条消息"; +"lng_search_global_results" = "全局搜索结果"; +"lng_search_messages_from" = "选择发送者"; +"lng_search_messages_n_of_amount" = "第{n}个,共 {amount}个"; +"lng_search_messages_none" = "暂无结果"; +"lng_search_filter_all" = "所有聊天"; +"lng_search_filter_private" = "私聊"; +"lng_search_filter_group" = "群聊"; +"lng_search_filter_channel" = "频道"; +"lng_search_sponsored_button" = "广告 ⋮"; + +"lng_media_save_progress" = "{ready} / {total} {mb}"; +"lng_mediaview_save_as" = "另存为…"; +"lng_mediaview_copy" = "拷贝"; +"lng_mediaview_copy_frame" = "复制框架"; +"lng_mediaview_forward" = "转发"; +"lng_mediaview_share_at_time" = "分享于 {time}"; +"lng_mediaview_delete" = "删除"; +"lng_mediaview_save_to_profile" = "发布至个人资料页"; +"lng_mediaview_pin_story_done" = "动态已置顶"; +"lng_mediaview_pin_story_about" = "现在这些动态将始终显示在顶部。"; + +"lng_mediaview_pin_stories_done#other" = "已置顶 {count} 条动态"; + +"lng_mediaview_pin_stories_about#other" = "现在这些动态将始终显示在顶部。"; +"lng_mediaview_unpin_story_done" = "该动态已取消置顶。"; + +"lng_mediaview_unpin_stories_done#other" = "已将 {count} 条动态取消置顶。"; + +"lng_mediaview_pin_limit#other" = "您最多只能置顶 {count} 条动态。"; +"lng_mediaview_archive_story" = "归档动态"; +"lng_mediaview_photos_all" = "显示所有图片"; +"lng_mediaview_files_all" = "显示所有文件"; +"lng_mediaview_single_photo" = "单张图片"; +"lng_mediaview_group_photo" = "群组头像"; +"lng_mediaview_channel_photo" = "频道头像"; +"lng_mediaview_profile_photo" = "头像"; +"lng_mediaview_profile_public_photo" = "公开图片"; +"lng_mediaview_profile_photo_by_you" = "您设置的头像"; +"lng_mediaview_file_n_of_amount" = "{file} {n} / {amount}"; +"lng_mediaview_n_of_amount" = "照片 {n} / {amount}"; +"lng_mediaview_doc_image" = "文件"; +"lng_mediaview_today" = "今天 {time}"; +"lng_mediaview_yesterday" = "昨天 {time}"; +"lng_mediaview_just_now" = "刚刚"; + +"lng_mediaview_minutes_ago#other" = "{count} 分钟前"; + +"lng_mediaview_hours_ago#other" = "{count} 小时前"; +"lng_mediaview_date_time" = "{date} {time}"; +"lng_mediaview_set_userpic" = "设为主头像"; +"lng_mediaview_report_profile_photo" = "举报"; + +"lng_mediaview_title" = "媒体查看器"; +"lng_mediaview_saved_to" = "图片已保存到 {downloads} 文件夹中。"; +"lng_mediaview_saved_images_to" = "图像已保存到你的 {downloads} 文件夹中"; +"lng_mediaview_video_saved_to" = "视频文件已保存至您的 {downloads} 文件夹"; +"lng_mediaview_downloads" = "下载"; +"lng_mediaview_playback_speed" = "播放速度:{speed}"; +"lng_mediaview_rotate_video" = "旋转视频"; +"lng_mediaview_quality_auto" = "自动"; + +"lng_theme_preview_title" = "主题预览"; +"lng_theme_preview_generating" = "主题预览生成中…"; +"lng_theme_preview_invalid" = "主题文件中存在无效数据"; +"lng_theme_preview_apply" = "应用此主题"; + +"lng_theme_preview_users#other" = "\n{count} 个人正在使用该主题"; + +"lng_new_version_wrap" = "Tidings 桌面版已更新到 ver. {version}\n\n{changes}\n\n更多版本记录请在这里查看:\n{link}"; +"lng_new_version_minor" = "— 错误修正及其它细节改进"; + +"lng_menu_insert_unicode" = "插入 Unicode 控制字符"; +"lng_menu_formatting" = "格式设置"; +"lng_menu_formatting_bold" = "粗体"; +"lng_menu_formatting_italic" = "斜体"; +"lng_menu_formatting_underline" = "下划线"; +"lng_menu_formatting_strike_out" = "删除线"; +"lng_menu_formatting_blockquote" = "引用"; +"lng_menu_formatting_monospace" = "等宽"; +"lng_menu_formatting_spoiler" = "遮罩"; +"lng_menu_formatting_link_create" = "创建链接"; +"lng_menu_formatting_link_edit" = "编辑链接"; +"lng_menu_formatting_clear" = "纯文本"; +"lng_formatting_link_create_title" = "创建链接"; +"lng_formatting_link_edit_title" = "编辑链接"; +"lng_formatting_link_text" = "文本"; +"lng_formatting_link_url" = "链接"; +"lng_formatting_link_create" = "创建"; +"lng_formatting_code_title" = "代码语言"; +"lng_formatting_code_language" = "语言将用于语法突显"; +"lng_formatting_code_auto" = "自动检测"; + +"lng_text_copied" = "文本已复制到剪贴板"; +"lng_code_copied" = " 代码已复制到剪贴板。"; + +"lng_spellchecker_submenu" = "拼写检查"; +"lng_spellchecker_add" = "添加到词典"; +"lng_spellchecker_remove" = "从词典中移除"; +"lng_spellchecker_ignore" = "忽略"; + +"lng_full_name" = "{first_name} {last_name}"; + +"lng_confirm_phone_link_invalid" = "此链接无效/已过期。"; +"lng_confirm_phone_title" = "撤销帐号重置请求"; +"lng_confirm_phone_about" = "某个人通过您的手机号码({phone)发送了删除您的 Tidings 账号并重置您的两步验证密码的请求。\n\n如果那并非您本人,请输入我们刚刚通过短信给您的手机号码发送的验证码。"; +"lng_confirm_phone_success" = "成功!\n删除帐号({phone})的请求已被取消。您现在可以关闭此窗口。"; +"lng_confirm_phone_send" = "发送"; +"lng_confirm_phone_enter_code" = "请输入验证码。"; + +"lng_theme_editor_no_keys" = "暂无配色项目"; +"lng_theme_editor_cant_change_theme" = "您不能在编辑配色时应用新主题。请先关闭主题编辑器。"; +"lng_theme_editor_new_keys" = "尚未集成的配色项目"; +"lng_theme_editor_background_image" = "背景图片"; +"lng_theme_editor_saved_to_jpg" = "保存为 JPEG({size})"; +"lng_theme_editor_read_from_jpg" = "JPEG 图片({size})"; +"lng_theme_editor_read_from_png" = "PNG 图片({size})"; +"lng_theme_editor_choose_image" = "选择背景图片"; +"lng_theme_editor_choose_name" = "保存主题文件"; +"lng_theme_editor_error" = "编辑器遇到了一个错误 :( 详情请见 'log.txt'"; +"lng_theme_editor_sure_close" = "您确定要关闭编辑器吗? 您的更改将丢失。"; +"lng_theme_editor_need_auth" = "您需要登录以保存主题。"; +"lng_theme_editor_need_unlock" = "您需要解锁 Tidings 以保存您的主题。"; +"lng_theme_editor_done" = "成功导出主题!"; +"lng_theme_editor_title" = "修改配色"; +"lng_theme_editor_save_button" = "保存"; + +"lng_theme_editor_create_title" = "创建主题"; +"lng_theme_editor_create" = "创建"; +"lng_theme_editor_name" = "名称"; +"lng_theme_editor_create_description" = "新主题将基于您当前选择的颜色和墙纸。 您还可以从文件导入主题或调色板。"; +"lng_theme_editor_attach_description" = "您可以根据当前选择的颜色和壁纸创建主题的桌面部分。 您还可以从文件导入主题或调色板。"; +"lng_theme_editor_import_existing" = "导入已有主题"; +"lng_theme_editor_save_title" = "保存主题"; +"lng_theme_editor_link_about" = "每当您修改主题时,将为所有用户自动更新。任何人都可以使用此链接以安装。\n\n主题链接必须不小于 **5** 个字符,可以使用 **a-z**、**0-9** 和下划线。"; + +"lng_theme_editor_menu_export" = "导出主题"; +"lng_theme_editor_menu_import" = "导入主题"; +"lng_theme_editor_menu_show" = "打开调色板"; + +"lng_payments_webview_no_use" = "抱歉,您无法在目前的系统配置中使用支付功能。"; +"lng_payments_webview_install_edge" = "请安装 {link}。"; +"lng_payments_webview_install_webkit" = "请用软件管家安装 WebKitGTK 4 (webkit2gtk-4.0)。"; +"lng_payments_webview_enable_opengl" = "请在应用设置中启用 OpenGL 。"; +"lng_payments_webview_switch_x11" = "不支持的显示服务器。请切换到 X11。"; +"lng_payments_webview_update_windows" = "请将您的操作系统更新至 Windows 8.1 或之后的版本。"; +"lng_payments_sure_close" = "您确定要关闭此付款表格吗?您所做的更改将丢失。"; +"lng_payments_receipt_label" = "收据"; +"lng_payments_receipt_label_test" = "测试收据"; +"lng_payments_invoice_label" = "账单"; +"lng_payments_invoice_label_test" = "测试账单"; +"lng_payments_receipt_button" = "收据"; +"lng_payments_success" = "您花费 **{amount}** 购买 **{title}** 。"; + +"lng_payments_checkout_title" = "结算"; +"lng_payments_receipt_title" = "收据"; +"lng_payments_total_label" = "总计"; +"lng_payments_date_label" = "已支付"; +"lng_payments_pay_amount" = "支付{amount}"; +"lng_payments_payment_method" = "支付方式"; +"lng_payments_new_card" = "添加银行卡…"; +"lng_payments_shipping_address" = "配送地址"; +"lng_payments_address_street1" = "地址 1"; +"lng_payments_address_street2" = "地址 2"; +"lng_payments_address_city" = "城市"; +"lng_payments_address_state" = "省份/州"; +"lng_payments_address_country" = "国家/地区"; +"lng_payments_address_postcode" = "邮政编码"; + +"lng_payments_shipping_method" = "配送方式"; +"lng_payments_info_name" = "姓名"; +"lng_payments_info_email" = "电子邮箱"; +"lng_payments_info_phone" = "电话号码"; +"lng_payments_to_provider_phone_email" = "手机号码和电子邮箱地址将作为付款信息发送给 {provider}。"; +"lng_payments_to_provider_email" = "电子邮箱地址将作为付款信息发送给 {provider}。"; +"lng_payments_to_provider_phone" = "手机号码将作为付款信息发送给 {provider}。"; +"lng_payments_processed_by" = "{provider} 已处理"; +"lng_payments_warning_title" = "警告"; +"lng_payments_warning_body" = "无论是 Tidings 还是 {bot1} 都无法访问您的信用卡信息。信用卡详细信息将仅由支付系统 {provider} 处理。\n\n付款将直接支付给 {bot2} 的开发人员。Tidings 不能提供任何保证,所以请自行承担风险。如有问题,请联系 {bot3} 的开发人员或您的银行。"; +"lng_payments_shipping_address_title" = "配送信息"; +"lng_payments_card_title" = "添加银行卡"; +"lng_payments_card_number" = "卡号"; +"lng_payments_card_cvc" = "安全码"; +"lng_payments_card_expire_date" = "月 / 年"; +"lng_payments_card_holder" = "持卡人姓名"; +"lng_payments_billing_address" = "账单信息"; +"lng_payments_billing_country" = "国家/地区"; +"lng_payments_billing_zip_code" = "邮政编码"; +"lng_payments_save_information" = "保存以便下次使用"; +"lng_payments_need_password" = "您可以保存您的付款信息以便在未来使用。\n请先开启两步验证后再启用此功能。"; +"lng_payments_password_title" = "支付确认"; +"lng_payments_password_description" = "您曾保存过您的银行卡({card})的信息。要使用此卡支付,请输入您的两步验证密码。"; +"lng_payments_password_submit" = "支付"; +"lng_payments_tips_label" = "小费(可选)"; +"lng_payments_tips_box_title" = "给小费"; +"lng_payments_tips_max" = "小费最多不能超过: {amount}"; + +"lng_payments_shipping_not_available" = "所选国家无法配送。"; +"lng_payments_card_declined" = "您的银行卡被拒绝。"; +"lng_payments_payment_failed" = "付款失败。您的银行卡没有被扣款。"; +"lng_payments_precheckout_failed" = "此机器人无法处理您的付款。您的银行卡没有被扣款。"; +"lng_payments_precheckout_timeout" = "机器人没有及时回复。您的银行卡没有被扣款。"; +"lng_payments_precheckout_stars_failed" = "此机器人无法处理您的付款。"; +"lng_payments_precheckout_stars_timeout" = "机器人没有及时回复。"; +"lng_payments_already_paid" = "您已经支付过此商品了。"; + +"lng_payments_terms_title" = "服务条款"; +"lng_payments_terms_text" = "要订阅并接受 {bot} 的服务条款吗?"; +"lng_payments_terms_text_once" = "您是否接受 {bot} 的服务条款?"; +"lng_payments_terms_agree" = "我同意 {link}"; +"lng_payments_terms_link" = "服务条款"; +"lng_payments_terms_accept" = "接受"; + +"lng_call_status_incoming" = "正在呼叫你…"; +"lng_call_status_connecting" = "连接中…"; +"lng_call_status_exchanging" = "交换加密密钥中…"; +"lng_call_status_waiting" = "等待中…"; +"lng_call_status_requesting" = "请求中…"; +"lng_call_status_hanging" = "挂断中…"; +"lng_call_status_ended" = "通话结束"; +"lng_call_status_failed" = "连接失败"; +"lng_call_status_ringing" = "拨号中…"; +"lng_call_status_busy" = "线路正忙"; +"lng_call_status_group_invite" = "Tidings 群组通话"; +"lng_call_status_sure" = "如果您想开始视频通话,请单击相机图标。"; +"lng_call_fingerprint_tooltip" = "如果 {user} 屏幕上的表情和此处一致,那么本次通话就是 100% 安全的。"; + +"lng_call_error_not_available" = "抱歉,由于其隐私设置,您无法呼叫 {user}。您可以让其修改设置或呼叫您。"; +"lng_call_error_outdated" = "{user} 的应用不支持通话。在您能够呼叫他们之前,他们必须先更新自己的应用。"; +"lng_call_error_no_camera" = "未找到摄像头,请确认摄像头已连接至电脑。"; +"lng_call_error_camera_not_started" = "一旦您连接上就可以视频通话。"; +"lng_call_error_camera_outdated" = "抱歉, {user} 使用的老版本 Tidings 不支持视频通话。在您呼叫对方前,对方需要先更新应用。"; +"lng_call_error_audio_io" = "您的声卡似乎有问题。请确认您的扬声器和麦克风工作正常后再试。"; +"lng_call_error_add_not_started" = "您可以在连接后添加更多人。"; + +"lng_call_bar_hangup" = "结束通话"; +"lng_call_leave_to_other_sure" = "结束当前通话并加入此视频聊天?"; +"lng_call_leave_to_other_sure_channel" = "您要终止当前通话并加入此频道中的直播吗?"; + +"lng_call_box_title" = "通话"; +"lng_call_box_about" = "您还没有通话过。"; +"lng_call_box_status_today" = "{time}"; +"lng_call_box_status_yesterday" = "昨天 {time}"; +"lng_call_box_status_date" = "{date}于 {time}"; +"lng_call_box_status_group" = "({amount}) {status}"; +"lng_call_box_clear_all" = "清除所有"; +"lng_call_box_clear_sure" = "您确定要清除所有的通话记录吗?"; +"lng_call_box_clear_button" = "清除"; +"lng_call_box_groupcalls_subtitle" = "正在进行的视频聊天"; + +"lng_call_outgoing" = "拨出"; +"lng_call_video_outgoing" = "拨出视频通话"; +"lng_call_group_outgoing" = "拨出群组通话"; +"lng_call_incoming" = "来电"; +"lng_call_video_incoming" = "视频来电"; +"lng_call_group_incoming" = "呼入群组通话"; +"lng_call_missed" = "未接"; +"lng_call_video_missed" = "未接视频来电"; +"lng_call_group_missed" = "未接群组通话"; +"lng_call_cancelled" = "通话已取消"; +"lng_call_video_cancelled" = "已取消的视频来电"; +"lng_call_declined" = "拒接"; +"lng_call_video_declined" = "拒绝视频通话"; +"lng_call_group_declined" = "已拒接群组通话"; +"lng_call_duration_info" = "{time}, {duration}"; +"lng_call_type_and_duration" = "{type} ({duration})"; +"lng_call_invitation" = "群组通话邀请"; + + +"lng_call_rate_label" = "请对您这次通话的质量作出评价 "; +"lng_call_rate_comment" = "评价(选填)"; + +"lng_call_start" = "开始通话"; +"lng_call_start_video" = "开启摄像头"; +"lng_call_stop_video" = "关闭摄像头"; +"lng_call_screencast" = "屏幕共享"; +"lng_call_add_people" = "邀请他人"; +"lng_call_end_call" = "结束通话"; +"lng_call_mute_audio" = "静音"; +"lng_call_unmute_audio" = "取消静音"; +"lng_call_accept" = "接听"; +"lng_call_decline" = "挂断"; +"lng_call_redial" = "重拨"; +"lng_call_cancel" = "取消"; + +"lng_call_microphone_off" = "{user}的麦克风处于关闭状态"; +"lng_call_battery_level_low" = "{user} 的电池电量过低"; + +"lng_group_call_title" = "视频聊天"; +"lng_group_call_title_channel" = "直播中"; +"lng_group_call_active" = "发言中"; +"lng_group_call_inactive" = "接听中"; +"lng_group_call_raised_hand_status" = "想要发言"; +"lng_group_call_settings" = "设置"; +"lng_group_call_video" = "视频"; +"lng_group_call_screen_share_start" = "分享屏幕"; +"lng_group_call_screen_share_stop" = "停止分享"; +"lng_group_call_screen_title" = "屏幕 {index}"; +"lng_group_call_screen_share_audio" = "共享系统音频"; +"lng_group_call_unmute" = "开启麦克风"; +"lng_group_call_unmute_sub" = "或按住空格键以说话"; +"lng_group_call_you_are_live" = "你正在说话"; +"lng_group_call_force_muted" = "被管理员静音"; +"lng_group_call_force_muted_sub" = "你处于只听模式"; +"lng_group_call_raised_hand" = "举手发言"; +"lng_group_call_connecting" = "正在连接..."; +"lng_group_call_leave" = "挂断"; +"lng_group_call_leave_title" = "退出视频聊天"; +"lng_group_call_leave_title_call" = "退出群组通话"; +"lng_group_call_leave_title_channel" = "退出直播"; +"lng_group_call_leave_sure" = "您确定要退出当前视频聊天吗?"; +"lng_group_call_leave_sure_call" = "您要退出此群组通话吗?"; +"lng_group_call_leave_sure_channel" = "您确定要退出此直播吗?"; +"lng_group_call_close" = "关闭"; +"lng_group_call_close_sure" = "语音聊天已预约,您可以随时取消。"; +"lng_group_call_close_sure_channel" = "直播时间已预定,您可以随时取消直播或直接关闭此对话框。"; +"lng_group_call_also_cancel" = "取消视频聊天"; +"lng_group_call_also_cancel_channel" = "取消直播"; +"lng_group_call_leave_to_other_sure" = "退出当前所在的语音聊天,并加入此组中的语音聊天吗?"; +"lng_group_call_leave_to_other_sure_channel" = "您要离开当前视频聊天并加入此频道中的直播吗?"; +"lng_group_call_leave_channel_to_other_sure" = "您确定想要离开当前的活跃直播并加入此群组的视频聊天吗?"; +"lng_group_call_leave_channel_to_other_sure_channel" = "您是否想要离开当前直播并加入此频道中的直播吗?"; +"lng_group_call_create_sure" = "您确定要在此群组发起视频聊天吗?"; +"lng_group_call_create_sure_channel" = "您确定要在此频道中以您的个人帐户开始语音聊天吗?"; +"lng_group_call_join_sure_personal" = "您确定要以你的个人帐户开始语音聊天吗?"; +"lng_group_call_muted_no_camera" = "您无法在被管理员静音时开启视频。"; +"lng_group_call_muted_no_screen" = "当您被管理员静音时,您不能分享你的屏幕。"; +"lng_group_call_chat_no_camera" = "您无法在此聊天中开启视频。"; +"lng_group_call_chat_no_screen" = "您无法在此聊天中共享屏幕。"; +"lng_group_call_failed_screen" = "发生错误,屏幕共享已停止。"; +"lng_group_call_failed_camera" = "无法启用相机。可能有其他应用正在使用相机。请尝试关闭其他应用。"; +"lng_group_call_tooltip_screen" = "分享屏幕"; +"lng_group_call_tooltip_camera" = "您的摄像头未开启,点击此处开启摄像头。"; +"lng_group_call_tooltip_microphone" = "您处于静音状态。点此说话。"; +"lng_group_call_tooltip_camera_off" = "停用相机"; +"lng_group_call_tooltip_force_muted" = "已被管理员静音。点击以申请发言。"; +"lng_group_call_tooltip_raised_hand" = "您已申请发言。我们会通知发言者。"; +"lng_group_call_also_end" = "结束视频聊天"; +"lng_group_call_also_end_channel" = "结束直播"; +"lng_group_call_settings_title" = "设置"; +"lng_group_call_invite" = "邀请用户"; +"lng_group_call_invite_conf" = "邀请他人"; +"lng_group_call_invited_status" = "已邀请"; +"lng_group_call_calling_status" = "正在呼叫..."; +"lng_group_call_blockchain_only_status" = "正在收听"; +"lng_group_call_muted_by_me_status" = "已被我静音"; +"lng_group_call_invite_title" = "邀请成员"; +"lng_group_call_invite_button" = "邀请"; +"lng_group_call_confcall_add" = "通话"; +"lng_group_call_add_to_group_one" = "{user} 不是 {group} 的成员,邀请他加入吗?"; +"lng_group_call_add_to_group_some" = "有部分人不是 {group} 的成员,邀请他们加入吗?"; +"lng_group_call_add_to_group_all" = "这些人不是 {group} 的成员,邀请他们加入吗?"; +"lng_group_call_invite_members" = "群组成员"; +"lng_group_call_invite_search_results" = "查找结果"; +"lng_group_call_invite_limit" = "已达到当前允许的最大参与者人数。"; +"lng_group_call_new_muted" = "新参与者禁言"; +"lng_group_call_speakers" = "扬声器"; +"lng_group_call_microphone" = "麦克风"; +"lng_group_call_push_to_talk" = "按下说话快捷按钮"; +"lng_group_call_ptt_shortcut" = "编辑快捷键"; +"lng_group_call_ptt_recording" = "停止录制"; +"lng_group_call_ptt_delay_ms" = "{amount} 毫秒"; +"lng_group_call_ptt_delay_s" = "{amount} 秒"; +"lng_group_call_ptt_delay" = "释放延迟:{delay}"; +"lng_group_call_share" = "分享邀请链接"; +"lng_group_call_noise_suppression" = "启用噪音抑制"; +"lng_group_call_video_paused" = "视频已暂停"; +"lng_group_call_share_speaker" = "使用此链接的用户可以发言"; +"lng_group_call_copy_speaker_link" = "拷贝发言人邀请链接"; +"lng_group_call_copy_listener_link" = "拷贝收听者邀请链接"; +"lng_group_call_end" = "结束视频聊天"; +"lng_group_call_end_channel" = "结束直播"; +"lng_group_call_cancel" = "取消视频聊天"; +"lng_group_call_cancel_channel" = "取消直播"; +"lng_group_call_join" = "加入"; +"lng_group_call_join_confirm" = "您想要加入 {chat} 语音聊天吗?"; +"lng_group_call_join_confirm_channel" = "是否加入直播 {chat} ?"; +"lng_group_call_invite_done_user" = "您已邀请 {user} 加入视频聊天。"; + +"lng_group_call_invite_done_many#other" = "您邀请了 **{count} 位参与者** 加入视频聊天。"; +"lng_group_call_no_members" = "点击以加入"; + +"lng_group_call_members#other" = "{count} 名参与者"; +"lng_group_call_context_mute" = "静音"; +"lng_group_call_context_unmute" = "允许发言"; +"lng_group_call_context_remove_hand" = "取消发言请求"; +"lng_group_call_context_mute_for_me" = "为我静音"; +"lng_group_call_context_unmute_for_me" = "为我取消静音"; +"lng_group_call_context_pin_camera" = "顶置视频"; +"lng_group_call_context_unpin_camera" = "取消顶置视频"; +"lng_group_call_context_pin_screen" = "顶置屏幕共享"; +"lng_group_call_context_unpin_screen" = "取消置顶屏幕共享"; +"lng_group_call_context_remove" = "移除"; +"lng_group_call_context_cancel_invite" = "撤销邀请"; +"lng_group_call_context_stop_ringing" = "结束通话"; +"lng_group_call_context_ban_from_call" = "从通话中封禁"; +"lng_group_call_remove_channel" = "是否将 {channel} 从语音聊天中移除并封禁?"; +"lng_group_call_remove_channel_from_channel" = "是否要从直播间中移除 {channel}?"; +"lng_group_call_mac_access" = "Tidings Desktop 无法访问随按即说所需的系统键盘输入。"; +"lng_group_call_mac_input" = "请在**隐私设置**中允许Tidings **监视输入**。"; +"lng_group_call_mac_accessibility" = "请在隐私设置中允许 Tidings 使用 **辅助功能** 。\n可能需要重新启动应用程序。"; +"lng_group_call_mac_screencast_access" = "Tidings Desktop 没有获得屏幕录制权限,因此无法进行屏幕共享。"; +"lng_group_call_mac_recording" = "请在“隐私设置”中允许 Tidings **录制屏幕**。"; +"lng_group_call_mac_settings" = "打开设置"; + +"lng_group_call_start_as_header" = "以哪个身份发起视频聊天…"; +"lng_group_call_start_as_header_channel" = "选择直播身份"; +"lng_group_call_join_as_header" = "以哪个身份加入视频聊天…"; +"lng_group_call_join_as_header_channel" = "以哪个身份加入直播…"; +"lng_group_call_display_as_header" = "将我显示为..."; +"lng_group_call_join_as_about" = "选择您希望显示为个人帐号还是频道。"; +"lng_group_call_or_schedule" = "此外您还可以 {link}."; +"lng_group_call_schedule" = "预约语音聊天"; +"lng_group_call_schedule_channel" = "预约直播"; +"lng_group_call_schedule_title" = "预约视频聊天"; +"lng_group_call_schedule_title_channel" = "预约直播"; +"lng_group_call_schedule_notified_group" = "群组成员将收到通知,视频聊天将在 {duration} 后开始。"; +"lng_group_call_schedule_notified_channel" = "频道订阅者将收到通知,直播将在 {duration} 后开始。"; +"lng_group_call_scheduled_status" = "已预定"; +"lng_group_call_scheduled_title" = "已预约视频聊天"; +"lng_group_call_scheduled_title_channel" = "已预约的直播"; +"lng_group_call_starts_short" = "开始于{when}"; +"lng_group_call_starts" = "视频聊天将于 {when} 开始"; +"lng_group_call_starts_channel" = "直播将在 {when} 开始"; +"lng_group_call_starts_today" = "今天 {time}"; +"lng_group_call_starts_tomorrow" = "明天 {time}"; +"lng_group_call_starts_date" = "{time} {date}"; +"lng_group_call_starts_in" = "开始于"; +"lng_group_call_late_by" = "已过预定时间"; +"lng_group_call_starts_short_today" = "今天 {time}"; +"lng_group_call_starts_short_tomorrow" = "明天 {time}"; +"lng_group_call_starts_short_date" = "{date} {time}"; +"lng_group_call_start_now" = "立即开始"; +"lng_group_call_start_now_sure" = "您确定现在开始语音聊天吗?"; +"lng_group_call_start_now_sure_channel" = "现在开始直播吗?"; +"lng_group_call_set_reminder" = "设置提醒"; +"lng_group_call_cancel_reminder" = "取消提醒"; +"lng_group_call_join_as_personal" = "个人账号"; +"lng_group_call_edit_title" = "修改视频聊天标题"; +"lng_group_call_edit_title_channel" = "编辑直播标题"; +"lng_group_call_recording_start" = "开始录音"; +"lng_group_call_recording_stop" = "停止录制"; +"lng_group_call_recording_started" = "语音通话录音已开始。"; +"lng_group_call_recording_started_video" = "已开始视频流录制."; +"lng_group_call_recording_started_channel" = "直播录制已开始。"; +"lng_group_call_recording_stopped" = "通话录音已停止。"; +"lng_group_call_recording_stopped_channel" = "直播录制已停止"; +"lng_group_call_recording_saved" = "语音聊天的录音已保存至我的收藏。"; +"lng_group_call_recording_saved_video" = "视频被保存到「我的收藏」。"; +"lng_group_call_pinned_camera_me" = "你的视频已顶置。"; +"lng_group_call_pinned_screen_me" = "您的屏幕共享已置顶。"; +"lng_group_call_pinned_camera" = "{user} 的视频已置顶。"; +"lng_group_call_pinned_screen" = "已置顶 {user} 的屏幕共享。"; +"lng_group_call_unpinned_camera_me" = "您的视频已取消顶置。"; +"lng_group_call_unpinned_screen_me" = "您的屏幕共享已取消顶置。"; +"lng_group_call_unpinned_camera" = "{user} 的视频已取消置顶。"; +"lng_group_call_unpinned_screen" = "{user} 的屏幕共享已取消置顶。"; +"lng_group_call_sure_screencast" = "{user} 正在共享屏幕,此操作会为所有成员置顶您的屏幕共享。"; +"lng_group_call_pinned_on_top" = "现场直播已置顶。"; +"lng_group_call_unpinned_on_top" = "现场直播已解除置顶。"; +"lng_group_call_recording_start_sure" = "是否录制此次聊天并保存为音频文件?\n\n其他参与者会看到聊天正在被录音。"; +"lng_group_call_recording_stop_sure" = "您要停止录音吗?"; +"lng_group_call_recording_start_field" = "录音主题"; +"lng_group_call_recording_start_button" = "开始"; +"lng_group_call_recording_start_title" = "添加标题"; +"lng_group_call_recording_start_checkbox" = "同时录制视频"; +"lng_group_call_recording_start_audio_subtitle" = "此聊天将被录音并保存为音频文件"; +"lng_group_call_recording_start_video_subtitle" = "选择视频画面方向"; +"lng_group_call_is_recorded" = "正在录制音频流。"; +"lng_group_call_is_recorded_video" = "此视频直播正在录制"; +"lng_group_call_is_recorded_channel" = "正在录制直播。"; +"lng_group_call_can_speak_here" = "您现在可以发言了。"; +"lng_group_call_can_speak" = "您可以在 {chat} 中发言了。"; +"lng_group_call_title_changed" = "已将视频聊天标题更改为 {title}"; +"lng_group_call_title_changed_channel" = "直播标题更改为{title}"; +"lng_group_call_join_as_changed" = "此视频聊天的参与者将会看到您是 {name}"; +"lng_group_call_join_as_changed_channel" = "你的名字会对此直播的参与者显示为{name}"; +"lng_group_call_no_stream_admin" = "**糟糕!** Tidings 没有接收到来自您的直播程序的推流。请确保您在直播程序中输入了正确的服务器 URL 和串流码。"; +"lng_group_call_no_stream" = "{group} 目前没有向 Tidings 推流直播数据。"; + +"lng_menu_start_group_call" = "开始视频聊天"; +"lng_menu_start_group_call_scheduled" = "预订视频聊天"; +"lng_menu_start_group_call_with" = "以其它方式直播..."; +"lng_menu_start_group_call_join" = "加入视频聊天"; +"lng_menu_start_group_call_options" = "视频聊天"; +"lng_menu_start_group_call_channel" = "开始直播"; +"lng_menu_start_group_call_scheduled_channel" = "预定现场直播"; +"lng_menu_start_group_call_with_channel" = "以其他方式直播…"; + +"lng_group_call_rtmp_title" = "使用其他应用程序直播"; +"lng_group_call_rtmp_url_subtitle" = "服务器 URL"; +"lng_group_call_rtmp_url_copy" = "拷贝服务器 URL"; +"lng_group_call_rtmp_url_copied" = "服务器地址已复制到剪贴板。"; +"lng_group_call_rtmp_key_subtitle" = "串流密钥"; +"lng_group_call_rtmp_key_copy" = "拷贝串流密钥"; +"lng_group_call_rtmp_key_copied" = "推流密钥已复制到剪贴板。"; +"lng_group_call_rtmp_key_warning" = "**从不分享您的推流密钥给任何人或在直播中展示它!**"; +"lng_group_call_rtmp_info" = "要通过其他软件直播,请在你的直播软件中输入服务器地址和推流码。推荐使用软件编码(例如OBS的x264)\n\n先在你的直播软件中开始直播,推流成功后点击下面的“开始直播”按钮。"; +"lng_group_call_rtmp_start" = "开始直播"; +"lng_group_call_rtmp_revoke" = "撤销推流密钥"; +"lng_group_call_rtmp_revoke_sure" = "您确定要撤销您的推流密钥吗?"; + +"lng_group_call_rtmp_viewers#other" = "{count} 位访客"; + +"lng_confcall_join_title" = "群组通话"; +"lng_confcall_join_text" = "您已获邀请加入此群组通话。"; +"lng_confcall_join_text_inviter" = "{user} 邀请您进入 Tidings 通话。"; +"lng_confcall_already_joined_one" = "{user} 已加入此通话。"; +"lng_confcall_already_joined_two" = "{user} 和 {other} 已加入此次通话。"; +"lng_confcall_already_joined_three" = "{user}、{other} 和 {third} 已加入本次通话。"; + +"lng_confcall_already_joined_many#other" = "{user}, {other} 和其他 {count} 个人已加入本次通话。"; +"lng_confcall_join_button" = "加入群组通话"; +"lng_confcall_create_call" = "开始新通话"; + +"lng_confcall_create_call_description#other" = "您最多可以向通话添加 {count} 位参与者。"; +"lng_confcall_create_title" = "新建通话"; +"lng_confcall_create_link" = "创建通话链接"; +"lng_confcall_create_link_description" = "您可以创建一个链接以允许您在 Tidings 上的朋友加入通话。"; +"lng_confcall_link_revoke" = "撤销链接"; +"lng_confcall_link_revoked_title" = "链接已撤销"; +"lng_confcall_link_inactive" = "此链接不再有效。"; +"lng_confcall_link_revoked_text" = "已生成新的链接。"; +"lng_confcall_link_title" = "通话链接"; +"lng_confcall_link_about" = "Tidings 上的任何人都可以通过以下链接加入您的通话。"; +"lng_confcall_link_or" = "或"; +"lng_confcall_link_join" = "率先加入通话并在通话中添加其他人。 {link}"; +"lng_confcall_link_join_link" = "开始通话 {arrow}"; +"lng_confcall_inactive_title" = "开始群组通话"; +"lng_confcall_inactive_about" = "此通话不再有效。您可以开始新通话。"; +"lng_confcall_invite_done_user" = "您正在呼叫 {user} 加入通话。"; + +"lng_confcall_invite_done_many#other" = " 您呼叫了 **{count} 个人** 加入。"; +"lng_confcall_invite_already_user" = "{user} 已经在此通话中。"; + +"lng_confcall_invite_already_many#other" = "**{count} 个人** 已经在此通话中。"; +"lng_confcall_invite_privacy_user" = "由于隐私设置,您无法呼叫 {user}。"; + +"lng_confcall_invite_privacy_many#other" = "由于他们的隐私设置,您无法呼叫**{count} 个人**。"; +"lng_confcall_invite_fail_user" = "无法呼叫 {user} 加入通话。"; + +"lng_confcall_invite_fail_many#other" = "无法呼叫 **{count} 个人**加入。"; +"lng_confcall_invite_kicked_user" = "{user} 已被禁止进入此通话。"; + +"lng_confcall_invite_kicked_many#other" = "**{count} 个人** 已从此通话中删除。"; +"lng_confcall_not_accessible" = "此通话已无法访问。"; +"lng_confcall_participants_limit" = "此通话已达到参与者数量上限。"; +"lng_confcall_sure_remove" = "从此通话中移除 {user} ?"; +"lng_confcall_e2e_badge" = "端到端加密"; +"lng_confcall_e2e_badge_small" = "端到端加密"; +"lng_confcall_e2e_about" = "这些表情符号代表加密密钥。所有参与者的密钥必须匹配,当有人加入或离开时密钥会发生变化。"; + +"lng_no_mic_permission" = "Tidings 需要访问您的麦克风,以便您可以拨打电话及录制语音留言。"; + +"lng_player_message_today" = "今天 {time}"; +"lng_player_message_yesterday" = "昨天 {time}"; +"lng_player_message_date" = "{date} {time}"; + +"lng_audio_player_reverse" = "倒序"; +"lng_audio_player_shuffle" = "随机"; +"lng_audio_transcribe_long" = "语音消息过长"; + +"lng_audio_transcribe_trials_left#other" = "在 {date} 前,您还剩余 **{count}** 次免费的语音转录。"; +"lng_audio_transcribe_trials_over" = "请等待至 {date} 或者现在订阅 {link}。"; + +"lng_rights_edit_admin" = "编辑权限"; +"lng_rights_edit_admin_header" = "这位管理员可以做什么?"; +"lng_rights_edit_admin_rank_name" = "自定义头衔"; +"lng_rights_edit_admin_rank_about" = "群组成员将看到此头衔,而不是 “{title}”。"; +"lng_rights_about_add_admins_yes" = "此管理员将能够添加新的管理员,并赋予其与该管理员同等(或更少)的权限。"; +"lng_rights_about_add_admins_no" = "此管理员将不能添加新的管理员。"; +"lng_rights_about_by" = "此管理员由 {user} 于 {date} 提权。"; + +"lng_rights_about_admin_cant_edit" = "您无法修改此管理员的权限。"; +"lng_rights_about_restriction_cant_edit" = "您无法更改此用户的限制。"; +"lng_rights_restriction_for_all" = "群组设定中所有群组成员无权做此操作。"; +"lng_rights_permission_for_all" = "这个选项为所有群组成员启用。"; +"lng_rights_permission_unavailable" = "公开群组不具有此权限。"; +"lng_rights_permission_in_discuss" = "讨论群中无此权限。"; +"lng_rights_permission_cant_edit" = "您不能更改此权限。"; +"lng_rights_user_restrictions" = "用户权限"; +"lng_rights_user_restrictions_header" = "这位用户可以做什么?"; +"lng_rights_default_restrictions_header" = "本群组成员有何权限?"; +"lng_rights_slowmode_header" = "慢速模式"; +"lng_rights_slowmode_off" = "关闭"; +"lng_rights_slowmode_about" = "成员每个周期只能发送一条信息。"; +"lng_rights_slowmode_about_interval" = "成员每 {interval} 只能发送一条信息。"; + +"lng_rights_slowmode_interval_seconds#other" = "每 {count} 秒"; + +"lng_rights_slowmode_interval_minutes#other" = "每 {count} 分钟"; +"lng_rights_boosts_no_restrict" = "不限制助推者"; +"lng_rights_boosts_about" = "启用此功能可始终允许已助推您群组的用户发送消息和媒体。"; +"lng_rights_boosts_about_on" = "选择用户必须为群组助推几次才能不受发送消息的限制。"; +"lng_rights_charge_stars" = "为消息收取星星"; +"lng_rights_charge_stars_about" = "如果您启用此功能,群组的普通成员将必须支付星星才能发送消息。"; +"lng_rights_charge_price" = "设定每条消息的价格"; +"lng_rights_charge_price_about" = "每逢收到一条消息,你的群组将获取设置费用的 {percent}({amount})。"; + +"lng_slowmode_enabled" = "慢速模式已启用,您可以在 {left} 后发送下一条消息。"; +"lng_slowmode_no_many" = "启用慢速模式。您一次最多只能发送一条消息。"; +"lng_slowmode_too_long" = "抱歉,此文本过长而无法作为一条消息发送。\n\n慢速模式已启用,您不能一次发送多条消息。"; + +"lng_slowmode_seconds#other" = "{count} 秒"; + +"lng_payment_confirm_title" = "确认支付"; + +"lng_payment_confirm_text#other" = "{name} 对每条消息收取 **{count}** 颗星星。"; + +"lng_payment_confirm_amount#other" = "**{count}** 颗星星"; + +"lng_payment_confirm_users#other" = "您选择了 **{count}** 个需要支付星星以向他们发送消息的联系人。"; + +"lng_payment_confirm_chats#other" = "你选择了 **{count}** 个需要支付星星以发送消息的聊天。"; + +"lng_payment_confirm_sure#other" = "您愿意支付 {amount} 颗星星以发送 {count} 吗?"; +"lng_payment_confirm_dont_ask" = "不再询问我"; + +"lng_payment_confirm_button#other" = "为 {count} 条消息支付"; +"lng_payment_bar_text" = "{name} 必须为每条发送给你的消息支付 {cost}。"; +"lng_payment_bar_button" = "免收费用"; +"lng_payment_refund_title" = "免收费用"; +"lng_payment_refund_text" = "你确定要允许 {name} 免费向你发送消息吗?"; + +"lng_payment_refund_also#other" = "退还已支付的 {count} 颗星星"; +"lng_payment_refund_confirm" = "确认"; + +"lng_rights_gigagroup_title" = "广播群组"; +"lng_rights_gigagroup_convert" = "转换为广播群组"; +"lng_rights_gigagroup_about" = "广播组可以拥有超过20万名成员,但仅管理员可以发送消息。"; +"lng_gigagroup_convert_title" = "广播群组"; +"lng_gigagroup_convert_feature1" = "不限制成员数量。"; +"lng_gigagroup_convert_feature2" = "仅管理员可以发送消息。"; +"lng_gigagroup_convert_feature3" = "无法转回成常规群组。"; +"lng_gigagroup_convert_sure" = "转换"; +"lng_gigagroup_warning_title" = "确定吗?"; +"lng_gigagroup_warning" = "该群组的普通成员(非管理员)将 **不可恢复地** 失去在该群组中发送消息的权限。\n\n此操作 **无法** 撤消。"; +"lng_gigagroup_done" = "您的群组现在可以拥有超过20万个成员了。"; +"lng_gigagroup_suggest_title" = "群成员已达上限"; +"lng_gigagroup_suggest_text" = "您的群组已经达到了 **200,000** 名成员的上限。\n\n通过将该群组转换为只有管理员才能发布的消息的 **广播群** 可以提升成员上限。\n感兴趣吗?"; +"lng_gigagroup_suggest_more" = "了解更多"; + +"lng_rights_channel_info" = "修改频道信息"; +"lng_rights_channel_manage" = "管理消息"; +"lng_rights_channel_post" = "发布消息"; +"lng_rights_channel_edit" = "编辑其他用户的消息"; +"lng_rights_channel_delete" = "删除其他用户的消息"; +"lng_rights_channel_manage_stories" = "管理动态"; +"lng_rights_channel_post_stories" = "发布动态"; +"lng_rights_channel_edit_stories" = "编辑其他人的动态"; +"lng_rights_channel_delete_stories" = "删除其他用户的动态"; +"lng_rights_channel_manage_calls" = "管理直播"; +"lng_rights_group_info" = "修改群组信息"; +"lng_rights_group_ban" = "封禁用户"; +"lng_rights_group_invite_link" = "通过链接邀请用户"; +"lng_rights_group_invite" = "添加成员"; +"lng_rights_group_pin" = "置顶消息"; +"lng_rights_group_topics" = "管理话题"; +"lng_rights_group_add_topics" = "创建话题"; +"lng_rights_group_manage_calls" = "管理视频聊天"; +"lng_rights_group_delete" = "删除消息"; +"lng_rights_group_anonymous" = "匿名发送"; +"lng_rights_add_admins" = "添加新的管理员"; +"lng_rights_chat_send_text" = "发送消息"; +"lng_rights_chat_send_media" = "发送媒体文件"; +"lng_rights_chat_send_stickers" = "发送贴纸和 GIF 动态图"; +"lng_rights_chat_send_links" = "嵌入链接预览"; +"lng_rights_chat_send_polls" = "发起投票"; +"lng_rights_chat_add_members" = "添加成员"; +"lng_rights_chat_photos" = "照片"; +"lng_rights_chat_videos" = "视频文件"; +"lng_rights_chat_stickers" = "贴纸和 GIF 动态图"; +"lng_rights_chat_music" = "音乐"; +"lng_rights_chat_files" = "文件"; +"lng_rights_chat_voice_messages" = "语音消息"; +"lng_rights_chat_video_messages" = "视频消息"; +"lng_rights_chat_restricted_by" = "被 {user} 于 {date} 限制。"; +"lng_rights_chat_banned_by" = "被 {user} 于 {date} 封禁。"; +"lng_rights_chat_banned_until_header" = "限制至"; +"lng_rights_chat_banned_forever" = "永久"; + +"lng_rights_chat_banned_day#other" = "{count} 天后"; + +"lng_rights_chat_banned_week#other" = "{count} 周后"; +"lng_rights_chat_banned_custom" = "自定义"; +"lng_rights_chat_banned_custom_date" = "直到 {date}"; + +"lng_rights_transfer_group" = "转让群组所有权"; +"lng_rights_transfer_channel" = "转让频道所有权"; +"lng_rights_transfer_check" = "安全检查"; +"lng_rights_transfer_check_about" = "您必须符合以下条件,才可以将群组转让给 {user} :"; +"lng_rights_transfer_check_password" = "已于 **7天前** 启用了**两步验证**。"; +"lng_rights_transfer_check_session" = "• 在当前设备登录时长超过 **24 小时** 。"; +"lng_rights_transfer_check_later" = "请稍后再试。"; +"lng_rights_transfer_set_password" = "设置密码"; +"lng_rights_transfer_about" = "这将转让 **{group}** 的所有权给 **{user}**。"; +"lng_rights_transfer_sure" = "转让"; +"lng_rights_transfer_password_title" = "两步验证"; +"lng_rights_transfer_password_description" = "请输入密码以完成转让。"; +"lng_rights_transfer_done_group" = "{user} 成为了新群主。"; +"lng_rights_transfer_done_channel" = "{user} 现在是频道的所有者。"; + +"lng_bots_password_confirm_check_about" = "您只有满足以下条件才能完成此操作:"; +"lng_bots_password_confirm_title" = "两步验证"; +"lng_bots_password_confirm_description" = "请输入密码确认此操作。"; + +"lng_restricted_send_message" = "此群组的管理员已限制您发送消息。"; +"lng_restricted_send_photos" = "该群组的管理员已限制您发送图片。"; +"lng_restricted_send_videos" = "群管理员已限制您在本群发送视频文件。"; +"lng_restricted_send_music" = "群管理员已限制您在本群发送音乐消息。"; +"lng_restricted_send_files" = "群组管理员已禁止你在群内发布文档。"; +"lng_restricted_send_voice_messages_group" = "群组管理员已禁止你在群内发送语音消息。"; +"lng_restricted_send_video_messages_group" = "该群组的管理员已限制您在此发送视频消息。"; +"lng_restricted_send_stickers" = "此群组的管理员已限制您发送贴纸。"; +"lng_restricted_send_gifs" = "此群组的管理员已限制您发送 GIF 动态图。"; +"lng_restricted_send_inline" = "此群组的管理员已限制您发送机器人内容。"; +"lng_restricted_send_polls" = "此群组的管理员已限制您发送投票。"; +"lng_restricted_boost_group" = "助推此群组以发送消息"; + +"lng_restricted_send_message_until" = "该群组的管理员限制了您在 {date} {time} 之前发送消息。"; +"lng_restricted_send_photos_until" = "该群组的管理员已限制您在 {date} {time} 前发送图片。"; +"lng_restricted_send_videos_until" = "该群组的管理员已限制您在 {date} {time} 前发送视频。"; +"lng_restricted_send_music_until" = "该群组的管理员已限制您在 {date} {time} 前发送音乐。"; +"lng_restricted_send_files_until" = "该群组的管理员已限制您在 {date} {time} 之前发送文件。"; +"lng_restricted_send_voice_messages_until" = "该群组的管理员已限制您在 {date} {time} 之前发送语音消息。"; +"lng_restricted_send_video_messages_until" = "该群组的管理员已限制您在 {date} {time} 之前发送视频消息。"; +"lng_restricted_send_stickers_until" = "本群管理员限制你发送贴纸直到 {date} {time} 。"; +"lng_restricted_send_gifs_until" = "此群组的管理员已限制您发送 GIF 动态图直到 {date} {time}。"; +"lng_restricted_send_inline_until" = "此群组的管理员已限制您在 {date},{time} 之前发送机器人内容。"; +"lng_restricted_send_polls_until" = "本群管理员限制你发起投票直到 {date} {time} 。"; + +"lng_restricted_send_message_all" = "此群组不允许发送消息。"; +"lng_restricted_send_photos_all" = "此群组不允许发送图片。"; +"lng_restricted_send_videos_all" = "此群组不允许发送视频。"; +"lng_restricted_send_music_all" = "此群组不允许发送音频。"; +"lng_restricted_send_files_all" = "此群组不允许发送文件。"; +"lng_restricted_send_voice_messages_all" = "此群组不允许发送语音。"; +"lng_restricted_send_video_messages_all" = "对不起,你不能在本群内发布视频。"; +"lng_restricted_send_stickers_all" = "此群组不允许发送贴纸。"; +"lng_restricted_send_gifs_all" = "此群组不允许发送 GIF 动态图。"; +"lng_restricted_send_inline_all" = "此群组不允许发送机器人内容。"; +"lng_restricted_send_polls_all" = "很抱歉, 此群组中不允许发布投票。"; + +"lng_restricted_send_public_polls" = "抱歉,非匿名投票不能被转发到频道。"; +"lng_restricted_send_paid_media" = "抱歉,付费媒体无法发送到此频道。"; + +"lng_restricted_send_voice_messages" = "{user} 不接受语音消息。"; +"lng_restricted_send_video_messages" = "{user} 不接受视频消息。"; +"lng_restricted_send_non_premium" = "{user} 只接受来自高级版订阅用户的消息。"; +"lng_restricted_send_non_premium_more" = "了解更多..."; + +"lng_send_non_premium_text" = "订阅 **Tidings 高级版**\n以给 {user} 发送消息。"; +"lng_send_non_premium_go" = "获取高级版"; +"lng_send_non_premium_story" = "回复受限"; +"lng_send_non_premium_unlock" = "解锁"; +"lng_send_non_premium_message_toast" = "**{user}** 仅接受来自联系人和 {link} 订阅用户的消息。"; +"lng_send_non_premium_message_toast_link" = "Tidings 高级版"; + +"lng_send_charges_stars_text" = "{user} 对每条消息收取 {amount}。"; +"lng_send_charges_stars_go" = "购买星星"; + +"lng_exceptions_list_title" = "例外"; +"lng_removed_list_title" = "被移除的用户"; + +"lng_admin_log_title_all" = "所有操作"; +"lng_admin_log_title_selected" = "选中的操作"; +"lng_admin_log_filter" = "筛选器"; +"lng_admin_log_filter_title" = "筛选器"; +"lng_admin_log_filter_all_actions" = "所有操作"; +"lng_admin_log_filter_actions_type_subtitle" = "按类型筛选操作"; +"lng_admin_log_filter_actions_member_section" = "成员与管理员"; +"lng_admin_log_filter_actions_subscriber_section" = "订阅者和管理员"; +"lng_admin_log_filter_restrictions" = "新限制"; +"lng_admin_log_filter_admins_new" = "管理员权限"; +"lng_admin_log_filter_members_new" = "新成员"; +"lng_admin_log_filter_subscribers_new" = "新的订阅者"; +"lng_admin_log_filter_actions_settings_section" = "群组设置"; +"lng_admin_log_filter_actions_channel_settings_section" = "频道设置"; +"lng_admin_log_filter_info_group" = "群组信息"; +"lng_admin_log_filter_info_channel" = "频道信息"; +"lng_admin_log_filter_actions_messages_section" = "消息"; +"lng_admin_log_filter_messages_deleted" = "已删除消息"; +"lng_admin_log_filter_messages_edited" = "已编辑消息"; +"lng_admin_log_filter_messages_pinned" = "已置顶消息"; +"lng_admin_log_filter_voice_chats" = "视频聊天"; +"lng_admin_log_filter_voice_chats_channel" = "直播"; +"lng_admin_log_filter_invite_links" = "邀请链接"; +"lng_admin_log_filter_members_removed" = "成员退出"; +"lng_admin_log_filter_subscribers_removed" = "订阅者已离开此频道"; +"lng_admin_log_filter_topics" = "话题"; +"lng_admin_log_filter_sub_extend" = "续订信息"; +"lng_admin_log_filter_all_admins" = "所有用户和管理员"; +"lng_admin_log_filter_actions_admins_subtitle" = "按管理员筛选操作"; +"lng_admin_log_filter_actions_admins_section" = "显示所有管理员的操作"; +"lng_admin_log_about" = "这是什么?"; +"lng_admin_log_about_text" = "此列表包含了在过去 48 小时内群组成员/管理员执行的所有值得注意的操作。"; +"lng_admin_log_about_text_channel" = "此列表包含了在过去 48 小时内频道管理员执行的所有操作。"; +"lng_admin_log_no_results_title" = "未找到操作记录"; +"lng_admin_log_no_results_text" = "近期操作中找不到与您查询请求相符的结果。"; +"lng_admin_log_no_results_search_text" = "找不到包含“{query}”的近期操作。"; +"lng_admin_log_no_events_title" = "暂无操作记录"; +"lng_admin_log_no_events_text" = "在过去的 48 小时内,群组成员/管理员没有执行任何操作。"; +"lng_admin_log_no_events_text_channel" = "在过去的 48 小时内,频道管理员没有执行任何值得注意的操作。"; + +"lng_admin_log_empty_text" = "< 空 >"; +"lng_admin_log_changed_title_channel" = "{from} 将频道名称更改为«{title}»"; +"lng_admin_log_changed_description_group" = "{from} 编辑了群组简介:"; +"lng_admin_log_removed_description_group" = "{from} 移除了群组简介"; +"lng_admin_log_changed_description_channel" = "{from} 编辑了频道简介:"; +"lng_admin_log_removed_description_channel" = "{from} 移除了频道简介"; +"lng_admin_log_previous_description" = "之前的简介"; +"lng_admin_log_changed_link_group" = "{from} 更换了群组链接:"; +"lng_admin_log_removed_link_group" = "{from} 移除了群组链接"; +"lng_admin_log_changed_link_channel" = "{from} 更换了频道链接:"; +"lng_admin_log_removed_link_channel" = "{from} 移除了频道链接"; +"lng_admin_log_previous_link" = "之前的链接"; +"lng_admin_log_reordered_link_group" = "{from} 续订的群组链接:"; +"lng_admin_log_reordered_link_channel" = "{from} 续订的频道链接:"; +"lng_admin_log_previous_links_order" = "先前顺序"; +"lng_admin_log_activated_link" = "{from} 已启用 @{link} 用户名"; +"lng_admin_log_deactivated_link" = "{from} 已停用 @{link} 用户名"; +"lng_admin_log_changed_photo_group" = "{from} 更换了群组头像"; +"lng_admin_log_changed_photo_channel" = "{from} 更换了频道头像"; +"lng_admin_log_removed_photo_group" = "{from} 移除了群组头像"; +"lng_admin_log_removed_photo_channel" = "{from} 移除了频道头像"; +"lng_admin_log_invites_enabled" = "{from} 开启了群组成员的邀请权限 "; +"lng_admin_log_invites_disabled" = "{from} 已停用群组邀请"; +"lng_admin_log_signatures_enabled" = "{from} 开启了消息署名"; +"lng_admin_log_signatures_disabled" = "{from} 关闭了消息署名"; +"lng_admin_log_signature_profiles_enabled" = "{from} 已启用作者资料"; +"lng_admin_log_signature_profiles_disabled" = "{from} 已禁用作者资料"; +"lng_admin_log_forwards_enabled" = "{from} 已允许保存内容"; +"lng_admin_log_forwards_disabled" = "{from} 已限制内容复制"; +"lng_admin_log_history_made_hidden" = "{from} 设置消息记录对新成员不可见"; +"lng_admin_log_history_made_visible" = "{from} 已将消息记录设置为对新成员可见"; +"lng_admin_log_pinned_message" = "{from} 置顶了消息:"; +"lng_admin_log_unpinned_message" = "{from} 已将一条消息取消置顶"; +"lng_admin_log_edited_caption" = "{from} 已编辑说明:"; +"lng_admin_log_edited_media" = "{from} 已编辑媒体文件:"; +"lng_admin_log_edited_media_and_caption" = "{from} 已编辑媒体文件和标题:"; +"lng_admin_log_edited_media_and_removed_caption" = "{from} 已编辑媒体文件并移除了标题:"; +"lng_admin_log_removed_caption" = "{from} 已移除说明"; +"lng_admin_log_previous_caption" = "原说明"; +"lng_admin_log_edited_message" = "{from} 编辑了消息:"; +"lng_admin_log_previous_message" = "原消息"; +"lng_admin_log_deleted_message" = "{from} 删除了消息:"; +"lng_admin_log_sent_message" = "{from} 发送了这条消息:"; +"lng_admin_log_participant_joined" = "{from} 加入了群组"; +"lng_admin_log_participant_joined_channel" = "{from} 关注了频道"; +"lng_admin_log_participant_joined_by_link" = "{from} 通过 {link} 加入群组"; +"lng_admin_log_participant_joined_by_link_channel" = "{from} 通过 {link} 加入频道"; +"lng_admin_log_participant_joined_by_filter_link" = "{from} 通过文件夹邀请链接加入 {link}"; +"lng_admin_log_participant_joined_by_filter_link_channel" = "{from} 通过文件夹的分享链接 {link} 已加入此聊天"; +"lng_admin_log_participant_approved_by_link" = " {user} 同意 {from} 通过 {link} 加入群组"; +"lng_admin_log_participant_approved_by_link_channel" = "{user} 已批准 {from} 使用 {link} 加入频道"; +"lng_admin_log_participant_approved_by_request" = "\n{from} 通过公共请求,已被 {user} 批准进入群组"; +"lng_admin_log_participant_approved_by_request_channel" = "{from} 通过公共请求,已被 {user} 批准进入频道"; +"lng_admin_log_revoke_invite_link" = "{from} 已撤销邀请链接 {link}"; +"lng_admin_log_delete_invite_link" = "{from} 已删除邀请链接 {link}"; +"lng_admin_log_participant_left" = "{from} 已退出群组"; +"lng_admin_log_participant_left_channel" = "{from} 已退出频道"; +"lng_admin_log_stopped_poll" = "{from} 停止了投票:"; +"lng_admin_log_invited" = "邀请了“{user}”"; +"lng_admin_log_banned" = "封禁了“{user}”"; +"lng_admin_log_unbanned" = "已将 {user} 解除封禁"; +"lng_admin_log_restricted" = "修改了对“{user}”的限制至 {until}"; +"lng_admin_log_promoted" = "已修改 {user} 的权限"; +"lng_admin_log_transferred" = "转让所有权给 {user}"; +"lng_admin_log_changed_default_permissions" = "默认权限已修改"; +"lng_admin_log_changed_stickers_group" = "{from} 更换了群组{sticker_set}"; +"lng_admin_log_changed_stickers_set" = "贴纸包"; +"lng_admin_log_removed_stickers_group" = "{from} 已移除群组贴纸包"; +"lng_admin_log_changed_emoji_group" = "{from} 已更改群组 {sticker_set}"; +"lng_admin_log_changed_emoji_set" = "表情包"; +"lng_admin_log_removed_emoji_group" = "{from} 已移除群组的表情包"; +"lng_admin_log_changed_linked_chat" = "{from} 将 {chat} 更改为本频道的讨论群组"; +"lng_admin_log_removed_linked_chat" = "{from} 已移除讨论群组"; +"lng_admin_log_changed_linked_channel" = "{from} 将关联频道更改为 «{chat}»"; +"lng_admin_log_removed_linked_channel" = "{from} 移除了关联频道"; +"lng_admin_log_changed_location_chat" = "{from} 更改群组位置为 {address}"; +"lng_admin_log_removed_location_chat" = "{from} 已移除群组位置"; +"lng_admin_log_changed_slow_mode" = "{from} 将慢速模式调整为 {duration}"; +"lng_admin_log_removed_slow_mode" = "{from} 已停用慢速模式"; +"lng_admin_log_started_group_call" = "{from} 已发起视频聊天"; +"lng_admin_log_started_group_call_channel" = "{from}开始了新的直播"; +"lng_admin_log_discarded_group_call" = "{from} 已结束视频聊天"; +"lng_admin_log_discarded_group_call_channel" = "{from} 已结束直播"; +"lng_admin_log_muted_participant" = "{from} 已在视频聊天中将 {user} 静音"; +"lng_admin_log_muted_participant_channel" = "{user} 已在直播间被 {from} 静音"; +"lng_admin_log_unmuted_participant" = "{from} 在视频聊天中已取消 {user} 的静音"; +"lng_admin_log_unmuted_participant_channel" = "{from} 已在直播中将 {user} 取消静音"; +"lng_admin_log_allowed_unmute_self" = "{from} 已允许新视频聊天参与者发言"; +"lng_admin_log_allowed_unmute_self_channel" = "{from} 已允许直播参与者发言"; +"lng_admin_log_disallowed_unmute_self" = "{from} 已将新加入视频聊天的参与者静音"; +"lng_admin_log_disallowed_unmute_self_channel" = "{from} 已将直播的新参与者静音"; +"lng_admin_log_participant_volume" = "{from} 已将 {user} 的音量调整为 {percent}"; +"lng_admin_log_participant_volume_channel" = "{from} 已将 {user} 的直播音量改为 {percent}"; +"lng_admin_log_antispam_enabled" = "{from} 已启用主动反垃圾措施"; +"lng_admin_log_antispam_disabled" = "{from} 已停用主动反垃圾措施"; +"lng_admin_log_autotranslate_enabled" = "{from} 已启用自动翻译"; +"lng_admin_log_autotranslate_disabled" = "{from} 已禁用自动翻译"; +"lng_admin_log_change_color" = "{from} 已将频道颜色从 {previous} 更改为 {color}"; +"lng_admin_log_set_background_emoji" = "{from} 将背景表情符号设为 {emoji}"; +"lng_admin_log_change_background_emoji" = "{from} 已将频道的背景表情从 {previous} 更改为 {emoji}"; +"lng_admin_log_removed_background_emoji" = "{from} 已移除背景表情符号 {emoji}"; +"lng_admin_log_change_profile_color" = "{from} 将频道资料页的颜色从 {previous} 改为 {color}"; +"lng_admin_log_set_profile_background_emoji" = "{from} 将频道资料页的背景表情符号设置为 {emoji}"; +"lng_admin_log_change_profile_background_emoji" = "{from} 将频道资料页背景表情符号从 {previous} 更改为 {emoji}"; +"lng_admin_log_removed_profile_background_emoji" = "{from} 已删除频道资料页的背景表情符号 {emoji}"; +"lng_admin_log_change_profile_color_group" = "{from} 已将群组资料页颜色从 {previous} 更改为 {color}"; +"lng_admin_log_set_profile_background_emoji_group" = "{from} 已将群组资料页背景表情符号设为 {emoji}"; +"lng_admin_log_change_profile_background_emoji_group" = "{from} 已将群组资料页背景表情符号从 {previous} 更改为 {emoji}"; +"lng_admin_log_removed_profile_background_emoji_group" = "{from} 已移除群组资页料背景表情符号 {emoji}"; +"lng_admin_log_change_wallpaper" = "{from} 已更改频道壁纸"; +"lng_admin_log_set_status" = "{from} 将频道的表情符号状态设置为 {emoji}"; +"lng_admin_log_change_status" = "{from} 将频道的表情符号状态从 {previous} 改为 {emoji}"; +"lng_admin_log_removed_status" = "{from} 已移除频道的表情符号状态 {emoji}"; +"lng_admin_log_set_status_until" = "{from} 已将频道表情状态更改 {emoji} 直到 {date}"; +"lng_admin_log_change_status_until" = "{from} 已将频道表情状态由 {previous} 改为 {emoji},持续到 {date}"; +"lng_admin_log_user_with_username" = "{name} ({mention})"; +"lng_admin_log_messages_ttl_set" = "{from} 开启 {duration}后自动删除消息"; +"lng_admin_log_messages_ttl_changed" = "{from} 将消息的自动删除时间从 {previous} 改为 {duration}"; +"lng_admin_log_messages_ttl_removed" = "{from} 关闭 {duration}后自动删除消息"; +"lng_admin_log_reactions_disabled" = "{from} 已停用表情回应"; +"lng_admin_log_reactions_updated" = "{from} 已更新可用的回应: {emoji}"; +"lng_admin_log_reactions_allowed_all" = "{from} 开启全部表情回复"; +"lng_admin_log_reactions_allowed_official" = "{from} 允许所有官方的表情符号"; +"lng_admin_log_edited_invite_link" = "已编辑邀请链接 {link}"; +"lng_admin_log_invite_link_expire_date" = "过期时间:{previous} -> {limit}"; +"lng_admin_log_invite_link_usage_limit" = "用量限制:{previous} -> {limit}"; +"lng_admin_log_invite_link_label" = "名称: {previous} → {limit}"; +"lng_admin_log_invite_link_request_needed" = "加入请求需要管理员批准。"; +"lng_admin_log_invite_link_request_not_needed" = "加入不再需要管理员批准。"; +"lng_admin_log_topics_enabled" = "{from} 已启用话题"; +"lng_admin_log_topics_disabled" = "{from} 已停用话题"; +"lng_admin_log_topics_created" = "{from} 已创建话题 {topic}"; +"lng_admin_log_topics_changed" = "{from} 将话题 {topic} 更改为 {new_topic}"; +"lng_admin_log_topics_closed" = "{from} 已锁定话题 {topic}"; +"lng_admin_log_topics_reopened" = "{from} 已解锁话题 {topic}"; +"lng_admin_log_topics_hidden" = "{from} 已隐藏 {topic} 话题"; +"lng_admin_log_topics_unhidden" = "{from} 已取消隐藏话题 {topic}"; +"lng_admin_log_topics_deleted" = "{from} 已删除话题 {topic}"; +"lng_admin_log_topics_pinned" = "{from} 已置顶话题 {topic}"; +"lng_admin_log_topics_unpinned" = "{from} 已取消置顶话题 {topic}"; +"lng_admin_log_restricted_forever" = "直到永久"; +"lng_admin_log_restricted_until" = "直到 {date}"; +"lng_admin_log_banned_view_messages" = "查看消息"; +"lng_admin_log_banned_send_messages" = "发送消息"; +"lng_admin_log_banned_send_photos" = "发送照片"; +"lng_admin_log_banned_send_videos" = "发送视频文件"; +"lng_admin_log_banned_send_music" = "发送音频"; +"lng_admin_log_banned_send_files" = "发送文件"; +"lng_admin_log_banned_send_voice_messages" = "发送语音消息"; +"lng_admin_log_banned_send_video_messages" = "发送视频消息"; +"lng_admin_log_banned_send_stickers" = "发送贴纸和 GIF 动态图"; +"lng_admin_log_banned_embed_links" = "嵌入链接预览"; +"lng_admin_log_banned_send_polls" = "发起投票"; +"lng_admin_log_admin_change_info" = "修改信息"; +"lng_admin_log_admin_post_messages" = "发布消息"; +"lng_admin_log_admin_edit_messages" = "编辑消息"; +"lng_admin_log_admin_delete_messages" = "删除消息"; +"lng_admin_log_admin_post_stories" = "发布动态"; +"lng_admin_log_admin_edit_stories" = "编辑他人的动态"; +"lng_admin_log_admin_delete_stories" = "删除动态"; +"lng_admin_log_admin_remain_anonymous" = "保持匿名"; +"lng_admin_log_admin_ban_users" = "封禁用户"; +"lng_admin_log_admin_invite_users" = "添加用户"; +"lng_admin_log_admin_invite_link" = "通过链接邀请用户"; +"lng_admin_log_admin_pin_messages" = "置顶消息"; +"lng_admin_log_admin_manage_topics" = "管理话题"; +"lng_admin_log_admin_create_topics" = "创建话题"; +"lng_admin_log_admin_manage_calls" = "管理视频聊天"; +"lng_admin_log_admin_manage_calls_channel" = "直播管理"; +"lng_admin_log_admin_add_admins" = "添加新的管理员"; +"lng_admin_log_subscription_extend" = "{name} 已将订阅续订到 {date}"; + +"lng_admin_log_antispam_menu_report" = "申诉误报"; +"lng_admin_log_antispam_menu_report_toast" = "你可以在 {link} 中管理反垃圾设置。"; +"lng_admin_log_antispam_menu_report_toast_link" = "群组信息 > 管理员"; + +"lng_terms_signup" = "注册即代表您同意{link}。"; +"lng_terms_signup_link" = "服务条款"; +"lng_terms_header" = "服务条款"; + +"lng_terms_age#other" = "我确认我已满 {count} 周岁"; +"lng_terms_agree" = "同意并继续"; +"lng_terms_decline" = "拒绝"; +"lng_terms_signup_sorry" = "我们对此感到非常抱歉,但这也意味着您现在无法注册 Tidings。\n\n与其它软件不同,我们不会利用您的个人数据来导向广告或其它商业目的。Tidings 储存的所有文件都是为了给您创造一个功能丰富的云服务体验。 您可以在「隐私和安全」 里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n但若您完全无法接受 Tidings 的云数据政策,我们将无法为您提供服务。"; +"lng_terms_update_sorry" = "我们对此感到非常抱歉,但是这意味着我们必须在此离别了。与其它软件不同,我们不会利用您的个人数据来进行广告定位或其它商业目的。Tidings 储存的所有文件都是为了给您创造一个功能丰富的云服务体验。您可以在「隐私和安全」设置里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n但若您完全无法接受 Tidings 的云数据政策,我们将无法为您提供服务。您现在可以销毁您的帐号,或者再等一等,在完全确定无法接受我们的云数据政策之后再选择销毁帐号。"; +"lng_terms_decline_and_delete" = "拒绝并删除帐号"; +"lng_terms_back" = "返回"; +"lng_terms_delete_warning" = "警告,这将不可逆转地删除您的 Tidings 帐号以及所有储存在 Tidings 云服务器中的数据。\n\n注意:为保留个人数据,您可以先行取消,在导出数据并保存后,再删除您的帐号。(若要导出个人数据,请打开 Tidings 最新桌面版,并进入设置 > 高级 > 导出 Tidings 数据 。)"; +"lng_terms_delete_now" = "立即删除"; +"lng_terms_agree_to_proceed" = "请同意以继续使用 {bot} 。"; + +"lng_date_input_day" = "日"; +"lng_date_input_month" = "月"; +"lng_date_input_year" = "年"; + +"lng_forward_title" = "转发消息"; + +"lng_forward_many_title#other" = "转发 {count} 条消息"; +"lng_forward_about" = "您可以删除原发送者的名字,使这条消息看上去像是由您发出的。"; +"lng_forward_many_about" = "您可以删除发件人的姓名,使这些消息看起来像是由您所发送的,而非转发的。"; +"lng_forward_show_sender" = "显示发送者名称"; +"lng_forward_show_senders" = "显示发送者名称"; +"lng_forward_show_caption" = "显示说明文字"; +"lng_forward_show_captions" = "显示说明"; +"lng_forward_change_recipient" = "修改收件人"; +"lng_forward_sender_names_removed" = "已移除发送者名称"; +"lng_forward_header_short" = "转发"; +"lng_forward_action_show_sender" = "显示发送者名称"; +"lng_forward_action_show_senders" = "显示发送者名称"; +"lng_forward_action_hide_sender" = "隐藏发送者名称"; +"lng_forward_action_hide_senders" = "隐藏发送者名称"; +"lng_forward_action_show_caption" = "显示说明"; +"lng_forward_action_show_captions" = "显示说明"; +"lng_forward_action_hide_caption" = "隐藏说明"; +"lng_forward_action_hide_captions" = "隐藏说明"; +"lng_forward_action_change_recipient" = "修改收件人"; +"lng_forward_action_remove" = "取消转发"; + +"lng_passport_title" = "Tidings 通行证"; +"lng_passport_request1" = "{bot} 请求访问您的个人数据"; +"lng_passport_request2" = "以便为您提供服务"; +"lng_passport_create_password" = "请输入一个用于加密\n您的个人数据的密码"; +"lng_passport_about_password" = "您在新的设备上登录时\n也将需要输入这个密码。"; +"lng_passport_password_create" = "创建密码"; +"lng_passport_email_validate" = "验证"; +"lng_passport_code_sent" = "验证码已发送到 {email}"; +"lng_passport_stop_password_sure" = "您确定要关闭本次设置密码的流程吗?"; +"lng_passport_password_placeholder" = "你的密码"; +"lng_passport_next" = "下一步"; +"lng_passport_password_wrong" = "您输入的密码错误。"; +"lng_passport_header" = "所需信息"; +"lng_passport_identity_title" = "身份证件"; +"lng_passport_identity_description" = "上传您的身份证明"; +"lng_passport_identity_passport" = "护照"; +"lng_passport_identity_passport_upload" = "上传一份护照的扫描件"; +"lng_passport_identity_card" = "身份证"; +"lng_passport_identity_card_upload" = "上传一份身份证的扫描件"; +"lng_passport_identity_license" = "驾驶证"; +"lng_passport_identity_license_upload" = "上传一份驾驶证扫描件"; +"lng_passport_identity_internal" = "户口本(Internal Passport)"; +"lng_passport_identity_internal_upload" = "上传一份户口本(Internal Passport)的扫描件"; +"lng_passport_identity_about" = "这份证件必须包含您的照片、姓名、生日、证件号、签发国家、以及失效日期。"; +"lng_passport_address_title" = "住址"; +"lng_passport_address_description" = "上传您的地址证明"; +"lng_passport_address_bill" = "水电气费账单"; +"lng_passport_address_bill_upload" = "上传一份水电气费的扫描件"; +"lng_passport_address_statement" = "银行对账单"; +"lng_passport_address_statement_upload" = "上传一份银行对账单的扫描件"; +"lng_passport_address_agreement" = "租约"; +"lng_passport_address_agreement_upload" = "上传一份租约的扫描件"; +"lng_passport_address_registration" = "护照信息"; +"lng_passport_address_registration_upload" = "上传一份您护照里关于登记信息那一页的扫描件。"; +"lng_passport_address_temporary" = "临时证明信息"; +"lng_passport_address_temporary_upload" = "上传一份您临时证明的扫描件"; +"lng_passport_address_about" = "为了确认您的住址,请上传所选证件(全部页面)的扫描件或照片。"; +"lng_passport_or_title" = "{document}或{second_document}"; +"lng_passport_document_type" = "请选择文件类型:"; +"lng_passport_upload_document" = "上传文件"; +"lng_passport_phone_title" = "电话号码"; +"lng_passport_phone_description" = "输入您的手机号码"; +"lng_passport_email_title" = "电子邮箱"; +"lng_passport_email_description" = "输入您的电子邮箱地址"; +"lng_passport_identity_selfie" = "拍摄一张您拿着证件的自拍照"; +"lng_passport_translation_needed" = "添加您证件的英语翻译"; +"lng_passport_accept_allow" = "您同意 {policy} 隐私政策并允许 {bot} 给您发送消息。"; +"lng_passport_allow" = "您允许 {bot} 给您发送消息。"; +"lng_passport_policy" = "{bot} 隐私政策 "; +"lng_passport_authorize" = "授权"; +"lng_passport_form_error" = "无法获取授权表单。"; +"lng_passport_save_value" = "保存"; +"lng_passport_scan_index" = "扫描件 {index}"; +"lng_passport_upload_scans" = "上传扫描件"; +"lng_passport_upload_more" = "上传更多的扫描件"; +"lng_passport_selfie_title" = "自拍照"; +"lng_passport_selfie_description" = "上传一张您手持文件的照片。请保证您的 ID 与脸清晰可见。"; +"lng_passport_upload_selfie" = "上传自拍照"; +"lng_passport_reupload_selfie" = "重新上传自拍照"; +"lng_passport_front_side_title" = "正面"; +"lng_passport_front_side_description" = "上传证件正面照片。"; +"lng_passport_upload_front_side" = "上传一份文件正面的扫描件"; +"lng_passport_reupload_front_side" = "重新上传文件正面的扫描件"; +"lng_passport_reverse_side_title" = "反面"; +"lng_passport_reverse_side_description" = "上传证件反面照片。"; +"lng_passport_upload_reverse_side" = "上传一份文件反面的扫描件"; +"lng_passport_reupload_reverse_side" = "重新上传文件反面的扫描件"; +"lng_passport_main_page_title" = "文件主页"; +"lng_passport_main_page_description" = "上传证件第一页的扫描件。"; +"lng_passport_upload_main_page" = "上传一份文件主页的扫描件"; +"lng_passport_reupload_main_page" = "重新上传文件主页的扫描件"; +"lng_passport_personal_details" = "个人信息"; +"lng_passport_personal_details_enter" = "填写个人信息"; +"lng_passport_document_details" = "证件信息"; +"lng_passport_choose_image" = "选择扫描件"; +"lng_passport_delete_scan_undo" = "撤消"; +"lng_passport_scan_uploaded" = "上传于 {date}"; +"lng_passport_first_name" = "名字"; +"lng_passport_middle_name" = "中间名"; +"lng_passport_last_name" = "姓氏"; +"lng_passport_birth_date" = "出生日期"; +"lng_passport_gender" = "性别"; +"lng_passport_gender_male" = "男"; +"lng_passport_gender_female" = "女"; +"lng_passport_country" = "国籍"; +"lng_passport_residence_country" = "居住地"; +"lng_passport_country_choose" = "选择国家/地区"; +"lng_passport_document_number" = "证件号"; +"lng_passport_expiry_date" = "失效日期"; +"lng_passport_native_name_title" = "以您所居住的国家语言填写您的名字。"; +"lng_passport_native_name_about" = "以您所居住的国家({country})语言填写您的名字。"; +"lng_passport_native_name_language" = "您的{language}名"; +"lng_passport_native_name_language_about" = "以您所居住的国家语言填写您的名字。"; +"lng_passport_address" = "地址信息"; +"lng_passport_address_enter" = "请提供您的住址"; +"lng_passport_street" = "街道"; +"lng_passport_city" = "城市"; +"lng_passport_state" = "省份/州"; +"lng_passport_postcode" = "邮政编码"; +"lng_passport_translation" = "翻译"; +"lng_passport_use_existing" = "使用 {existing}"; +"lng_passport_use_existing_phone" = "使用当前 Tidings 帐号绑定的手机号。"; +"lng_passport_new_phone" = "或输入新的手机号码"; +"lng_passport_new_phone_code" = "注意:您将在您提供的手机号码上收到一份验证码。"; +"lng_passport_use_existing_email" = "使用当前 Tidings 帐号中设定的电子邮箱。"; +"lng_passport_new_email" = "或者输入新的电子邮箱地址"; +"lng_passport_new_email_code" = "注意:您所提供的电子邮箱将会收到一份验证码。"; +"lng_passport_confirm_phone" = "我们已经给您的手机号码({phone})发送了一条带有验证码的短信。"; +"lng_passport_confirm_email" = "我们已经向电子邮箱({email})发送了一条带有验证码的邮件。"; +"lng_passport_sure_cancel" = "如果继续,您作出的更改将会丢失。"; +"lng_passport_scans_limit_reached" = "抱歉,单份证件的扫描件过多。"; +"lng_passport_delete_document" = "删除证件"; +"lng_passport_delete_document_sure" = "您确定要删除此证件吗?"; +"lng_passport_delete_details" = "删除个人信息"; +"lng_passport_delete_details_sure" = "您确定要删除个人信息吗?"; +"lng_passport_delete_address" = "删除住址信息"; +"lng_passport_delete_address_sure" = "您确定要删除您的地址信息吗?"; +"lng_passport_delete_email" = "删除电子邮箱?"; +"lng_passport_delete_email_sure" = "您确定要删除您的电子邮箱地址吗?"; +"lng_passport_delete_phone" = "删除手机号码?"; +"lng_passport_delete_phone_sure" = "您确定要删除您的手机号码吗?"; +"lng_passport_success" = "已成功授权!"; +"lng_passport_stop_sure" = "您确定要停止本次授权吗?"; +"lng_passport_stop" = "停止"; +"lng_passport_restart_sure" = "出现了意外错误。也许有人在另外的一个 Tidings 应用中作出了改动。您希望重新启动授权程序吗?"; +"lng_passport_restart" = "重启"; +"lng_passport_error_too_large" = "抱歉,此文件过大。"; +"lng_passport_error_bad_size" = "抱歉,此图片的大小不符合要求。"; +"lng_passport_error_cant_read" = "无法识别此文件。请选择「图片」上传。"; +"lng_passport_bad_name" = "只能使用拉丁字母。"; +"lng_passport_wait_upload" = "请等待文件上传完毕。"; +"lng_passport_app_out_of_date" = "抱歉,您的 Tidings 版本太低,无法处理此请求。 请升级 Tidings。"; + +"lng_export_title" = "导出你的数据"; +"lng_export_progress_title" = "正在导出您的数据"; +"lng_export_option_info" = "帐号信息"; +"lng_export_option_info_about" = "您设定的姓名、用户名、手机号码与头像。"; +"lng_export_option_contacts" = "联系人列表"; +"lng_export_option_contacts_about" = "如果您授权 Tidings,您的联系人信息将会被即时同步到 Tidings 云服务器。您可以在 Tidings 移动版应用中的设置项「隐私和安全」中修改此项设定。"; +"lng_export_option_stories" = "动态归档"; +"lng_export_option_stories_about" = "您从 Tidings 移动应用发布的所有动态。"; +"lng_export_option_sessions" = "活跃会话"; +"lng_export_option_sessions_about" = "我们可能会存储这些信息以便您可以通过“设置 - 隐私与安全 - 显示所有会话”查看您已登录的设备。"; +"lng_export_header_other" = "其它"; +"lng_export_option_other" = "其它数据"; +"lng_export_option_other_about" = "其它未在上方选项中提到的数据(测试)。"; +"lng_export_header_chats" = "历史记录导出设置"; +"lng_export_option_personal_chats" = "私聊"; +"lng_export_option_bot_chats" = "机器人对话"; +"lng_export_option_private_groups" = "私密群组"; +"lng_export_option_private_channels" = "私密频道"; +"lng_export_option_public_groups" = "公开群组"; +"lng_export_option_public_channels" = "公开频道"; +"lng_export_option_only_my" = "只导出我的消息"; +"lng_export_header_media" = "媒体文件导出设置"; +"lng_export_option_photos" = "图片"; +"lng_export_option_video_files" = "视频文件"; +"lng_export_option_voice_messages" = "语音消息"; +"lng_export_option_video_messages" = "视频消息"; +"lng_export_option_stickers" = "贴纸"; +"lng_export_option_gifs" = "GIF 动态图"; +"lng_export_option_files" = "文件"; +"lng_export_option_size_limit" = "大小限制:{size}"; +"lng_export_header_format" = "保存路径/格式"; +"lng_export_option_location" = "下载路径:{path}"; +"lng_export_option_format_location" = "格式: {format} ,路径: {path}"; +"lng_export_option_choose_format" = "选择导出格式"; +"lng_export_option_html" = "人类可读的 HTML"; +"lng_export_option_json" = "机器可读的 JSON"; +"lng_export_option_html_and_json" = "以上两者"; +"lng_export_limits" = "从:{from}, 到:{till}"; +"lng_export_beginning" = "最早的消息"; +"lng_export_end" = "现在"; +"lng_export_from_beginning" = "重置"; +"lng_export_till_end" = "重置"; +"lng_export_start" = "导出"; +"lng_export_state_initializing" = "正在初始化…"; +"lng_export_state_userpics" = "头像"; +"lng_export_state_chats_list" = "处理对话记录中…"; +"lng_export_state_chats" = "对话"; +"lng_export_skip_file" = "跳过文件"; +"lng_export_progress" = "您可以现在关闭此窗口,但请不要在数据导出结束之前退出 Tidings。"; +"lng_export_stop" = "停止"; +"lng_export_sure_stop" = "您确定要停止导出您的数据吗?\n\n若您确定的话,下次导出将需要重新开始。"; +"lng_export_about_done" = "已成功导出您的数据。"; +"lng_export_done" = "查看我的数据"; +"lng_export_finished" = "数据导出完成"; +"lng_export_total_amount" = "总计文件: {amount}"; +"lng_export_total_size" = "总大小:{size}"; +"lng_export_folder" = "选择导出文件夹"; +"lng_export_invalid" = "抱歉,您已经开始进行新的数据导出流程,所以本次导出已被终止。"; +"lng_export_delay" = "出于安全考虑,您将能在 {hours}之后开始下载您的数据。我们已经给您的所有设备发送了有关本次导出请求的通知,以保证这是您本人授权。\n\n请在 {date} 后使用同一设备再次请求导出数据。"; +"lng_export_delay_less_than_hour" = "少于一小时"; +"lng_export_suggest_title" = "数据导出已就绪"; +"lng_export_suggest_text" = "您现在可以下载您之前请求导出的数据。开始导出吗?"; +"lng_export_suggest_cancel" = "稍后"; +"lng_export_about_Tidings" = "这是您请求导出的数据。请记住:Tidings 没有任何广告,我们也不会讲您的数据用于广告导向用途,更不会出售您的数据。Tidings 只存储一部分信息以提供功能丰富且安全的云服务。\n\n前往 Tidings 移动版应用中的「设置 - 隐私与安全」查看相应设置。"; +"lng_export_about_contacts" = "如果您授权 Tidings,您的联系人信息将会被即时同步到 Tidings 云服务器。正因如此,您可以在任何设备中通过 Tidings 维系您原来的社交圈。我们将这些信息用于在您的联系人加入 Tidings 时通知您,同时这些信息也将用来保证您看到的是联系人在通讯录中的名字,而不是他们在 Tidings 中的填写的姓名。\n\n您可以停止同步联系人信息,或将它们从云端删除。要如此做,请前往 Tidings 移动版应用中的设置项「隐私和安全」。"; +"lng_export_about_frequent" = "这个评级显示了您经常联系的用户。Tidings 使用此数据来填入搜索栏顶部的“用户”部分。这个评级也会考虑您使用消息框内机器人的频率,这样 Tidings 可以在附件菜单给您推荐您最可能使用的机器人(或者您在文本首位打出“@”时给您推荐)。\n\n若要删除此数据,前往「设置 - 隐私和安全」并将「推荐常用联系人」设为关闭(需要 Tidings iOS v.4.8.3 / Tidings Android v.4.8.10 或更高版本)。"; +"lng_export_about_sessions" = "我们存储会话信息以便您可以通过「设置 - 隐私与安全 - 活跃会话」查看您已登录的设备。"; +"lng_export_about_web_sessions" = "我们存储这些信息以便您可以通过「设置 - 隐私与安全 - 活跃会话」查看您使用 Tidings 登录的网站。"; +"lng_export_about_chats" = "此页面列出了本次导出时所有的历史聊天记录。"; +"lng_export_about_left_chats" = "以下是在本次导出时,您已退出或被封禁的所有超级群组和频道。\n\n请注意,在退出您创建的超级群组/频道时,您可以选择将其删除,或选择仅退出(如此您便可以在之后重新加入,或者只是退出以使其社群继续存在)。"; + +"lng_language_switch_title" = "是否更改语言?"; +"lng_language_switch_about_official" = "您即将使用一个完成度为 {percent}% 的自定义语言包 ({lang_name})。\n\n此操作将会应用到整个界面。您可以在 {link} 建议更正。\n\n您可以随时在“设置”中更改语言。"; +"lng_language_switch_about_unofficial" = "您即将使用一个完成度为 {percent}% 的自定义语言包 ({lang_name})。\n\n此操作将会应用到整个界面。您可以在 {link} 建议更正。\n\n您可以随时在“设置”中更改语言。"; +"lng_language_switch_link" = "翻译平台"; +"lng_language_switch_apply" = "应用语言"; +"lng_language_not_found" = "抱歉,该语言包不存在。"; +"lng_language_already" = "您已经在使用此语言包。 您随时可以在“设置”中更改语言。"; +"lng_language_not_ready_title" = "数据不足"; +"lng_language_not_ready_about" = "很遗憾,这个自定义语言包 {lang_name} 不包含Tidings 桌面版的数据。您可以使用这个链接 {link} 为此语言包做出贡献。"; +"lng_language_not_ready_link" = "翻译平台"; + +"lng_translate_box_error" = "翻译失败。"; + +"lng_translate_settings_subtitle" = "翻译消息"; +"lng_translate_settings_show" = "显示 “翻译” 按钮"; +"lng_translate_settings_chat" = "翻译全部聊天记录"; +"lng_translate_settings_choose" = "不用翻译"; +"lng_translate_settings_about" = "当你打开文本的菜单時,便会出现 '翻译' 按钮。"; +"lng_translate_settings_one" = "请至少选择一种语言,以便将其设为“翻译为”的语言"; + +"lng_launch_exe_warning" = "此文件的扩展名为 {extension}。\n它可能会损害您的计算机。\n你确定要运行吗?"; +"lng_launch_other_warning" = "这个文件的拓展名为 {extension}。\n你确定要打开吗?"; +"lng_launch_svg_warning" = "\n打开此文件可能会将您的IP地址暴露给发送者。您确定继续吗?"; +"lng_launch_exe_sure" = "运行"; +"lng_launch_other_sure" = "打开"; +"lng_launch_exe_dont_ask" = "不要再次询问我"; +"lng_launch_dont_ask" = "记住该文件类型"; +"lng_launch_dont_ask_settings" = "您稍后可以在设置 > 隐私和安全 > 文件打开确认中编辑受信任的文件类型。"; + +"lng_polls_anonymous" = "匿名投票"; +"lng_polls_public" = "投票"; +"lng_polls_anonymous_quiz" = "匿名问答"; +"lng_polls_public_quiz" = "问答"; +"lng_polls_closed" = "最终结果"; + +"lng_polls_votes_count#other" = "{count} 人投票"; +"lng_polls_votes_none" = "无人投票"; + +"lng_polls_answers_count#other" = "{count} 个回答"; +"lng_polls_answers_none" = "无人回答"; +"lng_polls_submit_votes" = "提交投票"; +"lng_polls_view_results" = "查看结果"; +"lng_polls_retract" = "撤回投票"; +"lng_polls_stop" = "停止投票"; +"lng_polls_stop_warning" = "如果您现在停止这个投票,任何人都不能再进行投票了。 此操作无法撤消。"; +"lng_polls_stop_sure" = "停止"; +"lng_polls_create" = "发起投票"; +"lng_polls_create_title" = "发起投票"; +"lng_polls_create_question" = "问题"; +"lng_polls_create_question_placeholder" = "请输入问题"; +"lng_polls_create_options" = "投票选项"; +"lng_polls_create_option_add" = "添加一个选项…"; + +"lng_polls_create_limit#other" = "您还可以添加 {count} 个选项。"; +"lng_polls_create_maximum" = "您添加的选项数量已经达到上限。"; +"lng_polls_create_settings" = "设置"; +"lng_polls_create_anonymous" = "匿名投票"; +"lng_polls_create_multiple_choice" = "多项选择"; +"lng_polls_create_quiz_mode" = "问答模式"; +"lng_polls_create_button" = "创建"; +"lng_polls_create_one_answer" = "问答模式下的投票只能有一个正确答案。"; +"lng_polls_choose_question" = "请输入一个问题。"; +"lng_polls_choose_answers" = "请输入至少两个选项。"; +"lng_polls_choose_correct" = "请选择正确答案。"; +"lng_polls_solution_title" = "注释"; +"lng_polls_solution_placeholder" = "添加评论(可选)"; +"lng_polls_solution_about" = "选错的用户将会看到这段文字,因此非常适合教育用途。"; + +"lng_polls_poll_results_title" = "投票结果"; +"lng_polls_quiz_results_title" = "问答结果"; + +"lng_polls_show_more#other" = "显示更多 ({count})"; +"lng_polls_votes_collapse" = "折叠"; + +"lng_outdated_title" = "**请更新您的操作系统。**"; +"lng_outdated_title_bits" = "请切换至64位操作系统。"; +"lng_outdated_soon" = "否则,Tidings Desktop 将在 {date} 停止更新。"; +"lng_outdated_now" = "以便 Tidings Desktop 可以更新到最新版本。"; + +"lng_filters_all" = "所有聊天"; +"lng_filters_all_short" = "全部"; +"lng_filters_setup" = "编辑"; +"lng_filters_title" = "文件夹"; +"lng_filters_subtitle" = "我的文件夹"; +"lng_filters_no_chats" = "无聊天"; + +"lng_filters_chats_count#other" = "{count} 个聊天"; +"lng_filters_create" = "创建新文件夹"; +"lng_filters_about" = "为不同聊天分组创建文件夹,并在它们之间快速切换。"; +"lng_filters_recommended" = "推荐的文件夹"; +"lng_filters_recommended_add" = "添加"; +"lng_filters_restore" = "撤销"; +"lng_filters_new" = "新文件夹"; +"lng_filters_edit" = "编辑文件夹"; +"lng_filters_setup_menu" = "编辑文件夹"; +"lng_filters_new_name" = "文件夹名称"; +"lng_filters_enable_animations" = "启用动画"; +"lng_filters_disable_animations" = "禁用动画"; +"lng_filters_add_chats" = "添加聊天"; +"lng_filters_remove_chats" = "添加以排除聊天"; +"lng_filters_include" = "包含的聊天"; +"lng_filters_include_about" = "选择将出现在此文件夹中的聊天和聊天类型。"; +"lng_filters_exclude" = "排除的聊天"; +"lng_filters_exclude_about" = "选择不会出现在此文件夹中的聊天或聊天类型。"; +"lng_filters_create_button" = "创建"; +"lng_filters_include_title" = "包含的对话"; +"lng_filters_exclude_title" = "排除对话"; +"lng_filters_edit_types" = "聊天类型"; +"lng_filters_edit_chats" = "聊天"; +"lng_filters_include_contacts" = "联系人"; +"lng_filters_include_groups" = "群组"; +"lng_filters_include_channels" = "频道"; +"lng_filters_include_bots" = "机器人"; +"lng_filters_name_people" = "人"; +"lng_filters_name_unread" = "未读"; +"lng_filters_name_unmuted" = "未静音"; +"lng_filters_empty" = "请至少为此文件夹添加一个聊天。"; +"lng_filters_default" = "请为此文件夹更改至少一条规则。"; +"lng_filters_type_contacts" = "联系人"; +"lng_filters_type_non_contacts" = "非联系人"; +"lng_filters_type_groups" = "群组"; +"lng_filters_type_channels" = "频道"; +"lng_filters_type_new" = "新聊天"; +"lng_filters_type_existing" = "现有聊天"; +"lng_filters_type_bots" = "机器人"; +"lng_filters_type_no_archived" = "归档"; +"lng_filters_type_no_muted" = "已静音"; +"lng_filters_type_no_read" = "已读"; +"lng_filters_icon_header" = "选择一个图标"; +"lng_filters_context_edit" = "编辑文件夹"; +"lng_filters_context_remove" = "移除"; +"lng_filters_remove_sure" = "即将移除该分组,您的对话将不会被删除。"; +"lng_filters_remove_yes" = "移除"; +"lng_filters_menu_add" = "添加至文件夹"; +"lng_filters_toast_add" = "{folder} 已成功添加到{chat}"; +"lng_filters_toast_remove" = "已从{folder}中移除{chat}"; +"lng_filters_shareable_status" = "可共享的文件夹"; +"lng_filters_view_subtitle" = "标签预览"; +"lng_filters_vertical" = "移到左边"; +"lng_filters_horizontal" = "将分组显示在顶部"; +"lng_filters_enable_tags" = "显示文件夹标签"; +"lng_filters_enable_tags_about" = "显示聊天列表中每个聊天文件夹名称。"; +"lng_filters_enable_tags_about_premium" = "订阅 **{link}** 以显示聊天列表中每个聊天的文件夹名称。"; +"lng_filters_tag_color_subtitle" = "聊天列表中的文件夹颜色"; +"lng_filters_tag_color_about" = "为此文件夹的标签选择颜色。"; +"lng_filters_tag_color_no" = "无标签"; + +"lng_filters_delete_sure" = "您确定要删除此文件夹吗?这将同时停用所有用来分享此文件夹的邀请链接。"; +"lng_filters_link" = "分享文件夹"; +"lng_filters_link_has" = "邀请链接"; + +"lng_filters_checkbox_remove_bot" = "从所有的文件夹里移除机器人"; +"lng_filters_checkbox_remove_group" = "从所有的文件夹里移除群组"; +"lng_filters_checkbox_remove_channel" = "从所有的文件夹里移除频道"; + +"lng_filters_link_create" = "创建邀请链接"; +"lng_filters_link_cant" = "您不能分享包含特定聊天类别(比如“群组”、“联系人”等)的分组。"; +"lng_filters_link_about" = "与其他人分享此文件夹中的群组和频道的访问权。"; +"lng_filters_link_about_many" = "创建更多链接,为不同的人设置不同的访问权限。"; +"lng_filters_link_title" = "分享文件夹"; +"lng_filters_link_share_about" = "获得此链接的所有人都可以添加 {folder} 文件夹并加入以下选择的聊天。"; +"lng_filters_link_subtitle" = "邀请链接"; +"lng_filters_link_chats_none" = "没有选择任何聊天"; + +"lng_filters_link_chats#other" = "已选择 {count} 个聊天"; +"lng_filters_link_bot_status" = "您无法分享与机器人的聊天"; +"lng_filters_link_bot_error" = "与机器人的聊天无法共享。"; +"lng_filters_link_private_status" = "您无法分享私聊"; +"lng_filters_link_private_error" = "无法分享私聊。"; +"lng_filters_link_noadmin_status" = "你不能在这里邀请用户"; +"lng_filters_link_noadmin_group_error" = "您没有管理员权限,无法分享此私密群组的邀请链接。"; +"lng_filters_link_noadmin_channel_error" = "您没有管理员权限,无法分享此私密频道的邀请链接。"; +"lng_filters_link_already_group" = "您已经加入此群组"; +"lng_filters_link_already_channel" = "您已经订阅此频道"; +"lng_filters_link_inaccessible" = "此聊天无法访问"; +"lng_filters_link_chats_about" = "选择所有通过邀请链接添加文件夹的用户可以加入的群组和频道。"; +"lng_filters_link_no_about" = "该文件夹中没有您可以与他人分享的聊天。"; +"lng_filters_link_chats_no" = "无法分享这些聊天"; +"lng_filters_link_chats_no_about" = "您只能分享允许您创建邀请链接的群组和频道。"; +"lng_filters_link_name_it" = "为此链接命名"; +"lng_filters_link_delete_sure" = "你确定想要删除此链接吗?"; +"lng_filters_link_qr_about" = "Tidings 上的所有人都可以扫描此二维码以添加此文件夹,并加入此邀请链接中包含的所有聊天。"; +"lng_filters_link_group_admin_error" = "无法将其中一个群组添加至文件夹中,因为其中一位管理员已加入过多群组和频道。"; +"lng_filters_by_link_title" = "添加文件夹"; +"lng_filters_by_link_sure" = "您是否想要创建名为 {folder} 的文件夹, 并添加其群组和频道?"; + +"lng_filters_by_link_join#other" = "将加入 {count} 个聊天"; +"lng_filters_by_link_add_button" = "添加 {folder}"; +"lng_filters_by_link_add_no" = "无需添加此文件夹"; +"lng_filters_by_link_more" = "将聊天添加到文件夹"; +"lng_filters_by_link_more_sure" = "您是否要加入聊天,并将其添加至 {folder} 文件夹?"; +"lng_filters_by_link_about" = "您可以取消选择不想加入的聊天。"; + +"lng_filters_by_link_and_join_button#other" = "加入聊天"; +"lng_filters_by_link_join_no" = "不加入任何聊天"; +"lng_filters_by_link_already" = "文件夹已添加"; +"lng_filters_by_link_already_about" = "您已添加 {folder} 文件夹以及其中的所有聊天。"; + +"lng_filters_by_link_in#other" = "此文件夹中的 {count} 个聊天"; +"lng_filters_by_link_remove" = "移除文件夹"; +"lng_filters_by_link_remove_sure" = "您是否也要同时退出 {folder} 中包括的所有聊天?"; + +"lng_filters_by_link_quit#other" = "即将退出 {count} 个聊天"; +"lng_filters_by_link_select" = "全选"; +"lng_filters_by_link_deselect" = "取消全选"; +"lng_filters_by_link_about_quit" = "你可以取消选择不想退出的聊天。"; +"lng_filters_by_link_remove_button" = "移除文件夹并保留聊天"; + +"lng_filters_by_link_and_quit_button#other" = "移除文件夹和聊天"; +"lng_filters_added_title" = "已添加 {folder} 文件夹"; + +"lng_filters_added_also#other" = "您还加入了 {count} 个聊天。"; +"lng_filters_updated_title" = " {folder} 文件夹已更新"; + +"lng_filters_updated_also#other" = "您已加入 {count} 个新聊天。"; + +"lng_filters_bar_you_can#other" = "您可以加入 {count} 个新聊天"; + +"lng_filters_bar_view#other" = "点击此处查看"; + +"lng_chat_theme_change" = "更换主题颜色"; +"lng_chat_theme_wallpaper" = "设置壁纸"; +"lng_chat_theme_none" = "无主题"; +"lng_chat_theme_apply" = "应用主题"; +"lng_chat_theme_change_wallpaper" = "更改壁纸"; +"lng_chat_theme_title" = "选择主题"; +"lng_chat_theme_cant_voice" = "抱歉,当有未发送的语音消息时您无法更改主题。"; + +"lng_photo_editor_menu_delete" = "删除"; +"lng_photo_editor_menu_flip" = "翻转"; +"lng_photo_editor_menu_duplicate" = "复制"; + +"lng_voice_speed_slow" = "慢"; +"lng_voice_speed_normal" = "正常"; +"lng_voice_speed_medium" = "中速"; +"lng_voice_speed_fast" = "快"; +"lng_voice_speed_very_fast" = "非常快"; +"lng_voice_speed_super_fast" = "超级快"; + +"lng_view_button_user" = "发送信息"; +"lng_view_button_bot" = "查看机器人"; +"lng_view_button_group" = "查看群组"; +"lng_view_button_channel" = "查看频道"; +"lng_view_button_bot_app" = "打开应用"; +"lng_view_button_background" = "查看壁纸"; +"lng_view_button_theme" = "预览主题"; +"lng_view_button_message" = "查看消息"; +"lng_view_button_story" = "查看动态"; +"lng_view_button_voice_chat" = "视频聊天"; +"lng_view_button_voice_chat_channel" = "直播"; +"lng_view_button_request_join" = "请求加入"; +"lng_view_button_external_link" = "打开链接"; +"lng_view_button_boost" = "助推"; +"lng_view_button_giftcode" = "打开"; +"lng_view_button_iv" = "即时预览"; +"lng_view_button_stickerset" = "查看贴纸"; +"lng_view_button_emojipack" = "查看表情"; +"lng_view_button_collectible" = "查看典藏品"; +"lng_view_button_call" = "加入群组通话"; + +"lng_sponsored_hide_ads" = "隐藏"; +"lng_sponsored_title" = "什么是赞助消息?"; +"lng_sponsored_info_description1" = "与其他软件不同,Tidings 不会利用您的隐私来显示个性化的广告。赞助消息的投放完全基于公开频道的内容。这意味着 Tidings 不会通过挖掘或分析用户信息来显示广告,并且公开频道的所有订阅者都将显示完全相同的赞助消息。\n\n与其他软件不同,Tidings 不会追踪您是否点击赞助消息,更不会基于您的活动信息生成用户画像。与此同时,为了避免来自第三方的窥探,我们还禁止向赞助消息中插入外部链接。我们相信每个人都有保护隐私的权利,而科技平台应该尊重用户的隐私权。\n\nTidings 向数以亿计的用户提供免费服务的同时,也需要承担由此产生的高昂运营成本,例如服务器和流量开销。为了将 Tidings 的独立自由和价值观延续下去,我们将用户隐私权谨记于心,开发了这套付费赞助消息系统。欢迎有责任的广告商与我们取得联系:"; +"lng_sponsored_info_description2" = "赞助商消息目前仍然处于测试阶段。全面启动赞助商消息系统,并且 Tidings 能够平衡运营成本之后,我们将与显示赞助商消息的频道所有者共同分享广告收入。\n\n线上广告不应再是滥用用户隐私的代名词。让我们共同携手,重新定义科技公司的运营模式。"; +"lng_sponsored_info_menu" = "广告商信息"; +"lng_sponsored_info_submenu" = "广告商:{text}"; +"lng_sponsored_menu_revenued_about" = "关于此广告"; +"lng_sponsored_menu_revenued_report" = "举报广告"; +"lng_sponsored_revenued_subtitle" = "Tidings 广告与其他平台上的广告大有不同。这类广告:"; +"lng_sponsored_revenued_info1_title" = "尊重您的隐私"; +"lng_sponsored_revenued_info1_description" = "Tidings 上的广告不会使用您的个人信息,而是基于您所在的频道来展示的。"; +"lng_sponsored_revenued_info1_bot_description" = "Tidings 上的广告不会使用您的个人资料,而是仅基于您看到广告的迷你应用内容。"; +"lng_sponsored_revenued_info1_search_description" = "此类广告不会使用您的个人信息,仅基于您输入的搜索关键词显示。"; +"lng_sponsored_revenued_info2_title" = "帮助频道创作者"; +"lng_sponsored_revenued_info2_bot_title" = "帮助机器人开发者"; +"lng_sponsored_revenued_info2_description" = "Tidings 广告收入的 50% 将归广告频道的所有者所有。"; +"lng_sponsored_revenued_info2_bot_description" = " 广告收入的 50% 将归广告机器人的开发者所有。"; +"lng_sponsored_revenued_info3_title" = "可以移除"; + +"lng_sponsored_revenued_info3_description#other" = "您可以通过订阅 {link} 关闭广告,而等级为 {count} 的频道可以为其订阅者移除这些广告。"; +"lng_sponsored_revenued_info3_bot_description" = "您可以通过订阅 {link} 关闭广告。"; +"lng_sponsored_revenued_info3_search_description" = "您可以通过订阅 Tidings 高级版来关闭广告。 {link}"; +"lng_sponsored_revenued_info3_search_link" = "立即订阅 {arrow}"; +"lng_sponsored_revenued_footer_title" = "我可以投放广告吗?"; +"lng_sponsored_revenued_footer_description" = "任何人都可以用极低的预算在这个频道投放广告。详情请查看 **Tidings 广告平台**。{link}"; +"lng_sponsored_revenued_footer_bot_description" = "任何人都可以创建广告并在此机器人中展示——只需最小花费。请查看 **Tidings 广告平台** 了解详情。{link}"; +"lng_sponsored_revenued_footer_search_description" = "任何人都可以创建广告并在任何搜索查询的结果中显示,查看 **Tidings 广告平台** 了解详情。{link}"; +"lng_sponsored_top_bar_hide" = "移除"; + +"lng_tidings_features_url" = "https://tidings.me/tidingsTips"; + +"lng_mini_apps_disclaimer_title" = "警告"; +"lng_mini_apps_disclaimer_text" = "您即将使用一个 **不属于 Tidings** 的独立第三方小程序。您必须同意小程序使用条款后才能继续。"; +"lng_mini_apps_disclaimer_button" = "我同意 {link}"; +"lng_mini_apps_disclaimer_link" = "使用条款"; +"lng_mini_apps_tos_url" = "https://tidings.org/tos/mini-apps"; + +"lng_ringtones_box_title" = "通知铃声"; +"lng_ringtones_box_cloud_subtitle" = "选择你喜欢的铃声"; +"lng_ringtones_box_upload_choose" = "选择铃声文件"; +"lng_ringtones_box_upload_button" = "上传铃声"; +"lng_ringtones_box_about" = "在任意聊天中右击简短语音消息图标或 MP3 文件,然后选择 “保存为通知铃声” 后,就会出现在这里。"; +"lng_ringtones_box_default" = "默认"; +"lng_ringtones_box_no_sound" = "静音"; +"lng_ringtones_toast_added" = "声音已添加!"; +"lng_ringtones_error_max_size" = "抱歉,您的文件太大了,铃声最大为 {size}。"; +"lng_ringtones_error_max_duration" = "抱歉,铃声时长最多为 {duration}。"; + +"lng_forum_topic_new" = "新建话题"; +"lng_forum_topic_edit" = "编辑话题"; +"lng_forum_topic_title" = "话题名称"; +"lng_forum_topic_close" = "锁定话题"; +"lng_forum_topic_reopen" = "解锁话题"; +"lng_forum_topic_closed" = "该话题现在处于关闭状态。"; +"lng_forum_topic_delete" = "删除"; +"lng_forum_topic_delete_sure" = "您确定要删除该话题吗?"; +"lng_forum_topic_created_title_my" = "差不多完成!"; +"lng_forum_topic_created_body_my" = "发送第一条消息以启动此话题。"; +"lng_forum_topic_created_title" = "话题已开始!"; +"lng_forum_topic_created_body" = "发送一条消息\n以开始话题。"; +"lng_forum_topics_switch" = "话题"; + +"lng_forum_topics_not_enough#other" = "只有超过 **{count}人** 的群组才能启用话题功能。"; +"lng_forum_topics_no_discussion" = "目前无法在频道的讨论群组中使用话题。"; +"lng_forum_choose_title_and_icon" = "请选择话题名称与图标"; +"lng_forum_replies_only" = "您可以在话题里回复消息。"; +"lng_forum_message_in" = "{topic} 中的消息"; +"lng_forum_reply_in" = "在 {topic} 回复"; +"lng_forum_no_topics" = "此群组中目前没有创建话题。"; +"lng_forum_create_topic" = "创建话题"; +"lng_forum_discard_sure" = "您确定要放弃该话题吗?"; +"lng_forum_view_as_messages" = "以消息模式查看"; +"lng_forum_view_as_topics" = "以话题模式查看"; +"lng_forum_no_messages" = "暂无消息"; + +"lng_forum_messages#other" = "{count} 条消息"; +"lng_forum_show_topics_list" = "展示话题列表"; + +"lng_request_peer_requirements" = "要求"; +"lng_request_peer_rights" = "您必须拥有以下管理员权限:{rights}"; +"lng_request_peer_rights_and" = "{rights} 和 {last}"; +"lng_request_peer_confirm" = "您确定要将 {chat} 发送至 {bot} 吗?"; +"lng_request_peer_confirm_add" = "这也会将把 {bot} 添加到 {chat} 中。"; +"lng_request_peer_confirm_rights" = "这也会将 {bot} 加入到 {chat} 中,并具有以下权限:{rights}。"; +"lng_request_peer_confirm_send" = "发送"; +"lng_request_user_title" = "选择用户"; +"lng_request_users_title" = "选择用户"; +"lng_request_user_premium_yes" = "用户必须拥有 Tidings 高级版订阅。"; +"lng_request_user_premium_no" = "用户不能拥有 Tidings 高级版订阅。"; +"lng_request_user_no" = "没有符合条件的用户"; +"lng_request_user_no_about" = "未找到符合此机器人要求的用户。"; +"lng_request_bot_title" = "选择机器人"; +"lng_request_bot_no" = "无机器人"; +"lng_request_bot_no_about" = "您还没有机器人账号。"; +"lng_request_group_title" = "选择群组"; +"lng_request_group_no" = "没有符合条件的群组"; +"lng_request_group_no_about" = "您没有符合此机器人要求的群组。"; +"lng_request_group_public_yes" = "该群组必须是公开的。"; +"lng_request_group_public_no" = "此群组必须是私密群组。"; +"lng_request_group_topics_yes" = "群组必须开启话题功能。"; +"lng_request_group_topics_no" = "群组必须关闭话题功能。"; +"lng_request_group_am_owner" = "您必须是群组的所有者。"; +"lng_request_group_change_info" = "更改群组信息"; +"lng_request_group_delete_messages" = "删除消息"; +"lng_request_group_ban_users" = "封禁用户"; +"lng_request_group_invite" = "通过链接邀请用户"; +"lng_request_group_pin_messages" = "置顶消息"; +"lng_request_group_manage_topics" = "管理话题"; +"lng_request_group_manage_video_chats" = "管理视频聊天"; +"lng_request_group_anonymous" = "保持匿名"; +"lng_request_group_add_admins" = "新增管理员"; +"lng_request_group_create" = "为此创建一个新群组"; +"lng_request_channel_title" = "选择频道"; +"lng_request_channel_no" = "没有符合条件的频道"; +"lng_request_channel_no_about" = "您没有符合此机器人要求的频道。"; +"lng_request_channel_public_yes" = "频道必须为公开状态。"; +"lng_request_channel_public_no" = "频道必须为私密状态。"; +"lng_request_channel_am_owner" = "你必须是频道的拥有者。"; +"lng_request_channel_change_info" = "更改频道信息"; +"lng_request_channel_post_messages" = "发布消息"; +"lng_request_channel_edit_messages" = "编辑其用户的消息"; +"lng_request_channel_delete_messages" = "删除消息"; +"lng_request_channel_add_subscribers" = "添加订阅者"; +"lng_request_channel_manage_livestreams" = "管理直播"; +"lng_request_channel_add_admins" = "新增管理员"; +"lng_request_channel_create" = "为此创建新频道"; + +"lng_userpic_builder_color_subtitle" = "选择背景"; +"lng_userpic_builder_emoji_subtitle" = "选择表情或贴纸"; + +"lng_stories_my_name" = "我的动态"; +"lng_stories_archive" = "隐藏动态"; +"lng_stories_unarchive" = "取消隐藏动态"; + +"lng_stories_row_count#other" = "{count} 条动态"; + +"lng_stories_views#other" = "{count} 次浏览"; +"lng_stories_no_views" = "还没有人看过"; +"lng_stories_view_reactions" = "查看回应"; +"lng_stories_unsupported" = "您当前的 Tidings 版本\n不支持该动态。"; +"lng_stories_cant_reply" = "您无法回复此动态。"; +"lng_stories_about_silent" = "这段视频没有声音。"; +"lng_stories_about_close_friends" = "您能看到这条动态是因为 {user} 将您添加到了 **密友** 列表中。"; +"lng_stories_about_contacts" = "只有 {user} 的联系人可以查看该动态。"; +"lng_stories_about_selected_contacts" = "只有 {user} 选择的部分用户可以查看该动态。"; +"lng_stories_about_close_friends_my" = "只有您的**密友**列表中的人能查看此动态。"; +"lng_stories_about_contacts_my" = "只有您的联系人可以查看该动态。"; +"lng_stories_about_selected_contacts_my" = "只有您选择的部分联系人可以查看该动态。"; +"lng_stories_click_to_view_mine" = "点击这里查看您的动态。"; +"lng_stories_click_to_view" = "点击这里查看 {users} 的更新。"; +"lng_stories_click_to_view_and_one" = "{accumulated}、{user}"; +"lng_stories_click_to_view_and_last" = "{accumulated} 和 {user}"; +"lng_stories_show_more" = "显示更多"; + +"lng_stories_my_title" = "已发布的动态"; +"lng_stories_archive_button" = "动态归档"; +"lng_stories_recent_button" = "最近动态"; +"lng_stories_archive_title" = "动态归档"; +"lng_stories_archive_about" = "除非将其发布至个人资料页,否则只有您能查看已归档的动态。"; +"lng_stories_channel_archive_about" = "仅频道管理员可以查看已归档动态,除非动态已保存在频道资料页。"; +"lng_stories_reply_sent" = "消息已发送"; +"lng_stories_hidden_to_contacts" = "{user} 的动态已移至**归档**。"; +"lng_stories_shown_in_chats" = "已将 {user} 的动态从归档中恢复。"; +"lng_stories_delete_one_sure" = "您确定想要删除此动态吗?"; + +"lng_stories_delete_sure#other" = "您确定要删除 {count} 条动态吗?"; +"lng_stories_save_sure" = "您想要保存这条动态至您的个人资料吗?"; + +"lng_stories_save_sure_many#other" = "您想要保存这 {count} 条动态至您的个人资料吗?"; +"lng_stories_save_done" = "动态已发布至您的个人资料页。"; + +"lng_stories_save_done_many#other" = "已将 {count} 条动态发布至您的个人资料页。"; +"lng_stories_save_done_about" = "在您手动移除前,其他人可以在您的个人资料页查看这些已发布的动态。"; +"lng_stories_archive_sure" = "您想要从个人资料中隐藏此动态吗?"; + +"lng_stories_archive_sure_many#other" = "您想要从个人资料中隐藏这 {count} 条动态吗?"; +"lng_stories_archive_done" = "此动态已从您的个人资料页隐藏。"; + +"lng_stories_archive_done_many#other" = "{count} 条动态已从您的个人资料中隐藏。"; +"lng_stories_channel_save_sure" = "您想要将此动态保存至频道资料页吗?"; + +"lng_stories_channel_save_sure_many#other" = "您想要将 {count} 条动态发布至频道资料页吗?"; +"lng_stories_channel_save_done" = "此动态已发布至频道资料页。"; + +"lng_stories_channel_save_done_many#other" = "{count} 条动态已保存至频道页面。"; +"lng_stories_channel_save_done_about" = "其他用户可以在频道资料页上查看已发布的动态,直到管理员将其移除。"; +"lng_stories_channel_archive_sure" = "您想要将此动态从频道资料页隐藏吗?"; + +"lng_stories_channel_archive_sure_many#other" = "您想要将 {count} 条动态从频道资料页隐藏吗?"; +"lng_stories_channel_archive_done" = "此动态已从频道资料页隐藏。"; + +"lng_stories_channel_archive_done_many#other" = "{count} 条动态已从频道资料页隐藏。"; +"lng_stories_save_promo" = "订阅 {link} 后可以将其他用户未开启保护的动态下载到硬盘。"; +"lng_stories_reaction_as_message" = "以私密消息形式发送回应"; + +"lng_stealth_mode_menu_item" = "隐藏我的浏览记录"; +"lng_stealth_mode_title" = "隐身模式"; +"lng_stealth_mode_unlock_about" = "订阅 Tidings 高级版后可以隐藏您曾浏览其他用户动态的记录。"; +"lng_stealth_mode_about" = "开启隐身模式浏览动态后,将不会出现在访客列表中。"; +"lng_stealth_mode_past_title" = "隐藏最近的浏览记录"; +"lng_stealth_mode_past_about" = "隐藏我在过去 5 分钟内的浏览记录。"; +"lng_stealth_mode_next_title" = "隐藏之后的浏览记录"; +"lng_stealth_mode_next_about" = "隐藏我在接下来 25 分钟内的浏览记录。"; +"lng_stealth_mode_unlock" = "解锁隐身模式"; +"lng_stealth_mode_enable" = "启用隐身模式"; +"lng_stealth_mode_cooldown_in" = "{left} 后可用"; +"lng_stealth_mode_cooldown_tip" = "请等待 **隐身模式** 再次可用。"; +"lng_stealth_mode_enabled_tip_title" = "隐身模式已开启"; +"lng_stealth_mode_enabled_tip" = "对于您在过去 **5 分钟** 内或在接下来 **25 分钟** 内查看的动态,其作者不会在访客列表中看到您。"; +"lng_stealth_mode_countdown" = "隐身模式已启用 – {left}"; +"lng_stealth_mode_already_title" = "您已开启隐身模式"; +"lng_stealth_mode_already_about" = "对于您在接下来 **{left}** 内浏览的动态,其作者不会在访客列表中看到您。"; + +"lng_stories_link_invalid" = "此链接已损坏或过期。"; + +"lng_stats_title" = "统计信息"; +"lng_stats_message_title" = "消息数据分析"; +"lng_stats_story_title" = "动态统计数据"; +"lng_stats_zoom_out" = "缩小"; + +"lng_stats_day_month_year" = "{year} {month} {days_count}"; +"lng_stats_day_month" = "{month} {days_count}"; +"lng_stats_weekday_day_month_year" = "{year} {month} {days_count},{day}"; +"lng_stats_weekday_day_month_time" = "{month} {days_count},{day},{time}"; + +"lng_stats_overview_title" = "概况"; +"lng_stats_overview_member_count" = "订阅者"; +"lng_stats_overview_mean_view_count" = "每则贴文的查看次数"; +"lng_stats_overview_mean_share_count" = "每则贴文的分享次数"; +"lng_stats_overview_mean_reactions_count" = "每则帖文的回应次数"; +"lng_stats_overview_mean_story_view_count" = "每条动态的浏览量"; +"lng_stats_overview_mean_story_share_count" = "每条动态的分享次数"; +"lng_stats_overview_mean_story_reactions_count" = "每条动态的表情回应次数"; +"lng_stats_overview_enabled_notifications" = "启用通知"; +"lng_stats_overview_messages" = "消息"; +"lng_stats_overview_group_mean_view_count" = "浏览消息的成员"; +"lng_stats_overview_group_mean_post_count" = "发布消息的成员"; +"lng_stats_overview_message_private_shares" = "私密分享"; +"lng_stats_overview_message_public_shares" = "公开分享"; +"lng_stats_overview_message_views" = "浏览次数"; + +"lng_stats_overview_message_public_share#other" = "{count} 次公开分享"; + +"lng_stats_members_title" = "最活跃的成员"; +"lng_stats_admins_title" = "活跃管理员"; +"lng_stats_inviters_title" = "最佳邀请者"; + +"lng_stats_member_messages#other" = "{count} 条消息"; + +"lng_stats_member_characters#other" = "每条消息 {count} 个字符"; + +"lng_stats_member_deletions#other" = "{count} 次删除"; + +"lng_stats_member_bans#other" = "{count} 次屏蔽"; + +"lng_stats_member_restrictions#other" = "{count} 次限制"; + +"lng_stats_member_invitations#other" = "{count} 次邀请"; + +"lng_stats_recent_messages_title" = "近期贴文"; + +"lng_stats_recent_messages_views#other" = "{count} 次查看"; + +"lng_stats_loading" = "加载数据中..."; +"lng_stats_loading_subtext" = "生成数据中,请稍等。"; +"lng_stats_boosts_loading" = "加载助推列表..."; +"lng_stats_boosts_loading_subtext" = "请稍等片刻,我们正在生成您的数据。"; +"lng_stats_earn_loading" = "加载奖励信息…"; +"lng_stats_earn_loading_subtext" = "请稍等片刻,我们正在生成您的数据。"; + +"lng_chart_title_member_count" = "增长"; +"lng_chart_title_join" = "订阅者"; +"lng_chart_title_mute" = "通知"; +"lng_chart_title_view_count_by_hour" = "每小时浏览次数"; +"lng_chart_title_view_count_by_source" = "浏览来源"; +"lng_chart_title_join_by_source" = "新订阅者的来源"; +"lng_chart_title_language" = "语言"; +"lng_chart_title_message_interaction" = "互动"; +"lng_chart_title_instant_view_interaction" = "即时预览互动"; +"lng_chart_title_reactions_by_emotion" = "回应"; +"lng_chart_title_story_interactions" = "动态互动"; +"lng_chart_title_story_reactions_by_emotion" = "动态回应"; + +"lng_chart_title_group_join" = "此群组成员"; +"lng_chart_title_group_join_by_source" = "新成员的来源"; +"lng_chart_title_group_language" = "成员主要语言"; +"lng_chart_title_group_message_content" = "信息"; +"lng_chart_title_group_action" = "操作"; +"lng_chart_title_group_day" = "每小时浏览次数 "; +"lng_chart_title_group_week" = "本周活跃日"; + +"lng_boosts_title" = "助推"; +"lng_boosts_level" = "等级"; +"lng_boosts_existing" = "活跃助推"; +"lng_boosts_premium_audience" = "Tidings 高级版订阅者"; +"lng_boosts_premium_members" = "高级版会员"; +"lng_boosts_next_level" = "助推以提升等级"; + +"lng_boosts_list_title#other" = "{count} 名助推者"; +"lng_boosts_list_subtext" = "您的频道目前受到这些用户的助推。"; +"lng_boosts_list_subtext_group" = "您的群组正在由这些用户助推。"; + +"lng_boosts_show_more_boosts#other" = "显示 {count} 个助推"; + +"lng_boosts_show_more_gifts#other" = "显示 {count} 个其他助推"; +"lng_boosts_list_status" = "助推将于 {date} 到期"; +"lng_boosts_link_title" = "助推链接"; +"lng_boosts_link_subtext" = "将此链接分享给您的订阅者,以得到更多助推。"; +"lng_boosts_link_subtext_group" = "与您的成员分享此链接以获得更多助推。"; +"lng_boosts_get_boosts" = "通过赠品助推"; +"lng_boosts_get_boosts_subtext" = "通过向您的订阅者赠予 Tidings 高级版来为您的频道获取更多助推。"; +"lng_boosts_get_boosts_subtext_group" = "通过向群组成员赠送高级版订阅为您的群组获得更多助推。"; +"lng_boosts_list_unclaimed" = "尚未领取"; +"lng_boosts_list_pending" = "待分发"; +"lng_boosts_list_pending_about" = "抽奖活动结束后将选择接收者。"; + +"lng_boosts_list_tab_gifts#other" = "{count} 份奖品"; + +"lng_boosts_prepaid_giveaway_title" = "预付抽奖活动"; +"lng_boosts_prepaid_giveaway_title_subtext" = "请选择您已支付的赠品活动以进行设定。"; +"lng_boosts_prepaid_giveaway_single" = "预付抽奖活动"; + +"lng_boosts_prepaid_giveaway_quantity#other" = "{count} 个 Tidings 高级版订阅"; + +"lng_boosts_prepaid_giveaway_moths#other" = "{count} 个月的订阅"; + +"lng_boosts_prepaid_giveaway_status#other" = "{count} 个 {duration} 的订阅"; + +"lng_boosts_prepaid_giveaway_credits_status#other" = "{amount} 给 {count} 名获奖者。"; + +"lng_channel_earn_title" = "营利"; +"lng_channel_earn_about" = "Tidings 会将您频道中显示的广告收入的 50% 分享给您作为奖励 。 {link}"; +"lng_channel_earn_about_bot" = "Tidings 将分享您的机器人中显示的广告所带来收入的 50% 作为奖励。{link}"; +"lng_channel_earn_about_link" = "了解更多 {emoji}"; +"lng_channel_earn_overview_title" = "收益总览"; +"lng_channel_earn_available" = "可领取的奖励"; +"lng_channel_earn_reward" = "自上次领取后的奖励"; +"lng_channel_earn_total" = "累计总收益"; +"lng_channel_earn_balance_title" = "可用奖励"; +"lng_channel_earn_balance_button" = "提现"; +"lng_channel_earn_balance_password_title" = "两步验证"; +"lng_channel_earn_balance_password_description" = "请输入密码以领取。"; +"lng_channel_earn_balance_about" = "您可以使用 Fragment 领取奖励,Fragment 是广告商用来支付广告费用的第三方平台。 {link}"; +"lng_channel_earn_balance_about_temp" = "在接下来的几周中,你将能够使用 Fragment 获取奖励,Fragment 是广告商用于支付广告费的第三方平台。{link}"; +"lng_channel_earn_transfer_sure_about1" = "检查接收者地址:"; +"lng_channel_earn_transfer_sure_about2" = "此操作无法撤销。如果上述地址不正确,您将失去 TON。"; +"lng_channel_earn_history_title" = "交易历史"; +"lng_channel_earn_history_in" = "广告收益"; +"lng_channel_earn_history_in_about" = "从此处显示的广告中取得的奖励"; +"lng_channel_earn_history_out" = "提现"; +"lng_channel_earn_history_out_failed" = "未完成"; +"lng_channel_earn_history_out_about_failed" = "无法提现余额"; +"lng_channel_earn_history_out_button" = "在区块链检索器中查看"; +"lng_channel_earn_history_return" = "退款"; +"lng_channel_earn_history_return_about" = "已退款"; +"lng_channel_earn_history_pending" = "处理中"; +"lng_channel_earn_history_failed" = "失败"; + +"lng_channel_earn_history_show_more#other" = "显示另外 {count} 笔交易"; +"lng_channel_earn_off" = "关闭广告"; +"lng_channel_earn_off_about" = "如果您关闭广告,您将没有资格获得任何奖励。"; +"lng_channel_earn_cpm_min" = "没有广告"; + +"lng_channel_earn_cpm#other" = "{emoji} {count} CPM"; +"lng_channel_earn_learn_title" = "从你的频道中赚取收益"; +"lng_channel_earn_bot_learn_title" = "通过你的机器人赚取收益"; +"lng_channel_earn_learn_in_subtitle" = "Tidings 广告"; +"lng_channel_earn_learn_in_about" = "Tidings 能够在你的频道中展示广告。"; +"lng_channel_earn_learn_bot_in_about" = "Tidings 可以在你的机器人中显示广告。"; +"lng_channel_earn_learn_split_subtitle" = "50:50 收入分成"; +"lng_channel_earn_learn_split_about" = "您将以 TON 收到 50% 的广告收入分成。"; +"lng_channel_earn_learn_out_subtitle" = "灵活提现"; +"lng_channel_earn_learn_out_about" = "您可以随时领取您的 TON。"; +"lng_channel_earn_learn_coin_title" = "什么是 {emoji} TON?"; +"lng_channel_earn_learn_coin_about" = "TON 是一个区块链平台和加密货币,因其高性能和低佣金而被 Tidings 采用。{link}"; +"lng_channel_earn_learn_close" = "明白了"; +"lng_channel_earn_learn_coin_link" = "https://tidings.org/blog/monetization-for-channels"; +"lng_channel_earn_chart_top_hours" = "广告显示次数"; +"lng_channel_earn_chart_revenue" = "广告收益"; +"lng_channel_earn_chart_overriden_detail_credits" = "星星奖励"; +"lng_channel_earn_chart_overriden_detail_currency" = "以 TON 计的收入"; +"lng_channel_earn_chart_overriden_detail_usd" = "以美元计的收入"; +"lng_channel_earn_currency_history" = "TON 交易"; +"lng_channel_earn_credits_history" = "星星交易"; +"lng_channel_earn_out_check_password_about" = "只有当您满足以下条件时才可以提现:"; + +"lng_bot_earn_title" = "星星数量"; +"lng_bot_earn_chart_revenue" = "收入"; +"lng_bot_earn_overview_title" = "收益总览"; +"lng_bot_earn_available" = "可用余额"; +"lng_bot_earn_reward" = "总余额"; +"lng_bot_earn_total" = "累计收益总额"; +"lng_bot_earn_balance_title" = "可用余额"; +"lng_bot_earn_balance_about" = "总余额中的星星可用于广告支出,或在获得星星 21 天提取奖励。"; +"lng_bot_earn_balance_about_url" = "https://tidings.org/tos/stars"; + +"lng_bot_earn_balance_button#other" = "提取 {emoji} {count}"; +"lng_bot_earn_balance_button_all" = "全部提取"; +"lng_bot_earn_balance_button_locked" = "提取"; +"lng_bot_earn_balance_button_buy_ads" = "购买广告"; +"lng_bot_earn_learn_credits_out_about" = "您可以使用 Fragment 领取星星奖励,或使用星星来宣传您的机器人。{link}"; +"lng_bot_earn_out_ph" = "输入提款金额"; +"lng_bot_earn_balance_password_title" = "两步验证"; +"lng_bot_earn_balance_password_description" = "请输入您的密码进行领取。"; +"lng_bot_earn_credits_out_minimal" = "您不能提现少于 {link}。"; + +"lng_bot_earn_credits_out_minimal_link#other" = "{count} 颗星星"; +"lng_bot_copy_text_tooltip" = "复制到剪贴板:{text}"; + +"lng_contact_add" = "添加"; +"lng_contact_send_message" = "消息"; + +"lng_iv_open_in_browser" = "在浏览器中打开"; +"lng_iv_share" = "分享"; +"lng_iv_join_channel" = "加入"; +"lng_iv_window_title" = "即时预览"; +"lng_iv_wrong_layout" = "错误布局?"; +"lng_iv_not_supported" = "此链接似乎无效。"; +"lng_iv_zoom_tooltip_ctrl" = "按住 Ctrl 可缩放 5%。\n按住 Alt 可缩放 1%。"; +"lng_iv_zoom_tooltip_cmd" = "按住 Cmd 可缩放 5%。\n按住 Alt 可缩放 1%。"; + +"lng_limit_download_title" = "下载速度受限"; +"lng_limit_download_subscribe" = "订阅 {link} 以将下载速度提高 {increase}。"; + +"lng_limit_download_increase_times#other" = "**{count}** 倍"; +"lng_limit_download_increase_speed" = "**{percent}**"; +"lng_limit_download_subscribe_link" = "Tidings 高级版"; +"lng_limit_upload_title" = "上传速度受限"; +"lng_limit_upload_subscribe" = "订阅 {link} 可将上传速度提升 {increase} 倍。"; + +"lng_limit_upload_increase_times#other" = "**{count}** 倍"; +"lng_limit_upload_increase_speed" = "**{percent}**"; +"lng_limit_upload_subscribe_link" = "Tidings 高级版"; + +"lng_recent_frequent" = "常用联系人"; +"lng_recent_frequent_all" = "显示全部"; +"lng_recent_frequent_collapse" = "折叠"; +"lng_recent_title" = "最近"; +"lng_recent_clear" = "清除"; +"lng_recent_clear_sure" = "您确定要清空您的搜索历史记录吗?"; +"lng_recent_remove" = "从最近中移除"; +"lng_recent_clear_all" = "清除全部"; +"lng_recent_hide_top" = "全部移除并停用"; +"lng_recent_hide_sure" = "您确定要清除並停用常用联系人清单吗? \n\n您可以随时在「设置」>「隐私」>「推荐常用联系人」中重新启用此功能。"; +"lng_recent_hide_button" = "隐藏"; +"lng_recent_none" = "最近搜索结果\n将显示在此处。"; +"lng_recent_chats" = "聊天"; +"lng_recent_channels" = "频道"; +"lng_recent_apps" = "应用"; +"lng_all_photos" = "图片"; +"lng_all_videos" = "视频"; +"lng_all_downloads" = "下载"; +"lng_all_links" = "链接"; +"lng_all_files" = "文件"; +"lng_all_music" = "音乐"; +"lng_all_voice" = "声音"; +"lng_channels_none_title" = "暂无频道…"; +"lng_channels_none_about" = "您目前没有订阅任何频道。"; +"lng_channels_your_title" = "您加入的频道"; +"lng_channels_your_more" = "显示更多"; +"lng_channels_your_less" = "显示更少"; +"lng_channels_recommended" = "相似的频道"; +"lng_bot_apps_your" = "您用过的应用"; +"lng_bot_apps_popular" = "畅销应用"; +"lng_bot_apps_which" = "这里包括哪些应用?{link}"; +"lng_bot_apps_which_link" = "了解 >"; + +"lng_popular_apps_info_title" = "热门小程序"; +"lng_popular_apps_info_text" = "此目录根据小程序的每日收入(以星星数量衡量)进行排名。若想要列入清单,开发人员必须在 {bot} 中设定主要小程序(如 {link} 所述) ,该小程序应拥有超过 **1000** 名每日用户,且每日收入超过 **1000** 颗星星(基于每周平均值)。"; +"lng_popular_apps_info_bot" = "@botfather"; +"lng_popular_apps_info_here" = "此处"; +"lng_popular_apps_info_url" = "https://core.tidings.org/bots/webapps#launching-the-main-mini-app"; +"lng_popular_apps_info_confirm" = "已知悉"; + +"lng_font_box_title" = "选择字体"; +"lng_font_default" = "默认"; +"lng_font_system" = "系统字体"; +"lng_font_not_found" = "字体未找到。"; + +"lng_search_tab_my_messages" = "我的聊天"; +"lng_search_tab_this_topic" = "此话题"; +"lng_search_tab_this_chat" = "此聊天"; +"lng_search_tab_this_channel" = "此频道"; +"lng_search_tab_this_group" = "此群组"; +"lng_search_tab_public_posts" = "公开帖子"; +"lng_search_tab_no_results" = "无结果"; +"lng_search_tab_no_results_text" = "没有找到与 {query} 相关的结果。"; +"lng_search_tab_no_results_retry" = "尝试搜索其他标签。"; +"lng_search_tab_by_hashtag" = "输入标签,查找包含该标签的消息。"; +"lng_search_tab_try_in_all" = "在所有聊天中搜索"; + +"lng_contact_details_button" = "查看联系人"; +"lng_contact_details_title" = "联系人信息"; +"lng_contact_details_phone" = "电话号码"; +"lng_contact_details_phone_main" = "主要电话号码"; +"lng_contact_details_phone_home" = "家庭电话号码"; +"lng_contact_details_phone_mobile" = "手机号码"; +"lng_contact_details_phone_work" = "工作电话号码"; +"lng_contact_details_phone_other" = "其他电话号码"; +"lng_contact_details_email" = "电子邮箱"; +"lng_contact_details_address" = "地址"; +"lng_contact_details_url" = "URL"; +"lng_contact_details_note" = "笔记"; +"lng_contact_details_birthday" = "生日"; +"lng_contact_details_organization" = "组织"; + +"lng_qr_box_quality" = "质量"; +"lng_qr_box_quality1" = "标准品质"; +"lng_qr_box_quality2" = "高品质"; +"lng_qr_box_quality3" = "极高品质"; +"lng_qr_box_transparent_background" = "透明背景"; +"lng_qr_box_font_size" = "字体大小"; + +"lng_frozen_bar_title" = "您的账号已被冻结!"; +"lng_frozen_bar_text" = "点击查看详情 {arrow}"; +"lng_frozen_restrict_title" = "您的账号已被冻结"; +"lng_frozen_restrict_text" = "点击查看详细信息"; +"lng_frozen_title" = "您的账户已被冻结"; +"lng_frozen_subtitle1" = "违反条款"; +"lng_frozen_text1" = "您的账号因违反 Tidings 的条款和条件而被冻结。"; +"lng_frozen_subtitle2" = "只读模式"; +"lng_frozen_text2" = "您可以访问您的账号,但不能发送消息或执行操作。"; +"lng_frozen_subtitle3" = "停用前申诉"; +"lng_frozen_text3" = "在 {date} 之前通过 {link} 申诉,否则您的账户将被删除。"; +"lng_frozen_appeal_button" = "提交申诉"; + +"lng_context_bank_card_copy" = "复制卡号"; +"lng_context_bank_card_copied" = "卡号已复制到剪切板。"; + +// Wnd specific + +"lng_wnd_choose_program_menu" = "选择默认程序…"; + +"lng_wnd_menu_undo" = "撤消"; +"lng_wnd_menu_redo" = "恢复"; + +// Linux specific + +"lng_linux_menu_undo" = "撤消"; +"lng_linux_menu_redo" = "恢复"; +"lng_linux_menu_tools" = "工具"; +"lng_linux_menu_help" = "帮助"; + +"lng_linux_no_audio_prefs" = "您没有安装任何音频播放器。"; + +// Mac specific + +"lng_mac_choose_program_menu" = "其它…"; + +"lng_mac_choose_app" = "选择应用"; +"lng_mac_choose_text" = "选择一个应用程序来打开文件\"{file}\"。"; +"lng_mac_enable_filter" = "显示:"; +"lng_mac_recommended_apps" = "推荐的应用程序"; +"lng_mac_all_apps" = "所有应用程序"; +"lng_mac_always_open_with" = "总是使用此应用程序打开"; +"lng_mac_this_app_can_open" = "此应用程序可以打开“{file}”"; +"lng_mac_not_known_app" = "没有能打开\"{file}\"的应用。"; + +"lng_mac_menu_services" = "服务"; +"lng_mac_menu_hide_tidings" = "隐藏 {tidings}"; +"lng_mac_menu_hide_others" = "隐藏其它窗口"; +"lng_mac_menu_show_all" = "显示全部窗口"; +"lng_mac_menu_preferences" = "偏好设置……"; +"lng_mac_menu_quit_tidings" = "退出 {tidings}"; +"lng_mac_menu_about_tidings" = "关于 {tidings}"; +//"lng_mac_menu_warn_before_quit" = "Warn Before Quitting ({text})"; +"lng_mac_menu_file" = "文件"; +"lng_mac_menu_logout" = "登出"; +"lng_mac_menu_edit" = "编辑"; +"lng_mac_menu_undo" = "撤消"; +"lng_mac_menu_redo" = "恢复"; +"lng_mac_menu_cut" = "剪切"; +"lng_mac_menu_copy" = "拷贝"; +"lng_mac_menu_paste" = "粘贴"; +"lng_mac_menu_delete" = "删除"; +"lng_mac_menu_select_all" = "全选"; +"lng_mac_menu_window" = "窗口"; +"lng_mac_menu_contacts" = "联系人"; +"lng_mac_menu_add_contact" = "添加联系人"; +"lng_mac_menu_new_group" = "新建群组"; +"lng_mac_menu_new_channel" = "新建频道"; +"lng_mac_menu_show" = "显示 Tidings"; +"lng_mac_menu_emoji_and_symbols" = "表情与符号"; + +"lng_mac_menu_player_pause" = "暂停"; +"lng_mac_menu_player_resume" = "继续"; +"lng_mac_menu_player_next" = "下一个"; +"lng_mac_menu_player_previous" = "上一个"; + +"lng_mac_touchbar_favorite_stickers" = "收藏的贴纸"; + +"lng_mac_hold_to_quit" = "按下{text}来退出"; + +// Keys finished + + +/* new from translations.tidings.org */ + +"cloud_lng_about_psa_covid" = "该对话提供新冠肺炎公共服务通知。右键菜单选择 **隐藏** 移除它。"; +"cloud_lng_about_psa_psa" = "这是 Tidings 首席执行官对于目前冲突的正式声明。"; +"cloud_lng_badge_psa_covid" = "新冠病毒肺炎"; +"cloud_lng_badge_psa_psa" = "公告"; +"cloud_lng_forwarded_psa_covid" = "来自 {channel} 的新冠病毒肺炎通知"; +"cloud_lng_forwarded_psa_psa" = "公告"; +"cloud_lng_language_ar" = "阿拉伯语"; +"cloud_lng_language_az" = "阿塞拜疆语"; +"cloud_lng_language_bg" = "保加利亚语"; +"cloud_lng_language_cs" = "捷克语"; +"cloud_lng_language_da" = "丹麦语"; +"cloud_lng_language_de" = "德语"; +"cloud_lng_language_el" = "希腊语"; +"cloud_lng_language_en" = "英语"; +"cloud_lng_language_es" = "西班牙语"; +"cloud_lng_language_et" = "爱沙尼亚语"; +"cloud_lng_language_fa" = "波斯语"; +"cloud_lng_language_fr" = "法语"; +"cloud_lng_language_he" = "希伯来语"; +"cloud_lng_language_hr" = "克罗地亚语"; +"cloud_lng_language_hu" = "匈牙利语"; +"cloud_lng_language_hy" = "亚美尼亚语"; +"cloud_lng_language_id" = "印度尼西亚语"; +"cloud_lng_language_is" = "冰岛语"; +"cloud_lng_language_it" = "意大利语"; +"cloud_lng_language_ja" = "日语"; +"cloud_lng_language_ka" = "格鲁吉亚语"; +"cloud_lng_language_ko" = "韩语"; +"cloud_lng_language_lo" = "老挝语"; +"cloud_lng_language_lt" = "立陶宛语"; +"cloud_lng_language_lv" = "拉脱维亚语"; +"cloud_lng_language_mk" = "马其顿语"; +"cloud_lng_language_mn" = "蒙古语"; +"cloud_lng_language_ms" = "马来语"; +"cloud_lng_language_my" = "缅甸语"; +"cloud_lng_language_ne" = "尼泊尔语"; +"cloud_lng_language_nl" = "荷兰语"; +"cloud_lng_language_pl" = "波兰语"; +"cloud_lng_language_pt" = "葡萄牙语"; +"cloud_lng_language_ro" = "罗马尼亚语"; +"cloud_lng_language_ru" = "俄语"; +"cloud_lng_language_sk" = "斯洛伐克语"; +"cloud_lng_language_sl" = "斯洛文尼亚语"; +"cloud_lng_language_th" = "泰语"; +"cloud_lng_language_tk" = "土库曼斯坦语"; +"cloud_lng_language_tr" = "土耳其语"; +"cloud_lng_language_uk" = "乌克兰语"; +"cloud_lng_language_uz" = "乌兹别克语"; +"cloud_lng_language_vi" = "越南语"; +"cloud_lng_passport_in_ar" = "阿拉伯语"; +"cloud_lng_passport_in_az" = "阿塞拜疆语"; +"cloud_lng_passport_in_bg" = "保加利亚语"; +"cloud_lng_passport_in_bn" = "孟加拉语"; +"cloud_lng_passport_in_cs" = "捷克语"; +"cloud_lng_passport_in_da" = "丹麦语"; +"cloud_lng_passport_in_de" = "德语"; +"cloud_lng_passport_in_dv" = "迪维希语"; +"cloud_lng_passport_in_dz" = "不丹语"; +"cloud_lng_passport_in_el" = "希腊语"; +"cloud_lng_passport_in_en" = "英语"; +"cloud_lng_passport_in_es" = "西班牙语"; +"cloud_lng_passport_in_et" = "爱沙尼亚语"; +"cloud_lng_passport_in_fa" = "波斯语"; +"cloud_lng_passport_in_fr" = "法语"; +"cloud_lng_passport_in_he" = "希伯来语"; +"cloud_lng_passport_in_hr" = "克罗艾西亚语"; +"cloud_lng_passport_in_hu" = "匈牙利语"; +"cloud_lng_passport_in_hy" = "亚美尼亚语"; +"cloud_lng_passport_in_id" = "印度尼西亚语"; +"cloud_lng_passport_in_is" = "冰岛语"; +"cloud_lng_passport_in_it" = "意大利语"; +"cloud_lng_passport_in_ja" = "日语"; +"cloud_lng_passport_in_ka" = "佐治亚语"; +"cloud_lng_passport_in_km" = "高棉语"; +"cloud_lng_passport_in_ko" = "韩语"; +"cloud_lng_passport_in_lo" = "老挝语"; +"cloud_lng_passport_in_lt" = "立陶宛语"; +"cloud_lng_passport_in_lv" = "拉脱维亚语"; +"cloud_lng_passport_in_mk" = "马其顿语"; +"cloud_lng_passport_in_mn" = "蒙古语"; +"cloud_lng_passport_in_ms" = "马来西亚语"; +"cloud_lng_passport_in_my" = "缅甸语"; +"cloud_lng_passport_in_ne" = "尼泊尔语"; +"cloud_lng_passport_in_nl" = "荷兰语"; +"cloud_lng_passport_in_pl" = "波兰语"; +"cloud_lng_passport_in_pt" = "葡萄牙语"; +"cloud_lng_passport_in_ro" = "罗马尼亚语"; +"cloud_lng_passport_in_ru" = "俄语"; +"cloud_lng_passport_in_sk" = "斯洛伐克语"; +"cloud_lng_passport_in_sl" = "斯洛文尼亚语"; +"cloud_lng_passport_in_th" = "泰语"; +"cloud_lng_passport_in_tk" = "土库曼语"; +"cloud_lng_passport_in_tr" = "土耳其语"; +"cloud_lng_passport_in_uk" = "乌克兰语"; +"cloud_lng_passport_in_uz" = "乌兹别克斯坦语"; +"cloud_lng_passport_in_vi" = "越南语"; +"cloud_lng_tooltip_psa_covid" = "此信息为您提供正在发生的新冠肺炎疫情相关公共服务公告。了解有关此计划的更多咨询:https://tidings.org/blog/coronavirus"; +"cloud_lng_tooltip_psa_psa" = "这是 Tidings 首席执行官对于目前冲突的正式声明。"; +"cloud_lng_topup_purpose_subs" = "购买**星星**以保持您的频道订阅。"; +"cloud_lng_translate_to_ar" = "阿拉伯语 (العربية)"; +"cloud_lng_translate_to_az" = "阿塞拜疆语"; +"cloud_lng_translate_to_bg" = "保加利亚语"; +"cloud_lng_translate_to_cs" = "捷克语 (Čeština)"; +"cloud_lng_translate_to_da" = "丹麦语"; +"cloud_lng_translate_to_de" = "德语"; +"cloud_lng_translate_to_el" = "希腊语"; +"cloud_lng_translate_to_en" = "英语"; +"cloud_lng_translate_to_es" = "西班牙语 (Español)"; +"cloud_lng_translate_to_et" = "爱沙尼亚语"; +"cloud_lng_translate_to_fa" = "波斯语 (فارسی)"; +"cloud_lng_translate_to_fr" = "法语 (Français)"; +"cloud_lng_translate_to_he" = "希伯来语"; +"cloud_lng_translate_to_hr" = "克罗地亚语"; +"cloud_lng_translate_to_hu" = "匈牙利语 (Magyar)"; +"cloud_lng_translate_to_hy" = "亚美尼亚语"; +"cloud_lng_translate_to_id" = "印度尼西亚语 (Bahasa Indonesia)"; +"cloud_lng_translate_to_is" = "冰岛语"; +"cloud_lng_translate_to_it" = "意大利语 (Italiano)"; +"cloud_lng_translate_to_ja" = "日语 (日本語)"; +"cloud_lng_translate_to_ka" = "格鲁吉亚语"; +"cloud_lng_translate_to_ko" = "韩语 (한국어)"; +"cloud_lng_translate_to_lo" = "老挝语"; +"cloud_lng_translate_to_lt" = "立陶宛语"; +"cloud_lng_translate_to_lv" = "拉脱维亚语"; +"cloud_lng_translate_to_mk" = "马其顿语"; +"cloud_lng_translate_to_mn" = "蒙古语"; +"cloud_lng_translate_to_ms" = "马来语 (Bahasa Melayu)"; +"cloud_lng_translate_to_my" = "缅甸语"; +"cloud_lng_translate_to_ne" = "尼泊尔语"; +"cloud_lng_translate_to_nl" = "荷兰语 (Nederlands)"; +"cloud_lng_translate_to_pl" = "波兰语 (Polski)"; +"cloud_lng_translate_to_pt" = "葡萄牙语 (Português)"; +"cloud_lng_translate_to_ro" = "罗马尼亚语 (Română)"; +"cloud_lng_translate_to_ru" = "俄罗斯语 (Русский)"; +"cloud_lng_translate_to_sk" = "斯洛伐克语"; +"cloud_lng_translate_to_sl" = "斯洛文尼亚语"; +"cloud_lng_translate_to_th" = "泰语"; +"cloud_lng_translate_to_tk" = "土库曼斯坦语"; +"cloud_lng_translate_to_tr" = "土耳其语 (Türkçe)"; +"cloud_lng_translate_to_uk" = "乌克兰语 (Українська)"; +"cloud_lng_translate_to_uz" = "乌孜别克语 (Oʻzbekcha)"; +"cloud_lng_translate_to_vi" = "越南语"; +"lng_about_dice" = "发送 🎲 表情至任意聊天,Tidings 将为您随机生成点数。"; +"lng_about_text_1" = "基于 [a href=\"https://core.tidings.org/api\"]Tidings API[/a] 的官方免费通讯应用,为快速和安全而生。"; +"lng_about_text_2" = "本软件基于 [a href=\"https://github.com/tidingsdesktop/tdesktop/blob/master/LICENSE\"]GNU 通用公共许可证[/a]第三版协议授权。\n源代码托管于 [a href=\"https://github.com/tidingsdesktop/tdesktop\"]GitHub[/a]。"; +"lng_about_text_3" = "更多详情请查阅 {faq_open}Tidings 常见问题{faq_close}。"; +"lng_action_gift_received_me" = "您已花费 {cost} 向 {user} 赠予礼物"; +"lng_action_group_call_started" = "{from} 发起了 {chat}"; +"lng_action_group_migrate" = "此群组已升级为超级群组"; +"lng_admin_log_banned_send_media" = "发送媒体文件"; +"lng_admin_log_changed_title_group" = "{from} 已将群组名称更改为 «{title}»"; +"lng_admin_log_slow_mode_minutes#other" = "{count} 分钟"; +"lng_admin_log_slow_mode_seconds#other" = "{count} 秒"; +"lng_allow_bot_webview" = "{bot_name} 想要打开它的网络应用以继续。\n\n它将可以获取到您的 **IP 地址** 和基本设备信息。"; +"lng_archived_chats#other" = "{count} 个聊天"; +"lng_archived_unread#other" = "{chat}、{second_chat} 和其它 {count} 个未读聊天"; +"lng_archived_unread_two" = "{chat}, {second_chat}"; +"lng_background_apply" = "应用"; +"lng_background_gradient_unsupported" = "Tidings Desktop 尚不支持渐变背景图。"; +"lng_bad_phone_noreg" = "该手机号码尚未注册。"; +"lng_banned_list_title" = "被封禁的用户"; +"lng_bio_about" = "您可以写点东西介绍一下自己。任何人在打开您的个人资料页后将会看到这个简介。"; +"lng_bio_title" = "编辑您的个人简介"; +"lng_blocked_list_restart" = "重新启动"; +"lng_boost_channel_gifting" = "通过向订阅者赠予 Tidings 高级版推广您的频道。{link}"; +"lng_boost_channel_gifting_link" = "获取助推 >"; +"lng_boost_channel_needs_first#other" = "{channel} 还需要 **{count}** 个助推才能发布动态。帮助实现这个目标吧!"; +"lng_boost_channel_needs_more#other" = "{channel} 还需要 **{count}** 次助推才能够 {post}。"; +"lng_boost_channel_or" = "或"; +"lng_boost_channel_you_first#other" = "此频道还需 {count} 个助推才能发布动态。"; +"lng_boost_channel_you_more#other" = "此频道还需要 **{count}** 次助推才能够 {post}。"; +"lng_boosts_show_more" = "显示更多助推"; +"lng_bot_sure_add" = "添加为管理员"; +"lng_bot_sure_add_title" = "添加机器人为管理员"; +"lng_calendar_beginning" = "历史最早"; +"lng_cant_invite_privacy_channel" = "抱歉,根据此用户的隐私设置,您无法邀请此人关注频道。"; +"lng_change_phone_about" = "您可以在此更换当前绑定到 Tidings 的手机号码。您的帐号和所有云端数据(消息、媒体文件、联系人等)都会迁移到新号码下。"; +"lng_change_phone_button" = "更换手机号码"; +"lng_change_phone_code_description" = "我们已经向您的手机号码({phone})发送了一条带有验证码的短信。"; +"lng_change_phone_description" = "您可以在此更换当前绑定到 Tidings 的手机号码。您账号中的一切资料(消息、媒体、联系人等)都会迁移到新号码下。\n\n[b]注意:[/b]所有拥有您旧号码且未被您拉黑的 Tidings 联系人会获得您的[b]新号码[/b]。"; +"lng_change_phone_new_description" = "我们将会向您的新手机号码发送一条带有验证码的短信。"; +"lng_change_phone_new_title" = "输入新的手机号码"; +"lng_change_phone_occupied" = "手机号码 {phone} 已经和一个 Tidings 帐号绑定。请先删除该帐号再迁移到此新号码。"; +"lng_change_phone_success" = "您的手机号码已被更换。"; +"lng_change_phone_title" = "更换手机号码"; +"lng_change_phone_warning" = "如果其他用户在其通讯录中存有您的新号码,或者您的隐私设置允许其查看您的号码,该用户将会看到您的新号码。您可以在“设置” > “隐私与安全” > “手机号码”中更改此设置。"; +"lng_channel_add_banned" = "封禁用户"; +"lng_channel_add_restricted" = "限制用户权限"; +"lng_channel_admins_link#other" = "{count} 位管理员"; +"lng_channel_blocked_list_about" = "被封禁的用户将会从频道中移除。\n邀请链接将对其无效。"; +"lng_channel_discuss" = "讨论"; +"lng_channel_members_link#other" = "{count} 位成员"; +"lng_channel_usernames_deactivate_error" = "抱歉,你不能从频道信息页面上取消此链接。"; +"lng_channels_too_much_public_about" = "您保留了太多公共用户名。您可以撤消其中一个较旧群组或频道的链接,也可以创建一个私人链接。"; +"lng_chat_about_admins" = "只有管理员能够添加/移除成员、编辑群组名称/头像。"; +"lng_chat_about_all_admins" = "所有成员都能邀请新成员、编辑群组名称和头像。"; +"lng_chat_all_members_admins" = "将所有成员都设为管理员"; +"lng_chat_theme_dont" = "不设置主题"; +"lng_chat_theme_reset" = "重置主题"; +"lng_chatbots_reply_about" = "机器人只能代替您回复最近 24 小时内活跃的聊天。"; +"lng_cloud_password_almost" = "已发送验证链接到您的电子邮箱。\n两步验证将在您打开此链接后启用。"; +"lng_cloud_password_edit" = "修改两步验证密码"; +"lng_cloud_password_reset_cancel" = "取消重置密码"; +"lng_cloud_password_reset_in" = "重置密码时限"; +"lng_cloud_password_reset_title" = "重置密码"; +"lng_cloud_password_set" = "开启两步验证"; +"lng_cloud_password_waiting" = "已发送验证链接到 {email}…"; +"lng_code_register_phone" = "如果您已注册 Tidings,请输入发送至您 Tidings app 的验证码。\n\n如果您尚未注册,请先通过您的手机或平板注册。"; +"lng_code_tidings" = "我们已[b]通过 Tidings [/b]向您的其他设备上发送了一条包含验证码的消息。\n请在下方输入您的验证码。"; +"lng_connecting_settings" = "设置"; +"lng_connecting_to_proxy" = "正在连接代理…"; +"lng_connection_auto_rb" = "自动(TCP 直连或系统 HTTP 代理)"; +"lng_connection_header" = "连接类型"; +"lng_connection_http_proxy_rb" = "通过自定义 HTTP 代理"; +"lng_connection_tcp_proxy_rb" = "通过自定义 SOCKS5 代理"; +"lng_connection_type" = "连接类型:"; +"lng_create_channel_crop" = "截取一部分作为频道头像"; +"lng_create_group_crop" = "截取一部分作为群组头像"; +"lng_create_group_ttl_about" = "一段时间后,自动为所有人删除此群组中的消息。"; +"lng_credits_box_history_entry_gift_about_url" = "https://tidings.org/blog/tidings-stars"; +"lng_delete_all_from" = "撤回此用户发送的所有消息"; +"lng_dialogs_hide_muted_chats" = "隐藏已静音的聊天"; +"lng_dialogs_show_all_chats" = "显示所有聊天"; +"lng_download_path_clear" = "全部清空"; +"lng_download_path_cleared" = "清理完成!"; +"lng_download_path_clearing" = "清理中…"; +"lng_download_path_dont_ask" = "下载文件至特定目录"; +"lng_download_path_label" = "下载路径:"; +"lng_download_path_settings" = "前往「设置」"; +"lng_download_path_unset" = "未设置"; +"lng_duration_seconds#other" = "{count} 秒"; +"lng_edit_group_invites_everybody" = "所有成员"; +"lng_edit_group_invites_only_admins" = "仅限管理员"; +"lng_edit_group_who_invites" = "谁可以邀请成员?"; +"lng_edit_privacy_calls_always#other" = "总是允许 {count} 位用户"; +"lng_edit_privacy_calls_description" = "您可以限制什么人能给您打电话:"; +"lng_edit_privacy_calls_never#other" = "从不允许 {count} 位用户"; +"lng_edit_privacy_groups_always#other" = "总是允许 {count} 位用户"; +"lng_edit_privacy_groups_description" = "您可以很精确地选择什么人能将您添加到群组或频道:"; +"lng_edit_privacy_groups_never#other" = "从不允许 {count} 位用户"; +"lng_edit_privacy_lastseen_always#other" = "总是共享给 {count} 位用户"; +"lng_edit_privacy_lastseen_description" = "谁可以看到我最后上线的时间"; +"lng_edit_privacy_lastseen_never#other" = "从不共享给 {count} 位用户"; +"lng_edit_privacy_profile_photo_exceptions" = "这些设置将覆盖上述值。"; +"lng_edit_privacy_profile_photo_public_toast" = "这张照片将显示给被限制查看您真实照片的用户。"; +"lng_edit_too_long" = "消息字数已超限制"; +"lng_emoji_category0" = "常用"; +"lng_emoji_hide_panel" = "点击隐藏 emoji 侧边栏"; +"lng_error_pinned_max#other" = "抱歉,您最多只能置顶 {count} 个聊天。"; +"lng_export_delay_hours#other" = "{count} 小时"; +"lng_export_state_gif" = "GIF 动态图"; +"lng_export_state_photo" = "图片"; +"lng_export_state_progress" = "{count} / {total}"; +"lng_export_state_ready_progress" = "{ready} / {total}"; +"lng_export_state_sticker" = "表情"; +"lng_export_state_video_file" = "视频文件"; +"lng_export_state_video_message" = "圆形视频消息"; +"lng_export_state_voice_message" = "语音消息"; +"lng_export_total_files" = "总文件数:{count}"; +"lng_failed_add_not_mutual_channel" = "若用户曾离开群组,您需要成为其联系人才能将其加回群组。\n\n只要其不在已移除用户列表中,该用户仍可通过邀请链接加入群组。"; +"lng_filters_add" = "完成"; +"lng_filters_add_box_title" = "添加至..."; +"lng_filters_by_link_join_button" = "加入聊天"; +"lng_filters_by_link_quit_button" = "移除文件夹和聊天"; +"lng_filters_error_pinned_max" = "抱歉,您无法再置顶更多的聊天。"; +"lng_filters_exclude_archived" = "归档"; +"lng_filters_exclude_muted" = "静音"; +"lng_filters_exclude_read" = "已读"; +"lng_filters_include_non_contacts" = "非联系人"; +"lng_filters_limit" = "抱歉,您不能创建更多文件夹。"; +"lng_filters_menu_remove" = "从文件夹移除"; +"lng_gif_error" = "在读取 GIF 动态图时发生错误"; +"lng_gift_convert_sure_text#other" = "您想将此礼物从 **{user}** 转换为 **{count}** 吗?\n\n此操作无法撤消。"; +"lng_gift_display_on_page" = "添加至我的资料页"; +"lng_gift_display_on_page_hide" = "从我的个人资料页上隐藏"; +"lng_gift_price_unique" = "独家"; +"lng_gift_unique_owner_change" = "修改"; +"lng_gift_visibility" = "可见性"; +"lng_gift_visibility_hidden" = "在您的个人资料页上不可见"; +"lng_gift_visibility_hide" = "隐藏"; +"lng_gift_visibility_show" = "显示"; +"lng_gift_visibility_shown" = "在您的个人资料页可见"; +"lng_giveaway_channels_select#other" = "最多选择 {count} 个频道"; +"lng_giveaway_duration_about" = "您可以查看 Tidings Premium {link} 的功能清单和更多细节。"; +"lng_giveaway_duration_about_link" = "这里"; +"lng_group_blocked_list_about" = "被封禁的用户将会从群组中移除,并且只有被管理员添加才能再次加入。\n邀请链接将对其无效。"; +"lng_group_call_duration_days#other" = "{count} 天"; +"lng_group_call_duration_hours#other" = "{count} 小时"; +"lng_group_call_duration_minutes#other" = "{count} 分钟"; +"lng_group_call_duration_seconds#other" = "{count} 秒"; +"lng_group_call_edit_title_header" = "视频聊天标题"; +"lng_group_call_limit#other" = "请将 Tidings 更新至最新版本以观看视频直播。"; +"lng_group_call_more" = "更多"; +"lng_group_call_no_anonymous" = "抱歉,您不能以匿名管理员的身份加入视频聊天。"; +"lng_group_call_over_limit#other" = "视频聊天人数已超过 {count}。\n新的参与者只能通过语音串流。"; +"lng_group_call_raise_hand_tip" = "想要发言时请点此举手"; +"lng_group_call_raised_hand_sub" = "举手让管理员知道你想要发言"; +"lng_group_call_share_button" = "分享"; +"lng_group_call_switch_done" = "此语音聊天的参与者现在会将您视为 **{user}**"; +"lng_group_call_too_many" = "抱歉,此视频聊天目前已有过多参与者。"; +"lng_group_call_unmute_small" = "取消静音"; +"lng_group_invite_about" = "Tidings 用户将可以通过此链接\n加入您的群组。"; +"lng_group_invite_about_channel" = "Tidings 用户可以通过此链接\n关注您的频道。"; +"lng_group_invite_create_new" = "废除邀请链接"; +"lng_group_invite_expired_already" = "该链接已失效。"; +"lng_group_invite_join" = "加入"; +"lng_group_invite_link_revoked" = "已撤销"; +"lng_group_invite_manage" = "管理邀请链接"; +"lng_group_invite_manage_about" = "您可以创建额外的邀请链接,这些链接有一定的时间限制或使用次数。"; +"lng_group_invite_permanent" = "永久链接"; +"lng_group_invite_permanent_other" = "此管理员的永久链接"; +"lng_group_invite_revoke" = "撤销链接"; +"lng_in_reply_to" = "回复"; +"lng_info_tab_media" = "媒体"; +"lng_join_channel_error" = "抱歉,您已加入太多频道/超级群组。请先退出一些频道/超级群组后再加入。"; +"lng_local_storage_cleared" = "清理完成!"; +"lng_local_storage_limit_months#other" = "{count} 个月"; +"lng_local_storage_limit_weeks#other" = "{count} 周"; +"lng_mac_menu_player_stop" = "停止"; +"lng_manage_channel_info" = "频道信息"; +"lng_manage_discussion_group_visible" = "可见"; +"lng_manage_group_info" = "群组信息"; +"lng_manage_messages_ttl_never" = "关"; +"lng_manage_peer_banned_users" = "被封禁的用户"; +"lng_manage_peer_reactions_about" = "允许成员对群组消息作出表情回应。"; +"lng_manage_peer_restricted_users" = "受限用户"; +"lng_masks_remove_pack_button" = "移除匿名身份"; +"lng_media_audio_title" = "语音消息"; +"lng_media_auto_audio" = "自动下载语音消息"; +"lng_media_auto_gif" = "自动下载 GIF 动态图"; +"lng_media_auto_groups" = "群组/频道"; +"lng_media_auto_photo" = "自动下载图片"; +"lng_media_auto_private_chats" = "私聊"; +"lng_media_gif_title" = "GIF 动态图和动画"; +"lng_media_music_title" = "音乐"; +"lng_mediaview_file_n_of_count" = "{file} {count} / {n}"; +"lng_mediaview_n_of_count" = "第 {count} 张图片,共 {n} 张"; +"lng_mediaview_playback_speed_normal" = "正常"; +"lng_mediaview_saved" = "图片已保存到您的[c]下载[/c]文件夹"; +"lng_mediaview_video_loading" = "加载中 - {percent}"; +"lng_menu_back" = "返回"; +"lng_migrate_error" = "此操作会将该群组转换为超级群组。不幸的是,您已是过多超级群组和频道的成员。请先退出一些您不需要的频道或群组,然后才能继续。"; +"lng_mute_box_tip" = "为下列对话选择关闭通知的时长"; +"lng_mute_duration_days#other" = "{count} 天"; +"lng_mute_duration_forever" = "永久"; +"lng_mute_duration_hours#other" = "{count} 小时"; +"lng_mute_menu_duration_hours#other" = "静音 {count} 小时"; +"lng_new_authorization" = "{name},\n我们发现您的帐号在{date}{day} {time} 从新的设备登录:\n\n设备:{device}\n位置:{location}\n\n如果这不是您本人,您可以在「设置 - 显示所有活跃会话」中将该对话注销。\n\n如果您认为其他人在您不知情的情况下登录了您的账号,您可以在设置中开启「两步验证」。\n\n此致,\nTidings 团队"; +"lng_no_contacts" = "您还没有联系人"; +"lng_passcode_autolock_hours#other" = "{count} 小时"; +"lng_passcode_autolock_minutes#other" = "{count} 分钟"; +"lng_passcode_need_unblock" = "请先解锁后再继续。"; +"lng_passcode_turn_off" = "关闭"; +"lng_passcode_turn_on" = "开启本机锁定码"; +"lng_passport_fix_errors" = "请纠正错误。"; +"lng_passport_front_side_name" = "扫描件"; +"lng_passport_link_sent" = "验证链接已发送到\n{email}"; +"lng_passport_main_page_name" = "扫描件"; +"lng_passport_reverse_side_name" = "扫描件"; +"lng_passport_saving" = "正在保存…"; +"lng_passport_selfie_name" = "照片"; +"lng_passport_uploading" = "上传中…"; +"lng_payments_not_supported" = "抱歉,Tidings 桌面版暂不支持付款。请使用我们的移动版应用进行此操作。"; +"lng_payments_webview_no_card" = "抱歉,当前系统配置无法添加新卡。"; +"lng_payments_webview_switch_mutter" = "Qt的窗口嵌入在Mutter窗口管理器中无法正常工作。请切换到另一个窗口管理器或桌面环境。"; +"lng_payments_webview_switch_wayland" = "无法将WebView窗口嵌入Wayland。请切换到X11。"; +"lng_phone_notreg" = "如果您还没有注册 Tidings,\n请使用您的 {link_start}Android / iPhone 设备{link_end}[b]注册[/b],或 {signup_start}点此{signup_end}注册。"; +"lng_pinned_poll" = "置顶投票"; +"lng_pinned_quiz" = "已置顶问答"; +"lng_polls_create_hint" = "点击选择正确的答案"; +"lng_premium_stickers" = "高级版贴纸"; +"lng_premium_summary_about_unique_reactions" = "使用更多动画表情回应消息,仅高级版订阅者可用。"; +"lng_premium_summary_subtitle_unique_reactions" = "专属表情回应"; +"lng_premium_summary_user_button" = "了解更多"; +"lng_profile_actions_section" = "操作"; +"lng_profile_add_more_after_upgrade#other" = "在升级为超级群组后,成员数量上限将提升至高达 {count} 人。"; +"lng_profile_audios_header" = "语音消息"; +"lng_profile_bio" = "个人简介:"; +"lng_profile_convert_confirm" = "群升级"; +"lng_profile_convert_feature1" = "— 取决于您的设置,新成员将可以/无法看到所有历史聊天记录"; +"lng_profile_convert_feature2" = "— 所有成员将看不到已删除的消息"; +"lng_profile_convert_feature3" = "— 管理员可以向群组添加描述"; +"lng_profile_convert_feature4" = "— 创建者可以给群组设置一条公开链接"; +"lng_profile_convert_title" = "升级为超级群组"; +"lng_profile_convert_warning" = "{bold_start} 注意:{bold_end} 此操作无法撤消"; +"lng_profile_create_public_link" = "创建公开链接"; +"lng_profile_delete_and_exit" = "退出"; +"lng_profile_delete_contact" = "删除"; +"lng_profile_drop_area_subtitle" = "以将其设置为群组头像"; +"lng_profile_drop_area_subtitle_channel" = "以将其设置为频道头像"; +"lng_profile_drop_area_title" = "拖拽图片到此处"; +"lng_profile_edit_contact" = "编辑"; +"lng_profile_edit_public_link" = "编辑公开链接"; +"lng_profile_export_channel" = "导出频道帖文"; +"lng_profile_files_header" = "文件"; +"lng_profile_gifs_header" = "GIF 动态图"; +"lng_profile_info_section" = "介绍"; +"lng_profile_invite_link_section" = "邀请链接"; +"lng_profile_link" = "链接:"; +"lng_profile_manage_admins" = "设置管理员"; +"lng_profile_manage_blocklist" = "管理被封禁的用户"; +"lng_profile_manage_restrictedlist" = "管理受限用户"; +"lng_profile_migrate_body" = "升级为超级群组即可添加更多成员。"; +"lng_profile_migrate_button" = "转换为超级群组"; +"lng_profile_migrate_learn_more" = "了解更多»"; +"lng_profile_migrate_reached#other" = "已达 {count} 位成员人数上限"; +"lng_profile_mobile_number" = "手机:"; +"lng_profile_photos_header" = "图片"; +"lng_profile_recent_actions" = "近期操作"; +"lng_profile_search_members" = "搜索成员"; +"lng_profile_search_messages" = "搜索消息"; +"lng_profile_set_for_done" = "您将会在 {user} 的帐号中看到这个头像。"; +"lng_profile_set_group_photo" = "设置头像"; +"lng_profile_share_contact" = "分享联系人信息"; +"lng_profile_shared_links_header" = "分享的链接"; +"lng_profile_songs_header" = "音频文件"; +"lng_profile_top_bar_share_contact" = "分享"; +"lng_profile_unblock_bot" = "将此机器人移出黑名单"; +"lng_profile_username" = "用户名:"; +"lng_profile_videos_header" = "视频"; +"lng_proxy_use" = "使用代理"; +"lng_reaction_live_location" = "对您的实时位置 {reaction}"; +"lng_reaction_premium_info" = "在消息反馈上点击以预览动画。"; +"lng_reaction_premium_no_channel" = "此频道已限制部分回应。"; +"lng_reaction_premium_no_group" = "此群组已限制部分回应。"; +"lng_recommended" = "推荐"; +"lng_report_reason_description" = "说明"; +"lng_report_spam_hide" = "隐藏"; +"lng_report_spam_sure" = "您要举报此用户发布垃圾信息吗?"; +"lng_report_spam_thanks" = "感谢您的举报!"; +"lng_restricted_list_title" = "受限用户"; +"lng_restricted_send_media" = "此群组的管理员已限制您发送媒体文件。"; +"lng_restricted_send_media_all" = "该群组不允许发送媒体文件。"; +"lng_restricted_send_media_until" = "该群组的管理员限制了您在 {date} {time} 之前发送媒体文件。"; +"lng_rights_chat_read" = "查看消息"; +"lng_rights_slowmode_hours#other" = "{count}小时"; +"lng_rights_slowmode_minutes#other" = "{count}分钟"; +"lng_rights_slowmode_seconds#other" = "{count}秒"; +"lng_self_destruct_months#other" = "{count} 个月"; +"lng_self_destruct_weeks#other" = "{count} 周"; +"lng_self_destruct_years#other" = "{count} 年"; +"lng_send_album" = "合并为相册发送"; +"lng_send_files#other" = "作为 {count} 个文件发送"; +"lng_send_image_too_large" = "无法发送此文件,文件大小超过 1500 MB:{name}"; +"lng_send_photo" = "作为图片发送"; +"lng_send_photos#other" = "作为 {count} 张图片发送"; +"lng_send_separate_photos" = "逐条发送图片"; +"lng_send_separate_photos_videos" = "逐条发送媒体文件"; +"lng_settings_advanced_notifications" = "通知位置/数量"; +"lng_settings_audios_cached#other" = "{count} 条语音消息, ({size})"; +"lng_settings_auto_night_enabled" = "匹配系统设定"; +"lng_settings_autoplay_gifs" = "自动播放 GIF 动态图"; +"lng_settings_bg_edit_theme" = "启动主题编辑器"; +"lng_settings_bg_use_default" = "使用默认主题"; +"lng_settings_call_audio_ducking" = "通话时禁用其他声音"; +"lng_settings_call_input_volume" = "输入音量:{percent}%"; +"lng_settings_call_output_volume" = "输出音量:{percent}%"; +"lng_settings_call_stop_mic_test" = "停止测试"; +"lng_settings_call_test_mic" = "测试麦克风"; +"lng_settings_calls_peer_to_peer" = "在通话中使用端对端连接"; +"lng_settings_calls_privacy" = "通话隐私设置"; +"lng_settings_change_lang" = "更改语言"; +"lng_settings_change_phone" = "更换手机号码"; +"lng_settings_choose_username" = "设定用户名"; +"lng_settings_close_sure" = "您确定要关闭此页面吗?您尚未保存您的更改。"; +"lng_settings_connected_about" = "点击以取消您 Tidings 账号的授权。"; +"lng_settings_crop_profile" = "截取一部分作为您的头像"; +"lng_settings_current_version_label" = "{version} 版:"; +"lng_settings_disable_night_theme" = "关闭夜间模式"; +"lng_settings_downloading" = "下载更新中 {ready} / {total} MiB…"; +"lng_settings_drop_area_subtitle" = "以设置为您的头像"; +"lng_settings_edit" = "编辑"; +"lng_settings_edit_info" = "编辑信息"; +"lng_settings_empty_bio" = "< 空 >"; +"lng_settings_enable_animations" = "启用动画效果"; +"lng_settings_enable_night_theme" = "开启夜间模式"; +"lng_settings_group_privacy_about" = "选择哪些人能将您添加到群组和频道中。"; +"lng_settings_groups_invite_privacy" = "群组和频道的邀请权限设置"; +"lng_settings_images_cached#other" = "{count} 张图片 ({size})"; +"lng_settings_last_seen_privacy" = "最后上线时间隐私设置"; +"lng_settings_logged_in_about" = "您使用 Tidings 登录过的网站。"; +"lng_settings_network_proxy" = "网络和代理"; +"lng_settings_no_data_cached" = "未发现任何缓存!"; +"lng_settings_password_reenter_email" = "重新输入恢复邮箱"; +"lng_settings_password_title" = "两步验证"; +"lng_settings_peer_to_peer" = "端对端连接"; +"lng_settings_reset" = "注销所有其它设备上的会话"; +"lng_settings_scale_auto" = "自动 ({cur})"; +"lng_settings_section_advanced_settings" = "高级设置"; +"lng_settings_section_general" = "通用"; +"lng_settings_section_scale" = "界面缩放比例"; +"lng_settings_self_destruct" = "自动删除帐号设置"; +"lng_settings_show_name" = "显示发送者姓名"; +"lng_settings_show_preview" = "显示消息预览"; +"lng_settings_sound_notify" = "播放提示音"; +"lng_settings_sound_notify_off" = "关闭"; +"lng_settings_theme_blue" = "天蓝"; +"lng_settings_theme_matrix" = "矩阵"; +"lng_settings_theme_midnight" = "深夜"; +"lng_settings_ttl_select_chats_subtitle" = "应用自毁定时器"; +"lng_settings_ttl_select_chats_subtitle_chosen" = "将具有自毁计时器"; +"lng_settings_ttl_select_chats_title" = "选择聊天"; +"lng_settings_update_now" = "立即重启"; +"lng_settings_upload" = "设置头像"; +"lng_settings_uploading_photo" = "上传头像中…"; +"lng_settings_username" = "用户名:"; +"lng_settings_view_emojis" = "显示列表"; +"lng_signin_password_removed" = "两步验证已被停用\n您可以稍后在「设置」中重新启用。"; +"lng_signin_reset_days#other" = "{count} 天"; +"lng_signin_reset_hours#other" = "{count} 小时"; +"lng_signin_reset_minutes#other" = "{count} 分钟"; +"lng_sponsored" = "广告"; +"lng_stats_recent_messages_shares#other" = "{count} 次分享"; +"lng_sticker_premium_title" = "包含特效"; +"lng_stickers_masks_pack" = "这是一个装饰包。只有 Tidings 移动版应用支持使用装饰。"; +"lng_stickers_remove_pack_button" = "移除贴纸包"; +"lng_stickers_search_sets" = "搜索贴纸包"; +"lng_sure_add_admin_unban" = "此用户正在被限制/封禁。您确定要解除其封禁并设为管理员吗?"; +"lng_sure_ban_user_channel" = "要在频道中屏蔽 {user} 吗?"; +"lng_sure_ban_user_group" = "是否在群组中封禁 {user} ?"; +"lng_sure_clear_downloads" = "您确定要删除临时文件夹中所有下载的文件吗? 它们会在您退出或卸载时被自动清空。"; +"lng_sure_remove_user_channel" = "确定要将 {user} 从频道中移除?"; +"lng_sure_remove_user_group" = "要将 {user} 从群组中移除吗?"; +"lng_theme_editor_attach_title" = "附上桌面主题"; +"lng_theme_editor_export" = "导出"; +"lng_theme_editor_export_button" = "导出主题"; +"lng_theme_editor_save_palette" = "保存配色文件"; +"lng_theme_no_desktop_version" = "抱歉,此主题未包含 Tidings Desktop 的版本。\n\n"; +"lng_translate_box_original" = "原文"; +"lng_ttl_about_duration1" = "1 天"; +"lng_ttl_about_duration2" = "一周"; +"lng_ttl_about_duration3" = "1 个月"; +"lng_ttl_edit_save" = "确认"; +"lng_username_about" = "您可以在 **Tidings** 上设置一个用户名。\n设置后,别人可以通过此用户名找到您并\n与您联系,而不需要您的手机号码。\n\n您可以使用 **a-z**、**0-9** 和下划线。\n最小长度为 **5** 个字符。"; +"lng_username_description" = "你可以设置一个用户名,这样其他人无需知道你的手机号,也可以通过这个用户名联系你。\n\n用户名只能包括 **小写字母(a-z)**、**数字(0-9)** 和 **下划线** 且至少为**5个字符**。"; +"lng_username_link_willbe" = "通过此链接将可与您进行对话:"; diff --git a/deploy/deploy.go b/deploy/deploy.go new file mode 100644 index 00000000..04d528bb --- /dev/null +++ b/deploy/deploy.go @@ -0,0 +1,10 @@ +// Package deploy 提供部署期资源(迁移脚本)的嵌入访问。 +package deploy + +import "embed" + +// Migrations 是嵌入的 golang-migrate 迁移脚本(deploy/migrations/*.sql)。 +// 供 store/postgres 的迁移 runner 在启动时执行,避免运行时依赖外部文件路径。 +// +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 00000000..ce4c0ee7 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,48 @@ +# telesrv 第一阶段三方依赖(开发用)。 +# +# 启动: docker compose up -d +# 状态: docker compose ps +# 关停: docker compose down (保留数据卷) +# 清空: docker compose down -v (连数据卷一起删,重置 schema/数据) +# +# server 端连接串见 internal/config/config.go 的默认值(localhost:5432 / localhost:6399)。 +name: telesrv + +services: + postgres: + image: postgres:17-alpine + container_name: telesrv-postgres + environment: + POSTGRES_DB: telesrv + POSTGRES_USER: telesrv + POSTGRES_PASSWORD: telesrv + TZ: UTC + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U telesrv -d telesrv"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: telesrv-redis + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6399:6379" # 宿主 6379 常被其他项目占用,telesrv 对外用 6399(容器内仍 6379) + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + +volumes: + pgdata: + redisdata: diff --git a/deploy/migrations/0001_init.down.sql b/deploy/migrations/0001_init.down.sql new file mode 100644 index 00000000..41918738 --- /dev/null +++ b/deploy/migrations/0001_init.down.sql @@ -0,0 +1,4 @@ +-- 0001_init 回滚:按外键依赖逆序删表。 +DROP TABLE IF EXISTS authorizations; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS auth_keys; diff --git a/deploy/migrations/0001_init.up.sql b/deploy/migrations/0001_init.up.sql new file mode 100644 index 00000000..7336707c --- /dev/null +++ b/deploy/migrations/0001_init.up.sql @@ -0,0 +1,48 @@ +-- 0001_init: telesrv 第一阶段持久化 schema(auth_key / user / authorization)。 +-- +-- 协议产物(auth_keys)与业务产物(authorizations)分表,遵循协议/业务隔离边界。 + +-- MTProto auth key:密钥交换产物。server 重启后据此解密客户端加密包,避免客户端重建密钥。 +CREATE TABLE IF NOT EXISTS auth_keys ( + auth_key_id BIGINT PRIMARY KEY, -- SHA1(auth_key) 低 64 位,小端解释为 int64 + body BYTEA NOT NULL, -- 256 字节 auth key 原文 + server_salt BIGINT NOT NULL, -- 密钥交换产出的初始 server salt + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- 用户:第一阶段仅登录链路必须字段。access_hash 为任何 InputUser 校验所必须,不可省。 +CREATE TABLE IF NOT EXISTS users ( + -- 普通用户 ID 从 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳开始。 + -- 777000 等系统兼容账号显式插入,低于该区间。 + id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1780243200) PRIMARY KEY, + access_hash BIGINT NOT NULL, + phone VARCHAR(32) NOT NULL, + first_name VARCHAR(64) NOT NULL DEFAULT '', + last_name VARCHAR(64) NOT NULL DEFAULT '', + username VARCHAR(64) NOT NULL DEFAULT '', + country_code VARCHAR(8) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT users_phone_key UNIQUE (phone) +); + +-- 授权:auth_key ↔ user 绑定 + initConnection 带来的设备信息。 +-- 这是 auth.signIn 后绑定关系与 account.getAuthorizations 的权威来源; +-- 也是 rpc.ClientInfo(device/app/layer)的持久化归宿。 +CREATE TABLE IF NOT EXISTS authorizations ( + auth_key_id BIGINT PRIMARY KEY REFERENCES auth_keys(auth_key_id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + hash BIGINT NOT NULL DEFAULT 0, -- getAuthorizations 列表项 hash + layer INT NOT NULL DEFAULT 0, + device_model VARCHAR(128) NOT NULL DEFAULT '', + platform VARCHAR(64) NOT NULL DEFAULT '', + system_version VARCHAR(64) NOT NULL DEFAULT '', + api_id INT NOT NULL DEFAULT 0, + app_version VARCHAR(64) NOT NULL DEFAULT '', + ip VARCHAR(64) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + active_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- 按 user 反查授权(PushToUser / getAuthorizations)。 +CREATE INDEX IF NOT EXISTS authorizations_user_id_idx ON authorizations (user_id); diff --git a/deploy/migrations/0002_phase1_business.down.sql b/deploy/migrations/0002_phase1_business.down.sql new file mode 100644 index 00000000..afb39a64 --- /dev/null +++ b/deploy/migrations/0002_phase1_business.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS lang_pack_strings; +DROP TABLE IF EXISTS lang_packs; +DROP TABLE IF EXISTS dialogs; +DROP TABLE IF EXISTS contacts; +DROP TABLE IF EXISTS update_states; diff --git a/deploy/migrations/0002_phase1_business.up.sql b/deploy/migrations/0002_phase1_business.up.sql new file mode 100644 index 00000000..991edd18 --- /dev/null +++ b/deploy/migrations/0002_phase1_business.up.sql @@ -0,0 +1,73 @@ +-- 0002_phase1_business: first-stage business persistence for startup RPCs. +-- +-- 表结构按 telesrv domain/store 边界建模,不引入旧工程依赖。 + +CREATE TABLE IF NOT EXISTS update_states ( + auth_key_id BIGINT PRIMARY KEY REFERENCES auth_keys(auth_key_id) ON DELETE CASCADE, + pts INT NOT NULL DEFAULT 0, + qts INT NOT NULL DEFAULT 0, + date INT NOT NULL DEFAULT 0, + seq INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS contacts ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + contact_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mutual BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, contact_user_id) +); + +CREATE INDEX IF NOT EXISTS contacts_contact_user_id_idx ON contacts (contact_user_id); + +CREATE TABLE IF NOT EXISTS dialogs ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + peer_type VARCHAR(16) NOT NULL, + peer_id BIGINT NOT NULL, + top_message_id INT NOT NULL DEFAULT 0, + read_inbox_max_id INT NOT NULL DEFAULT 0, + read_outbox_max_id INT NOT NULL DEFAULT 0, + unread_count INT NOT NULL DEFAULT 0, + unread_mentions_count INT NOT NULL DEFAULT 0, + unread_reactions_count INT NOT NULL DEFAULT 0, + pinned BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, peer_type, peer_id), + CONSTRAINT dialogs_peer_type_check CHECK (peer_type IN ('user')) +); + +CREATE INDEX IF NOT EXISTS dialogs_user_updated_idx ON dialogs (user_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS dialogs_user_pinned_idx ON dialogs (user_id, pinned) WHERE pinned; + +CREATE TABLE IF NOT EXISTS lang_packs ( + lang_pack VARCHAR(32) NOT NULL, + lang_code VARCHAR(64) NOT NULL, + version INT NOT NULL, + strings_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (lang_pack, lang_code) +); + +CREATE TABLE IF NOT EXISTS lang_pack_strings ( + lang_pack VARCHAR(32) NOT NULL, + lang_code VARCHAR(64) NOT NULL, + key VARCHAR(128) NOT NULL, + version INT NOT NULL, + pluralized BOOLEAN NOT NULL DEFAULT false, + value TEXT NOT NULL DEFAULT '', + zero_value TEXT NOT NULL DEFAULT '', + one_value TEXT NOT NULL DEFAULT '', + two_value TEXT NOT NULL DEFAULT '', + few_value TEXT NOT NULL DEFAULT '', + many_value TEXT NOT NULL DEFAULT '', + other_value TEXT NOT NULL DEFAULT '', + deleted BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (lang_pack, lang_code, key), + FOREIGN KEY (lang_pack, lang_code) REFERENCES lang_packs(lang_pack, lang_code) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS lang_pack_strings_pack_version_idx + ON lang_pack_strings (lang_pack, lang_code, version); diff --git a/deploy/migrations/0003_startup_config_security.down.sql b/deploy/migrations/0003_startup_config_security.down.sql new file mode 100644 index 00000000..fe1bf94e --- /dev/null +++ b/deploy/migrations/0003_startup_config_security.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS temp_auth_key_bindings; +DROP TABLE IF EXISTS country_codes; +DROP TABLE IF EXISTS countries; +DROP TABLE IF EXISTS app_configs; +DROP TABLE IF EXISTS account_passwords; diff --git a/deploy/migrations/0003_startup_config_security.up.sql b/deploy/migrations/0003_startup_config_security.up.sql new file mode 100644 index 00000000..ed5ab84b --- /dev/null +++ b/deploy/migrations/0003_startup_config_security.up.sql @@ -0,0 +1,64 @@ +-- 0003_startup_config_security: data-backed startup config, countries and account security. + +CREATE TABLE IF NOT EXISTS account_passwords ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + has_recovery BOOLEAN NOT NULL DEFAULT false, + has_secure_values BOOLEAN NOT NULL DEFAULT false, + has_password BOOLEAN NOT NULL DEFAULT false, + hint VARCHAR(256) NOT NULL DEFAULT '', + email_unconfirmed_pattern VARCHAR(256) NOT NULL DEFAULT '', + login_email_pattern VARCHAR(256) NOT NULL DEFAULT '', + secure_random BYTEA NOT NULL DEFAULT decode('74656c657372762d746465736b746f702d6465762d7365637572652d72616e64', 'hex'), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS app_configs ( + client VARCHAR(64) PRIMARY KEY, + hash INT NOT NULL, + config_json JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS countries ( + iso2 VARCHAR(2) PRIMARY KEY, + default_name VARCHAR(128) NOT NULL, + name VARCHAR(128) NOT NULL DEFAULT '', + hidden BOOLEAN NOT NULL DEFAULT false, + order_index INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS country_codes ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + iso2 VARCHAR(2) NOT NULL REFERENCES countries(iso2) ON DELETE CASCADE, + country_code VARCHAR(16) NOT NULL, + prefixes TEXT[] NOT NULL DEFAULT '{}', + patterns TEXT[] NOT NULL DEFAULT '{}', + order_index INT NOT NULL DEFAULT 0, + UNIQUE (iso2, country_code) +); + +CREATE TABLE IF NOT EXISTS temp_auth_key_bindings ( + temp_auth_key_id BIGINT PRIMARY KEY REFERENCES auth_keys(auth_key_id) ON DELETE CASCADE, + perm_auth_key_id BIGINT NOT NULL, + nonce BIGINT NOT NULL, + expires_at INT NOT NULL, + encrypted_message BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO app_configs (client, hash, config_json) +VALUES ('tdesktop', 1, '{}'::jsonb) +ON CONFLICT (client) DO NOTHING; + +INSERT INTO countries (iso2, default_name, name, hidden, order_index) +VALUES + ('US', 'United States', '', false, 10), + ('CN', 'China', '', false, 20) +ON CONFLICT (iso2) DO NOTHING; + +INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index) +VALUES + ('US', '1', ARRAY['1'], '{}'::text[], 10), + ('CN', '86', ARRAY['86'], '{}'::text[], 20) +ON CONFLICT (iso2, country_code) DO NOTHING; diff --git a/deploy/migrations/0004_temp_auth_key_binding_session.down.sql b/deploy/migrations/0004_temp_auth_key_binding_session.down.sql new file mode 100644 index 00000000..a99d3634 --- /dev/null +++ b/deploy/migrations/0004_temp_auth_key_binding_session.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE temp_auth_key_bindings + DROP COLUMN IF EXISTS temp_session_id; diff --git a/deploy/migrations/0004_temp_auth_key_binding_session.up.sql b/deploy/migrations/0004_temp_auth_key_binding_session.up.sql new file mode 100644 index 00000000..7732aab4 --- /dev/null +++ b/deploy/migrations/0004_temp_auth_key_binding_session.up.sql @@ -0,0 +1,4 @@ +-- 0004_temp_auth_key_binding_session: persist validated bind_auth_key_inner session id. + +ALTER TABLE temp_auth_key_bindings + ADD COLUMN IF NOT EXISTS temp_session_id BIGINT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0005_system_login_messages.down.sql b/deploy/migrations/0005_system_login_messages.down.sql new file mode 100644 index 00000000..65802c08 --- /dev/null +++ b/deploy/migrations/0005_system_login_messages.down.sql @@ -0,0 +1,11 @@ +DROP INDEX IF EXISTS messages_owner_date_idx; +DROP INDEX IF EXISTS messages_owner_dialog_idx; +DROP TABLE IF EXISTS messages; + +DROP INDEX IF EXISTS dialogs_user_top_message_idx; +ALTER TABLE dialogs DROP COLUMN IF EXISTS top_message_date; + +DELETE FROM users WHERE id = 777000; +ALTER TABLE users + DROP COLUMN IF EXISTS support, + DROP COLUMN IF EXISTS verified; diff --git a/deploy/migrations/0005_system_login_messages.up.sql b/deploy/migrations/0005_system_login_messages.up.sql new file mode 100644 index 00000000..75d0665d --- /dev/null +++ b/deploy/migrations/0005_system_login_messages.up.sql @@ -0,0 +1,44 @@ +-- 0005_system_login_messages: official system account and first message persistence. +-- +-- 777000 官方账号与登录消息推送;表结构按 telesrv domain/store 边界建模。 + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS verified BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS support BOOLEAN NOT NULL DEFAULT false; + +INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support) +VALUES (777000, 6599886787491911851, '42777', 'Telegram', '', 'telegram', '', true, true) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + phone = EXCLUDED.phone, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + username = EXCLUDED.username, + verified = EXCLUDED.verified, + support = EXCLUDED.support, + updated_at = now(); + +ALTER TABLE dialogs + ADD COLUMN IF NOT EXISTS top_message_date INT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS dialogs_user_top_message_idx + ON dialogs (user_id, pinned DESC, top_message_date DESC, top_message_id DESC, peer_id DESC); + +CREATE TABLE IF NOT EXISTS messages ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + peer_type VARCHAR(16) NOT NULL, + peer_id BIGINT NOT NULL, + from_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + message_date INT NOT NULL, + outgoing BOOLEAN NOT NULL DEFAULT false, + body TEXT NOT NULL DEFAULT '', + entities JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT messages_peer_type_check CHECK (peer_type IN ('user')) +); + +CREATE INDEX IF NOT EXISTS messages_owner_dialog_idx + ON messages (owner_user_id, peer_type, peer_id, id DESC); +CREATE INDEX IF NOT EXISTS messages_owner_date_idx + ON messages (owner_user_id, message_date DESC, id DESC); diff --git a/deploy/migrations/0006_update_events.down.sql b/deploy/migrations/0006_update_events.down.sql new file mode 100644 index 00000000..f505fd34 --- /dev/null +++ b/deploy/migrations/0006_update_events.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS update_events_auth_pts_idx; +DROP TABLE IF EXISTS update_events; diff --git a/deploy/migrations/0006_update_events.up.sql b/deploy/migrations/0006_update_events.up.sql new file mode 100644 index 00000000..ec31adfc --- /dev/null +++ b/deploy/migrations/0006_update_events.up.sql @@ -0,0 +1,16 @@ +-- 0006_update_events: minimal auth-key update queue for getDifference补偿. + +CREATE TABLE IF NOT EXISTS update_events ( + auth_key_id BIGINT NOT NULL REFERENCES auth_keys(auth_key_id) ON DELETE CASCADE, + pts INT NOT NULL, + pts_count INT NOT NULL DEFAULT 1, + date INT NOT NULL, + event_type VARCHAR(32) NOT NULL, + message_id INT REFERENCES messages(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (auth_key_id, pts), + CONSTRAINT update_events_type_check CHECK (event_type IN ('new_message')) +); + +CREATE INDEX IF NOT EXISTS update_events_auth_pts_idx + ON update_events (auth_key_id, pts); diff --git a/deploy/migrations/0007_read_history_events.down.sql b/deploy/migrations/0007_read_history_events.down.sql new file mode 100644 index 00000000..d6e4ded7 --- /dev/null +++ b/deploy/migrations/0007_read_history_events.down.sql @@ -0,0 +1,15 @@ +ALTER TABLE update_events + DROP CONSTRAINT IF EXISTS update_events_peer_type_check; + +ALTER TABLE update_events + DROP CONSTRAINT IF EXISTS update_events_type_check; + +ALTER TABLE update_events + ADD CONSTRAINT update_events_type_check + CHECK (event_type IN ('new_message')); + +ALTER TABLE update_events + DROP COLUMN IF EXISTS still_unread_count, + DROP COLUMN IF EXISTS max_id, + DROP COLUMN IF EXISTS peer_id, + DROP COLUMN IF EXISTS peer_type; diff --git a/deploy/migrations/0007_read_history_events.up.sql b/deploy/migrations/0007_read_history_events.up.sql new file mode 100644 index 00000000..94f47d66 --- /dev/null +++ b/deploy/migrations/0007_read_history_events.up.sql @@ -0,0 +1,23 @@ +-- 0007_read_history_events: persist readHistory update events for getDifference. +-- +-- updateReadHistoryInbox:真正发生已读推进时递增 pts,并给其它 session / getDifference 留可补偿事件。 + +ALTER TABLE update_events + ADD COLUMN IF NOT EXISTS peer_type VARCHAR(16), + ADD COLUMN IF NOT EXISTS peer_id BIGINT, + ADD COLUMN IF NOT EXISTS max_id INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS still_unread_count INT NOT NULL DEFAULT 0; + +ALTER TABLE update_events + DROP CONSTRAINT IF EXISTS update_events_type_check; + +ALTER TABLE update_events + ADD CONSTRAINT update_events_type_check + CHECK (event_type IN ('new_message', 'read_history_inbox')); + +ALTER TABLE update_events + DROP CONSTRAINT IF EXISTS update_events_peer_type_check; + +ALTER TABLE update_events + ADD CONSTRAINT update_events_peer_type_check + CHECK (peer_type IS NULL OR peer_type IN ('user')); diff --git a/deploy/migrations/0008_user_id_sequence_base.down.sql b/deploy/migrations/0008_user_id_sequence_base.down.sql new file mode 100644 index 00000000..eb26ae39 --- /dev/null +++ b/deploy/migrations/0008_user_id_sequence_base.down.sql @@ -0,0 +1,8 @@ +-- Revert generated ordinary user ids to the original first-phase base when possible. +-- Existing users keep their ids; the next generated id is never moved below MAX(id)+1. + +SELECT setval( + pg_get_serial_sequence('users', 'id'), + GREATEST((SELECT COALESCE(MAX(id), 0) FROM users), 999999999), + true +); diff --git a/deploy/migrations/0008_user_id_sequence_base.up.sql b/deploy/migrations/0008_user_id_sequence_base.up.sql new file mode 100644 index 00000000..3255da6f --- /dev/null +++ b/deploy/migrations/0008_user_id_sequence_base.up.sql @@ -0,0 +1,10 @@ +-- 0008_user_id_sequence_base: move ordinary user ids to the agreed timestamp range. +-- +-- Base: 2026-06-01 00:00:00 Asia/Shanghai => Unix seconds 1780243200. +-- Existing users keep their ids; the next generated id is at least this base. + +SELECT setval( + pg_get_serial_sequence('users', 'id'), + GREATEST((SELECT COALESCE(MAX(id), 0) FROM users), 1780243199), + true +); diff --git a/deploy/migrations/0009_private_message_pipeline.down.sql b/deploy/migrations/0009_private_message_pipeline.down.sql new file mode 100644 index 00000000..c46e4d91 --- /dev/null +++ b/deploy/migrations/0009_private_message_pipeline.down.sql @@ -0,0 +1,13 @@ +-- 0009 rollback: remove second-stage private message pipeline tables. +DROP TABLE IF EXISTS dispatch_outbox; +DROP TABLE IF EXISTS user_update_events; +DROP TABLE IF EXISTS dialogs; +DROP TABLE IF EXISTS message_boxes; +DROP TABLE IF EXISTS private_messages; + +ALTER TABLE IF EXISTS dialogs_legacy RENAME TO dialogs; +ALTER TABLE IF EXISTS messages_legacy RENAME TO messages; + +ALTER TABLE update_states DROP CONSTRAINT IF EXISTS update_states_pkey; +ALTER TABLE update_states DROP COLUMN IF EXISTS user_id; +ALTER TABLE update_states ADD PRIMARY KEY (auth_key_id); diff --git a/deploy/migrations/0009_private_message_pipeline.up.sql b/deploy/migrations/0009_private_message_pipeline.up.sql new file mode 100644 index 00000000..5f1806ea --- /dev/null +++ b/deploy/migrations/0009_private_message_pipeline.up.sql @@ -0,0 +1,239 @@ +-- 0009_private_message_pipeline: second-stage private text message storage. +-- +-- Large tables are partitioned from the first version of the message module: +-- message_boxes/dialogs/user_update_events/dispatch_outbox by owner/target user, +-- private_messages by sender user for random_id idempotency locality. + +ALTER TABLE IF EXISTS messages RENAME TO messages_legacy; +ALTER TABLE IF EXISTS dialogs RENAME TO dialogs_legacy; + +CREATE TABLE IF NOT EXISTS private_messages ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipient_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + random_id BIGINT NOT NULL DEFAULT 0, + message_date INT NOT NULL, + body TEXT NOT NULL DEFAULT '', + entities JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (sender_user_id, id), + CONSTRAINT private_messages_nonempty_body CHECK (body <> '') +) PARTITION BY HASH (sender_user_id); + +CREATE UNIQUE INDEX IF NOT EXISTS private_messages_sender_random_idx + ON private_messages (sender_user_id, random_id) + WHERE random_id <> 0; +CREATE INDEX IF NOT EXISTS private_messages_recipient_date_idx + ON private_messages (recipient_user_id, message_date DESC, id DESC); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS private_messages_p%s PARTITION OF private_messages FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS message_boxes ( + owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + box_id INT NOT NULL, + private_message_id BIGINT NOT NULL, + message_sender_id BIGINT NOT NULL, + peer_type VARCHAR(16) NOT NULL, + peer_id BIGINT NOT NULL, + from_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + message_date INT NOT NULL, + outgoing BOOLEAN NOT NULL DEFAULT false, + body TEXT NOT NULL DEFAULT '', + entities JSONB NOT NULL DEFAULT '[]'::jsonb, + pts INT NOT NULL DEFAULT 0, + deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (owner_user_id, box_id), + UNIQUE (owner_user_id, private_message_id), + CONSTRAINT message_boxes_peer_type_check CHECK (peer_type IN ('user')), + FOREIGN KEY (message_sender_id, private_message_id) + REFERENCES private_messages(sender_user_id, id) ON DELETE CASCADE +) PARTITION BY HASH (owner_user_id); + +CREATE INDEX IF NOT EXISTS message_boxes_dialog_seek_idx + ON message_boxes (owner_user_id, peer_type, peer_id, box_id DESC) + WHERE NOT deleted; +CREATE INDEX IF NOT EXISTS message_boxes_owner_date_idx + ON message_boxes (owner_user_id, message_date DESC, box_id DESC) + WHERE NOT deleted; +CREATE INDEX IF NOT EXISTS message_boxes_private_lookup_idx + ON message_boxes (private_message_id, owner_user_id); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS message_boxes_p%s PARTITION OF message_boxes FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS dialogs ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + peer_type VARCHAR(16) NOT NULL, + peer_id BIGINT NOT NULL, + top_message_id INT NOT NULL DEFAULT 0, + top_message_date INT NOT NULL DEFAULT 0, + read_inbox_max_id INT NOT NULL DEFAULT 0, + read_outbox_max_id INT NOT NULL DEFAULT 0, + unread_count INT NOT NULL DEFAULT 0, + unread_mentions_count INT NOT NULL DEFAULT 0, + unread_reactions_count INT NOT NULL DEFAULT 0, + pinned BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, peer_type, peer_id), + CONSTRAINT dialogs_peer_type_check CHECK (peer_type IN ('user')) +) PARTITION BY HASH (user_id); + +CREATE INDEX IF NOT EXISTS dialogs_user_top_message_idx + ON dialogs (user_id, pinned DESC, top_message_date DESC, top_message_id DESC, peer_id DESC); +CREATE INDEX IF NOT EXISTS dialogs_user_pinned_idx + ON dialogs (user_id, pinned) WHERE pinned; + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS dialogs_p%s PARTITION OF dialogs FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS user_update_events ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pts INT NOT NULL, + pts_count INT NOT NULL DEFAULT 1, + date INT NOT NULL, + event_type VARCHAR(32) NOT NULL, + message_box_id INT, + peer_type VARCHAR(16), + peer_id BIGINT, + max_id INT NOT NULL DEFAULT 0, + still_unread_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, pts), + CONSTRAINT user_update_events_type_check CHECK (event_type IN ('new_message', 'read_history_inbox', 'noop')), + CONSTRAINT user_update_events_peer_type_check CHECK (peer_type IS NULL OR peer_type IN ('user')), + FOREIGN KEY (user_id, message_box_id) REFERENCES message_boxes(owner_user_id, box_id) ON DELETE CASCADE +) PARTITION BY HASH (user_id); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS user_update_events_p%s PARTITION OF user_update_events FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS dispatch_outbox ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + target_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pts INT NOT NULL, + event_type VARCHAR(32) NOT NULL, + exclude_session_id BIGINT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (target_user_id, id), + CONSTRAINT dispatch_outbox_status_check CHECK (status IN ('pending', 'dispatching', 'delivered', 'failed')), + FOREIGN KEY (target_user_id, pts) REFERENCES user_update_events(user_id, pts) ON DELETE CASCADE +) PARTITION BY HASH (target_user_id); + +CREATE INDEX IF NOT EXISTS dispatch_outbox_pending_idx + ON dispatch_outbox (status, next_attempt_at, target_user_id, id) + WHERE status = 'pending'; + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS dispatch_outbox_p%s PARTITION OF dispatch_outbox FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +ALTER TABLE update_states + ADD COLUMN IF NOT EXISTS user_id BIGINT NOT NULL DEFAULT 0; +ALTER TABLE update_states DROP CONSTRAINT IF EXISTS update_states_pkey; +ALTER TABLE update_states ADD PRIMARY KEY (auth_key_id, user_id); +CREATE INDEX IF NOT EXISTS update_states_user_id_idx ON update_states (user_id); + +DO $$ +DECLARE + r record; + private_id bigint; +BEGIN + IF to_regclass('messages_legacy') IS NULL THEN + RETURN; + END IF; + + FOR r IN + SELECT id, owner_user_id, peer_type, peer_id, from_user_id, message_date, outgoing, body, entities + FROM messages_legacy + ORDER BY owner_user_id, id + LOOP + INSERT INTO private_messages ( + sender_user_id, recipient_user_id, random_id, message_date, body, entities + ) VALUES ( + r.from_user_id, + r.owner_user_id, + 0, + r.message_date, + r.body, + r.entities + ) + RETURNING id INTO private_id; + + INSERT INTO message_boxes ( + owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id, + from_user_id, message_date, outgoing, body, entities + ) VALUES ( + r.owner_user_id, r.id, private_id, r.from_user_id, r.peer_type, r.peer_id, + r.from_user_id, r.message_date, r.outgoing, r.body, r.entities + ) + ON CONFLICT (owner_user_id, box_id) DO NOTHING; + END LOOP; +END $$; + +INSERT INTO dialogs ( + user_id, peer_type, peer_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, unread_count, + unread_mentions_count, unread_reactions_count, pinned, updated_at +) +SELECT + user_id, peer_type, peer_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, unread_count, + unread_mentions_count, unread_reactions_count, pinned, updated_at +FROM dialogs_legacy +ON CONFLICT (user_id, peer_type, peer_id) DO NOTHING; diff --git a/deploy/migrations/0010_message_performance_indexes.down.sql b/deploy/migrations/0010_message_performance_indexes.down.sql new file mode 100644 index 00000000..f11cc848 --- /dev/null +++ b/deploy/migrations/0010_message_performance_indexes.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS dispatch_outbox_dispatching_stale_idx; +DROP INDEX IF EXISTS message_boxes_dialog_date_seek_idx; diff --git a/deploy/migrations/0010_message_performance_indexes.up.sql b/deploy/migrations/0010_message_performance_indexes.up.sql new file mode 100644 index 00000000..77b3d99b --- /dev/null +++ b/deploy/migrations/0010_message_performance_indexes.up.sql @@ -0,0 +1,9 @@ +-- 0010_message_performance_indexes: indexes for second-stage message seek paths. + +CREATE INDEX IF NOT EXISTS message_boxes_dialog_date_seek_idx + ON message_boxes (owner_user_id, peer_type, peer_id, message_date DESC, box_id DESC) + WHERE NOT deleted; + +CREATE INDEX IF NOT EXISTS dispatch_outbox_dispatching_stale_idx + ON dispatch_outbox (status, updated_at, target_user_id, id) + WHERE status = 'dispatching'; diff --git a/deploy/migrations/0011_drop_dead_tables.down.sql b/deploy/migrations/0011_drop_dead_tables.down.sql new file mode 100644 index 00000000..78e1a0ba --- /dev/null +++ b/deploy/migrations/0011_drop_dead_tables.down.sql @@ -0,0 +1,6 @@ +-- 0011 down (no-op): 本迁移清除的是已被取代、运行时零引用的死表 +-- (update_events / messages_legacy / dialogs_legacy)。 +-- +-- 全新项目明确不保留回退路径,故不在此重建这些表;历史结构可查 0002 / 0005 / 0006 / 0007 迁移脚本(保留未删)。 +-- golang-migrate 执行本文件即把版本回退到 0010,不恢复任何死表或其数据。 +SELECT 1; diff --git a/deploy/migrations/0011_drop_dead_tables.up.sql b/deploy/migrations/0011_drop_dead_tables.up.sql new file mode 100644 index 00000000..f9f2856b --- /dev/null +++ b/deploy/migrations/0011_drop_dead_tables.up.sql @@ -0,0 +1,13 @@ +-- 0011_drop_dead_tables: 清除已被取代、运行时零引用的遗留表(全新项目,不保留回退)。 +-- +-- - update_events : 一阶段 auth_key 维度 update 队列(0006 建 / 0007 扩展),二阶段被 +-- user_update_events 取代;无任何 query / Go 引用,仅在 sqlc 留下孤儿 model。 +-- - messages_legacy: 0009 由旧 messages(0005)重命名保留的迁移残骸,数据已迁入 private_messages + message_boxes。 +-- - dialogs_legacy : 0009 由旧 dialogs(0002)重命名保留的迁移残骸,数据已迁入新 dialogs。 +-- +-- 顺序要求:update_events.message_id 外键指向 messages_legacy(原 messages),故先删 update_events。 +-- 删除后需重跑 `sqlc generate`,models.go 中 UpdateEvent / MessagesLegacy / DialogsLegacy 孤儿 model 会自动消失。 + +DROP TABLE IF EXISTS update_events; +DROP TABLE IF EXISTS messages_legacy; +DROP TABLE IF EXISTS dialogs_legacy; diff --git a/deploy/migrations/0012_outbox_delete_on_deliver.down.sql b/deploy/migrations/0012_outbox_delete_on_deliver.down.sql new file mode 100644 index 00000000..1bc00c19 --- /dev/null +++ b/deploy/migrations/0012_outbox_delete_on_deliver.down.sql @@ -0,0 +1,5 @@ +-- 0012 down: 恢复宽 status CHECK(含 'delivered')以保持迁移链完整。 +-- 注意:方案 A 已删除的 delivered 行不可恢复;query 层(DELETE)回退需手动改回 UPDATE,本 down 不涉及代码。 +ALTER TABLE dispatch_outbox DROP CONSTRAINT IF EXISTS dispatch_outbox_status_check; +ALTER TABLE dispatch_outbox ADD CONSTRAINT dispatch_outbox_status_check + CHECK (status IN ('pending', 'dispatching', 'delivered', 'failed')); diff --git a/deploy/migrations/0012_outbox_delete_on_deliver.up.sql b/deploy/migrations/0012_outbox_delete_on_deliver.up.sql new file mode 100644 index 00000000..449dfb42 --- /dev/null +++ b/deploy/migrations/0012_outbox_delete_on_deliver.up.sql @@ -0,0 +1,12 @@ +-- 0012_outbox_delete_on_deliver: outbox 投递成功改为直接 DELETE(方案 A),杜绝 delivered 行无限堆积。 +-- +-- 配合 query 改动(MarkDispatchDelivered / MarkDispatchDeliveredBatch 由 UPDATE status='delivered' 改为 DELETE): +-- 1) 清理改造前堆积的存量 delivered 行; +-- 2) 收紧 status CHECK,移除不再使用的 'delivered'(状态机只剩 pending / dispatching / failed)。 +-- dispatch_outbox 为 HASH 分区表,父表 DELETE / ALTER CONSTRAINT 自动作用于全部分区。 + +DELETE FROM dispatch_outbox WHERE status = 'delivered'; + +ALTER TABLE dispatch_outbox DROP CONSTRAINT IF EXISTS dispatch_outbox_status_check; +ALTER TABLE dispatch_outbox ADD CONSTRAINT dispatch_outbox_status_check + CHECK (status IN ('pending', 'dispatching', 'failed')); diff --git a/deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql b/deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql new file mode 100644 index 00000000..ac857aa6 --- /dev/null +++ b/deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql @@ -0,0 +1,17 @@ +DROP INDEX IF EXISTS dialogs_user_pinned_order_idx; + +ALTER TABLE dialogs + DROP COLUMN IF EXISTS hidden_peer_settings_bar, + DROP COLUMN IF EXISTS unread_mark, + DROP COLUMN IF EXISTS pinned_order; + +DROP INDEX IF EXISTS contacts_user_name_idx; + +ALTER TABLE contacts + DROP COLUMN IF EXISTS stories_hidden, + DROP COLUMN IF EXISTS close_friend, + DROP COLUMN IF EXISTS note_entities, + DROP COLUMN IF EXISTS note, + DROP COLUMN IF EXISTS contact_last_name, + DROP COLUMN IF EXISTS contact_first_name, + DROP COLUMN IF EXISTS contact_phone; diff --git a/deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql b/deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql new file mode 100644 index 00000000..2efac0ed --- /dev/null +++ b/deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql @@ -0,0 +1,25 @@ +-- 0013_contact_profiles_and_dialog_pins: owner-scoped contact profile fields and pin order. +-- +-- contact_* columns are deliberately scoped to (user_id, contact_user_id): they model the +-- current account's saved name/phone/note for a peer and must not mutate users global data. + +ALTER TABLE contacts + ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(32) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS contact_first_name VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS contact_last_name VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS note_entities JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS close_friend BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS stories_hidden BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS contacts_user_name_idx + ON contacts (user_id, contact_first_name, contact_last_name, contact_user_id); + +ALTER TABLE dialogs + ADD COLUMN IF NOT EXISTS pinned_order INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS unread_mark BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS hidden_peer_settings_bar BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS dialogs_user_pinned_order_idx + ON dialogs (user_id, pinned, pinned_order, top_message_date DESC, top_message_id DESC, peer_id DESC) + WHERE pinned; diff --git a/deploy/migrations/0014_settings_update_events.down.sql b/deploy/migrations/0014_settings_update_events.down.sql new file mode 100644 index 00000000..a436f80f --- /dev/null +++ b/deploy/migrations/0014_settings_update_events.down.sql @@ -0,0 +1,10 @@ +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ('new_message', 'read_history_inbox', 'noop') + ); + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS event_bool; diff --git a/deploy/migrations/0014_settings_update_events.up.sql b/deploy/migrations/0014_settings_update_events.up.sql new file mode 100644 index 00000000..23ce7ed3 --- /dev/null +++ b/deploy/migrations/0014_settings_update_events.up.sql @@ -0,0 +1,25 @@ +-- 0014_settings_update_events: durable updates for contacts/dialog settings. +-- +-- Online push is not enough: offline sessions must recover contact resets, +-- dialog pin order changes, manual unread marks, and peer settings changes +-- through updates.getDifference. + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS event_bool BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'noop' + ) + ); diff --git a/deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql new file mode 100644 index 00000000..e1da2023 --- /dev/null +++ b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE dispatch_outbox + DROP COLUMN IF EXISTS exclude_auth_key_id; + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS peer_settings; + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS event_peers; diff --git a/deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql new file mode 100644 index 00000000..039d5985 --- /dev/null +++ b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql @@ -0,0 +1,14 @@ +-- 0015_update_event_payloads_and_outbox_auth: keep setting-update payloads durable. +-- +-- event_peers carries ordered dialog peers for updatePinnedDialogs.order. +-- peer_settings carries updatePeerSettings flags. +-- exclude_auth_key_id makes outbox exclusion precise for same session_id across auth keys. + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS event_peers JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS peer_settings JSONB NOT NULL DEFAULT '{}'::jsonb; + +ALTER TABLE dispatch_outbox + ADD COLUMN IF NOT EXISTS exclude_auth_key_id BIGINT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0016_delete_message_updates.down.sql b/deploy/migrations/0016_delete_message_updates.down.sql new file mode 100644 index 00000000..e36c9b41 --- /dev/null +++ b/deploy/migrations/0016_delete_message_updates.down.sql @@ -0,0 +1,21 @@ +DROP INDEX IF EXISTS message_boxes_private_sender_live_idx; + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'noop' + ) + ); + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS message_ids; diff --git a/deploy/migrations/0016_delete_message_updates.up.sql b/deploy/migrations/0016_delete_message_updates.up.sql new file mode 100644 index 00000000..17b59c98 --- /dev/null +++ b/deploy/migrations/0016_delete_message_updates.up.sql @@ -0,0 +1,29 @@ +-- 0016_delete_message_updates: durable owner-view delete message updates. +-- +-- message_ids carries updateDeleteMessages.messages for offline getDifference +-- and reliable online outbox delivery. + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS message_ids JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'noop' + ) + ); + +CREATE INDEX IF NOT EXISTS message_boxes_private_sender_live_idx + ON message_boxes (message_sender_id, private_message_id) + WHERE NOT deleted; diff --git a/deploy/migrations/0017_dialog_folders.down.sql b/deploy/migrations/0017_dialog_folders.down.sql new file mode 100644 index 00000000..a07578fb --- /dev/null +++ b/deploy/migrations/0017_dialog_folders.down.sql @@ -0,0 +1,46 @@ +-- 0017_dialog_folders rollback. + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'noop' + ) + ); + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS tags_enabled; + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS filter_id; + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS folder_peers; + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS filter_order; + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS dialog_filter; + +DROP TABLE IF EXISTS dialog_filter_settings CASCADE; + +DROP TABLE IF EXISTS dialog_filters CASCADE; + +DROP INDEX IF EXISTS dialogs_user_folder_top_message_idx; + +ALTER TABLE dialogs + DROP CONSTRAINT IF EXISTS dialogs_folder_id_check; + +ALTER TABLE dialogs + DROP COLUMN IF EXISTS folder_id; diff --git a/deploy/migrations/0017_dialog_folders.up.sql b/deploy/migrations/0017_dialog_folders.up.sql new file mode 100644 index 00000000..299f9fc7 --- /dev/null +++ b/deploy/migrations/0017_dialog_folders.up.sql @@ -0,0 +1,91 @@ +-- 0017_dialog_folders: archive folder, custom dialog filters, and durable folder updates. + +ALTER TABLE dialogs + ADD COLUMN IF NOT EXISTS folder_id INT NOT NULL DEFAULT 0; + +ALTER TABLE dialogs + DROP CONSTRAINT IF EXISTS dialogs_folder_id_check; + +ALTER TABLE dialogs + ADD CONSTRAINT dialogs_folder_id_check CHECK (folder_id >= 0); + +CREATE INDEX IF NOT EXISTS dialogs_user_folder_top_message_idx + ON dialogs (user_id, folder_id, pinned DESC, top_message_date DESC, top_message_id DESC, peer_id DESC); + +CREATE TABLE IF NOT EXISTS dialog_filters ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filter_id INT NOT NULL, + is_chatlist BOOLEAN NOT NULL DEFAULT false, + filter JSONB NOT NULL, + order_value INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, filter_id), + CONSTRAINT dialog_filters_id_check CHECK (filter_id >= 2), + CONSTRAINT dialog_filters_filter_object_check CHECK (jsonb_typeof(filter) = 'object') +) PARTITION BY HASH (user_id); + +CREATE INDEX IF NOT EXISTS dialog_filters_user_order_idx + ON dialog_filters (user_id, order_value, filter_id); + +CREATE TABLE IF NOT EXISTS dialog_filter_settings ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tags_enabled BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id) +) PARTITION BY HASH (user_id); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS dialog_filters_p%s PARTITION OF dialog_filters FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS dialog_filter_settings_p%s PARTITION OF dialog_filter_settings FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS dialog_filter JSONB NOT NULL DEFAULT '{}'::jsonb; + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS filter_order JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS folder_peers JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS filter_id INT NOT NULL DEFAULT 0; + +ALTER TABLE user_update_events + ADD COLUMN IF NOT EXISTS tags_enabled BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'dialog_filter', + 'dialog_filter_order', + 'dialog_filters', + 'folder_peers', + 'noop' + ) + ); diff --git a/deploy/migrations/0018_user_search_indexes.down.sql b/deploy/migrations/0018_user_search_indexes.down.sql new file mode 100644 index 00000000..5e1ffb96 --- /dev/null +++ b/deploy/migrations/0018_user_search_indexes.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS message_boxes_body_trgm_idx; +DROP INDEX IF EXISTS contacts_user_saved_name_trgm_idx; +DROP INDEX IF EXISTS users_name_lower_trgm_idx; +DROP INDEX IF EXISTS users_username_lower_trgm_idx; +DROP INDEX IF EXISTS users_phone_prefix_idx; diff --git a/deploy/migrations/0018_user_search_indexes.up.sql b/deploy/migrations/0018_user_search_indexes.up.sql new file mode 100644 index 00000000..4512f05c --- /dev/null +++ b/deploy/migrations/0018_user_search_indexes.up.sql @@ -0,0 +1,19 @@ +-- 0018_user_search_indexes: keep TDesktop global user search bounded as users grow. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS users_phone_prefix_idx + ON users (phone text_pattern_ops); + +CREATE INDEX IF NOT EXISTS users_username_lower_trgm_idx + ON users USING gin (lower(username) gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS users_name_lower_trgm_idx + ON users USING gin (lower(trim(first_name || ' ' || last_name)) gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS contacts_user_saved_name_trgm_idx + ON contacts USING gin (lower(trim(contact_first_name || ' ' || contact_last_name)) gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS message_boxes_body_trgm_idx + ON message_boxes USING gin (body gin_trgm_ops) + WHERE NOT deleted AND body <> ''; diff --git a/deploy/migrations/0019_usernames.down.sql b/deploy/migrations/0019_usernames.down.sql new file mode 100644 index 00000000..784e133b --- /dev/null +++ b/deploy/migrations/0019_usernames.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS users_username_lower_unique_idx; diff --git a/deploy/migrations/0019_usernames.up.sql b/deploy/migrations/0019_usernames.up.sql new file mode 100644 index 00000000..198c7dbe --- /dev/null +++ b/deploy/migrations/0019_usernames.up.sql @@ -0,0 +1,5 @@ +-- 0019_usernames: primary username lifecycle. + +CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique_idx + ON users (lower(username)) + WHERE username <> ''; diff --git a/deploy/migrations/0020_profile_message_state.down.sql b/deploy/migrations/0020_profile_message_state.down.sql new file mode 100644 index 00000000..e1790042 --- /dev/null +++ b/deploy/migrations/0020_profile_message_state.down.sql @@ -0,0 +1,34 @@ +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'dialog_filter', + 'dialog_filter_order', + 'dialog_filters', + 'folder_peers', + 'noop' + ) + ); + +DROP INDEX IF EXISTS message_boxes_private_sender_owner_idx; +DROP INDEX IF EXISTS user_update_events_read_outbox_idx; +DROP INDEX IF EXISTS message_boxes_read_receipt_idx; + +ALTER TABLE message_boxes + DROP COLUMN IF EXISTS edit_date; + +ALTER TABLE private_messages + DROP COLUMN IF EXISTS edit_date; + +ALTER TABLE users + DROP COLUMN IF EXISTS about; diff --git a/deploy/migrations/0020_profile_message_state.up.sql b/deploy/migrations/0020_profile_message_state.up.sql new file mode 100644 index 00000000..f4e2abce --- /dev/null +++ b/deploy/migrations/0020_profile_message_state.up.sql @@ -0,0 +1,46 @@ +-- 0020_profile_message_state: profile about, message edits, and read outbox updates. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS about VARCHAR(255) NOT NULL DEFAULT ''; + +ALTER TABLE private_messages + ADD COLUMN IF NOT EXISTS edit_date INT NOT NULL DEFAULT 0; + +ALTER TABLE message_boxes + ADD COLUMN IF NOT EXISTS edit_date INT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS message_boxes_read_receipt_idx + ON message_boxes (owner_user_id, peer_type, peer_id, box_id DESC) + WHERE NOT deleted AND NOT outgoing; + +CREATE INDEX IF NOT EXISTS message_boxes_private_sender_owner_idx + ON message_boxes (message_sender_id, private_message_id, owner_user_id) + WHERE NOT deleted; + +CREATE INDEX IF NOT EXISTS user_update_events_read_outbox_idx + ON user_update_events (user_id, peer_type, peer_id, max_id, date) + WHERE event_type = 'read_history_outbox'; + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'read_history_outbox', + 'edit_message', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'dialog_filter', + 'dialog_filter_order', + 'dialog_filters', + 'folder_peers', + 'noop' + ) + ); diff --git a/deploy/migrations/0021_message_reply_forward.down.sql b/deploy/migrations/0021_message_reply_forward.down.sql new file mode 100644 index 00000000..b99ad932 --- /dev/null +++ b/deploy/migrations/0021_message_reply_forward.down.sql @@ -0,0 +1,31 @@ +DROP INDEX IF EXISTS message_boxes_reply_lookup_idx; + +ALTER TABLE message_boxes + DROP COLUMN IF EXISTS fwd_date, + DROP COLUMN IF EXISTS fwd_from_name, + DROP COLUMN IF EXISTS fwd_from_peer_id, + DROP COLUMN IF EXISTS fwd_from_peer_type, + DROP COLUMN IF EXISTS quote_offset, + DROP COLUMN IF EXISTS quote_entities, + DROP COLUMN IF EXISTS quote_text, + DROP COLUMN IF EXISTS reply_to_top_id, + DROP COLUMN IF EXISTS reply_to_peer_id, + DROP COLUMN IF EXISTS reply_to_peer_type, + DROP COLUMN IF EXISTS reply_to_msg_id, + DROP COLUMN IF EXISTS noforwards, + DROP COLUMN IF EXISTS silent; + +ALTER TABLE private_messages + DROP COLUMN IF EXISTS fwd_date, + DROP COLUMN IF EXISTS fwd_from_name, + DROP COLUMN IF EXISTS fwd_from_peer_id, + DROP COLUMN IF EXISTS fwd_from_peer_type, + DROP COLUMN IF EXISTS quote_offset, + DROP COLUMN IF EXISTS quote_entities, + DROP COLUMN IF EXISTS quote_text, + DROP COLUMN IF EXISTS reply_to_top_id, + DROP COLUMN IF EXISTS reply_to_peer_id, + DROP COLUMN IF EXISTS reply_to_peer_type, + DROP COLUMN IF EXISTS reply_to_msg_id, + DROP COLUMN IF EXISTS noforwards, + DROP COLUMN IF EXISTS silent; diff --git a/deploy/migrations/0021_message_reply_forward.up.sql b/deploy/migrations/0021_message_reply_forward.up.sql new file mode 100644 index 00000000..991a5d72 --- /dev/null +++ b/deploy/migrations/0021_message_reply_forward.up.sql @@ -0,0 +1,35 @@ +-- 0021_message_reply_forward: private-message silent/reply/forward metadata. + +ALTER TABLE private_messages + ADD COLUMN IF NOT EXISTS silent BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS noforwards BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS reply_to_msg_id INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reply_to_peer_type VARCHAR(16) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS reply_to_peer_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reply_to_top_id INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS quote_text TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS quote_entities JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS quote_offset INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS fwd_from_peer_type VARCHAR(16) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS fwd_from_peer_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS fwd_from_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS fwd_date INT NOT NULL DEFAULT 0; + +ALTER TABLE message_boxes + ADD COLUMN IF NOT EXISTS silent BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS noforwards BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS reply_to_msg_id INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reply_to_peer_type VARCHAR(16) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS reply_to_peer_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reply_to_top_id INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS quote_text TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS quote_entities JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS quote_offset INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS fwd_from_peer_type VARCHAR(16) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS fwd_from_peer_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS fwd_from_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS fwd_date INT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS message_boxes_reply_lookup_idx + ON message_boxes (owner_user_id, peer_type, peer_id, box_id) + WHERE NOT deleted; diff --git a/deploy/migrations/0022_channels.down.sql b/deploy/migrations/0022_channels.down.sql new file mode 100644 index 00000000..453c47b0 --- /dev/null +++ b/deploy/migrations/0022_channels.down.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS channel_invites CASCADE; +DROP TABLE IF EXISTS channel_dialogs CASCADE; +DROP TABLE IF EXISTS channel_admin_log_events CASCADE; +DROP TABLE IF EXISTS channel_update_events CASCADE; +DROP TABLE IF EXISTS channel_messages CASCADE; +DROP TABLE IF EXISTS channel_members CASCADE; +DROP TABLE IF EXISTS channel_usernames CASCADE; +DROP TABLE IF EXISTS channel_invite_hashes CASCADE; +DROP TABLE IF EXISTS channels CASCADE; diff --git a/deploy/migrations/0022_channels.up.sql b/deploy/migrations/0022_channels.up.sql new file mode 100644 index 00000000..ed8ed51e --- /dev/null +++ b/deploy/migrations/0022_channels.up.sql @@ -0,0 +1,398 @@ +-- 0022_channels: supergroup/channel storage. +-- +-- Channel messages are single-copy. Per-user dialog/read state is stored separately. +-- Channel pts is scoped by channel_id and persisted in channel_update_events. + +CREATE TABLE IF NOT EXISTS channels ( + id BIGINT NOT NULL, + access_hash BIGINT NOT NULL, + creator_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + title TEXT NOT NULL, + about TEXT NOT NULL DEFAULT '', + username TEXT, + broadcast BOOLEAN NOT NULL DEFAULT false, + megagroup BOOLEAN NOT NULL DEFAULT false, + forum BOOLEAN NOT NULL DEFAULT false, + forum_tabs BOOLEAN NOT NULL DEFAULT false, + noforwards BOOLEAN NOT NULL DEFAULT false, + join_to_send BOOLEAN NOT NULL DEFAULT false, + join_request BOOLEAN NOT NULL DEFAULT false, + signatures BOOLEAN NOT NULL DEFAULT false, + pre_history_hidden BOOLEAN NOT NULL DEFAULT false, + participants_hidden BOOLEAN NOT NULL DEFAULT false, + antispam BOOLEAN NOT NULL DEFAULT false, + linked_chat_id BIGINT NOT NULL DEFAULT 0, + slowmode_seconds INT NOT NULL DEFAULT 0, + default_banned_rights JSONB NOT NULL DEFAULT '{}'::jsonb, + available_reactions JSONB NOT NULL DEFAULT '{}'::jsonb, + color_set BOOLEAN NOT NULL DEFAULT false, + color INT NOT NULL DEFAULT 0, + color_background_emoji_id BIGINT NOT NULL DEFAULT 0, + profile_color_set BOOLEAN NOT NULL DEFAULT false, + profile_color INT NOT NULL DEFAULT 0, + profile_color_background_emoji_id BIGINT NOT NULL DEFAULT 0, + emoji_status_document_id BIGINT NOT NULL DEFAULT 0, + emoji_status_until INT NOT NULL DEFAULT 0, + participants_count INT NOT NULL DEFAULT 0, + admins_count INT NOT NULL DEFAULT 0, + kicked_count INT NOT NULL DEFAULT 0, + banned_count INT NOT NULL DEFAULT 0, + top_message_id INT NOT NULL DEFAULT 0, + pts INT NOT NULL DEFAULT 0, + admin_log_seq BIGINT NOT NULL DEFAULT 0, + ttl_period INT NOT NULL DEFAULT 0, + date INT NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (id), + CONSTRAINT channels_kind_check CHECK ( + ((broadcast AND NOT megagroup AND NOT forum) + OR (megagroup AND NOT broadcast)) + AND (NOT forum_tabs OR forum) + ), + CONSTRAINT channels_title_nonempty_check CHECK (title <> '') +) PARTITION BY HASH (id); + +CREATE UNIQUE INDEX IF NOT EXISTS channels_access_hash_idx + ON channels (id, access_hash); +CREATE INDEX IF NOT EXISTS channels_creator_idx + ON channels (creator_user_id, id DESC) + WHERE NOT deleted; +CREATE INDEX IF NOT EXISTS channels_linked_chat_idx + ON channels (linked_chat_id) + WHERE linked_chat_id <> 0 AND NOT deleted; + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channels_p%s PARTITION OF channels FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +-- PostgreSQL global unique indexes on partitioned tables must include the partition key. +-- Keep username uniqueness in a compact lookup table instead of relying on channels(username). +CREATE TABLE IF NOT EXISTS channel_usernames ( + username_lower TEXT PRIMARY KEY, + channel_id BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT channel_usernames_nonempty_check CHECK (username_lower <> '') +); + +CREATE TABLE IF NOT EXISTS channel_members ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + inviter_user_id BIGINT NOT NULL DEFAULT 0, + role VARCHAR(16) NOT NULL DEFAULT 'member', + status VARCHAR(16) NOT NULL DEFAULT 'active', + joined_at INT NOT NULL DEFAULT 0, + left_at INT NOT NULL DEFAULT 0, + admin_rights JSONB NOT NULL DEFAULT '{}'::jsonb, + banned_rights JSONB NOT NULL DEFAULT '{}'::jsonb, + rank TEXT NOT NULL DEFAULT '', + available_min_id INT NOT NULL DEFAULT 0, + available_min_pts INT NOT NULL DEFAULT 0, + read_inbox_max_id INT NOT NULL DEFAULT 0, + read_inbox_date INT NOT NULL DEFAULT 0, + read_outbox_max_id INT NOT NULL DEFAULT 0, + unread_mark BOOLEAN NOT NULL DEFAULT false, + slowmode_last_send_date INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, user_id), + CONSTRAINT channel_members_role_check CHECK (role IN ('creator', 'admin', 'member')), + CONSTRAINT channel_members_status_check CHECK (status IN ('active', 'left', 'kicked', 'banned')) +) PARTITION BY HASH (channel_id); + +CREATE INDEX IF NOT EXISTS channel_members_user_active_idx + ON channel_members (user_id, channel_id) + WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS channel_members_user_left_idx + ON channel_members (user_id, left_at DESC, channel_id DESC) + WHERE status = 'left'; +CREATE INDEX IF NOT EXISTS channel_members_channel_role_idx + ON channel_members (channel_id, role, user_id) + WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS channel_members_read_participants_idx + ON channel_members (channel_id, read_inbox_max_id, user_id) + WHERE status = 'active'; + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_members_p%s PARTITION OF channel_members FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS channel_messages ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + id INT NOT NULL, + random_id BIGINT NOT NULL DEFAULT 0, + sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + from_peer_type VARCHAR(16) NOT NULL DEFAULT 'user', + from_peer_id BIGINT NOT NULL, + send_as_peer_type VARCHAR(16), + send_as_peer_id BIGINT, + message_date INT NOT NULL, + edit_date INT NOT NULL DEFAULT 0, + post BOOLEAN NOT NULL DEFAULT false, + silent BOOLEAN NOT NULL DEFAULT false, + noforwards BOOLEAN NOT NULL DEFAULT false, + body TEXT NOT NULL DEFAULT '', + entities JSONB NOT NULL DEFAULT '[]'::jsonb, + reply_to JSONB NOT NULL DEFAULT '{}'::jsonb, + reply_to_msg_id INT NOT NULL DEFAULT 0, + reply_to_peer_type VARCHAR(16) NOT NULL DEFAULT '', + reply_to_peer_id BIGINT NOT NULL DEFAULT 0, + reply_to_top_id INT NOT NULL DEFAULT 0, + fwd_from JSONB NOT NULL DEFAULT '{}'::jsonb, + discussion_channel_id BIGINT NOT NULL DEFAULT 0, + discussion_message_id INT NOT NULL DEFAULT 0, + action JSONB NOT NULL DEFAULT '{}'::jsonb, + pts INT NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, id), + CONSTRAINT channel_messages_peer_type_check CHECK ( + from_peer_type IN ('user', 'channel') + AND (send_as_peer_type IS NULL OR send_as_peer_type IN ('user', 'channel')) + AND (reply_to_peer_type = '' OR reply_to_peer_type IN ('user', 'channel')) + ), + CONSTRAINT channel_messages_content_check CHECK (body <> '' OR action <> '{}'::jsonb) +) PARTITION BY HASH (channel_id); + +CREATE UNIQUE INDEX IF NOT EXISTS channel_messages_random_idx + ON channel_messages (channel_id, sender_user_id, random_id) + WHERE random_id <> 0; +CREATE INDEX IF NOT EXISTS channel_messages_history_idx + ON channel_messages (channel_id, id DESC) + WHERE NOT deleted; +CREATE INDEX IF NOT EXISTS channel_messages_sender_history_idx + ON channel_messages (channel_id, sender_user_id, id DESC) + WHERE NOT deleted; +CREATE INDEX IF NOT EXISTS channel_messages_date_idx + ON channel_messages (channel_id, message_date DESC, id DESC) + WHERE NOT deleted; +CREATE INDEX IF NOT EXISTS channel_messages_reply_thread_idx + ON channel_messages (channel_id, reply_to_top_id, id DESC) + WHERE reply_to_top_id > 0 AND NOT deleted; +CREATE INDEX IF NOT EXISTS channel_messages_discussion_ref_idx + ON channel_messages (discussion_channel_id, discussion_message_id) + WHERE discussion_channel_id <> 0 AND discussion_message_id <> 0 AND NOT deleted; + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_messages_p%s PARTITION OF channel_messages FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS channel_update_events ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + pts INT NOT NULL, + pts_count INT NOT NULL DEFAULT 1, + date INT NOT NULL, + event_type VARCHAR(32) NOT NULL, + message_id INT NOT NULL DEFAULT 0, + message_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + sender_user_id BIGINT NOT NULL DEFAULT 0, + user_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, pts), + CONSTRAINT channel_update_events_pts_count_check CHECK (pts_count > 0), + CONSTRAINT channel_update_events_type_check CHECK ( + event_type IN ( + 'new_channel_message', + 'edit_channel_message', + 'delete_channel_messages', + 'channel_participant', + 'pinned_channel_messages', + 'noop' + ) + ) +) PARTITION BY HASH (channel_id); + +CREATE INDEX IF NOT EXISTS channel_update_events_scan_idx + ON channel_update_events (channel_id, pts ASC); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_update_events_p%s PARTITION OF channel_update_events FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS channel_admin_log_events ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + id BIGINT NOT NULL, + actor_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + event_date INT NOT NULL, + event_type VARCHAR(48) NOT NULL, + prev_string TEXT NOT NULL DEFAULT '', + new_string TEXT NOT NULL DEFAULT '', + prev_bool BOOLEAN NOT NULL DEFAULT false, + new_bool BOOLEAN NOT NULL DEFAULT false, + prev_int INT NOT NULL DEFAULT 0, + new_int INT NOT NULL DEFAULT 0, + prev_participant JSONB NOT NULL DEFAULT '{}'::jsonb, + new_participant JSONB NOT NULL DEFAULT '{}'::jsonb, + participant JSONB NOT NULL DEFAULT '{}'::jsonb, + message JSONB NOT NULL DEFAULT '{}'::jsonb, + prev_message JSONB NOT NULL DEFAULT '{}'::jsonb, + new_message JSONB NOT NULL DEFAULT '{}'::jsonb, + query TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, id), + CONSTRAINT channel_admin_log_events_type_check CHECK ( + event_type IN ( + 'change_title', + 'change_username', + 'change_linked_chat', + 'toggle_signatures', + 'toggle_pre_history_hidden', + 'toggle_forum', + 'toggle_anti_spam', + 'toggle_slow_mode', + 'participant_invite', + 'participant_join', + 'participant_leave', + 'participant_promote', + 'participant_demote', + 'participant_ban', + 'participant_unban', + 'participant_kick', + 'participant_unkick', + 'update_pinned', + 'send_message', + 'edit_message', + 'delete_message' + ) + ) +) PARTITION BY HASH (channel_id); + +CREATE INDEX IF NOT EXISTS channel_admin_log_events_scan_idx + ON channel_admin_log_events (channel_id, id DESC); +CREATE INDEX IF NOT EXISTS channel_admin_log_events_actor_idx + ON channel_admin_log_events (channel_id, actor_user_id, id DESC); +CREATE INDEX IF NOT EXISTS channel_admin_log_events_type_idx + ON channel_admin_log_events (channel_id, event_type, id DESC); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_admin_log_events_p%s PARTITION OF channel_admin_log_events FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS channel_dialogs ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + folder_id INT NOT NULL DEFAULT 0, + top_message_id INT NOT NULL DEFAULT 0, + top_message_date INT NOT NULL DEFAULT 0, + read_inbox_max_id INT NOT NULL DEFAULT 0, + read_outbox_max_id INT NOT NULL DEFAULT 0, + unread_count INT NOT NULL DEFAULT 0, + unread_mentions_count INT NOT NULL DEFAULT 0, + unread_reactions_count INT NOT NULL DEFAULT 0, + pinned BOOLEAN NOT NULL DEFAULT false, + pinned_order INT NOT NULL DEFAULT 0, + unread_mark BOOLEAN NOT NULL DEFAULT false, + view_forum_as_messages BOOLEAN NOT NULL DEFAULT false, + notify_settings JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id), + CONSTRAINT channel_dialogs_folder_id_check CHECK (folder_id >= 0) +) PARTITION BY HASH (user_id); + +CREATE INDEX IF NOT EXISTS channel_dialogs_user_top_idx + ON channel_dialogs (user_id, folder_id, pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC); +CREATE INDEX IF NOT EXISTS channel_dialogs_pinned_idx + ON channel_dialogs (user_id, pinned) + WHERE pinned; + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_dialogs_p%s PARTITION OF channel_dialogs FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; + +CREATE TABLE IF NOT EXISTS channel_invites ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + invite_id BIGINT NOT NULL, + hash TEXT NOT NULL, + admin_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + title TEXT NOT NULL DEFAULT '', + permanent BOOLEAN NOT NULL DEFAULT false, + revoked BOOLEAN NOT NULL DEFAULT false, + request_needed BOOLEAN NOT NULL DEFAULT false, + expire_date INT, + usage_limit INT, + usage_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, invite_id) +) PARTITION BY HASH (channel_id); + +-- Keep invite hash uniqueness outside the partitioned table because PostgreSQL +-- requires global unique indexes on partitions to include the partition key. +CREATE TABLE IF NOT EXISTS channel_invite_hashes ( + hash TEXT PRIMARY KEY, + channel_id BIGINT NOT NULL, + invite_id BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT channel_invite_hashes_nonempty_check CHECK (hash <> '') +); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_invites_p%s PARTITION OF channel_invites FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; diff --git a/deploy/migrations/0023_channel_admin_invites.down.sql b/deploy/migrations/0023_channel_admin_invites.down.sql new file mode 100644 index 00000000..78a09dda --- /dev/null +++ b/deploy/migrations/0023_channel_admin_invites.down.sql @@ -0,0 +1,18 @@ +DROP INDEX IF EXISTS channel_invites_hash_lookup_idx; + +ALTER TABLE channel_update_events + DROP CONSTRAINT IF EXISTS channel_update_events_type_check; + +ALTER TABLE channel_update_events + ADD CONSTRAINT channel_update_events_type_check CHECK ( + event_type IN ( + 'new_channel_message', + 'edit_channel_message', + 'delete_channel_messages', + 'channel_participant', + 'noop' + ) + ); + +ALTER TABLE channels + DROP COLUMN IF EXISTS pinned_message_id; diff --git a/deploy/migrations/0023_channel_admin_invites.up.sql b/deploy/migrations/0023_channel_admin_invites.up.sql new file mode 100644 index 00000000..94015352 --- /dev/null +++ b/deploy/migrations/0023_channel_admin_invites.up.sql @@ -0,0 +1,22 @@ +-- 0023_channel_admin_invites: metadata needed by channel admin/pin/invite RPCs. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS pinned_message_id INT NOT NULL DEFAULT 0; + +ALTER TABLE channel_update_events + DROP CONSTRAINT IF EXISTS channel_update_events_type_check; + +ALTER TABLE channel_update_events + ADD CONSTRAINT channel_update_events_type_check CHECK ( + event_type IN ( + 'new_channel_message', + 'edit_channel_message', + 'delete_channel_messages', + 'channel_participant', + 'pinned_channel_messages', + 'noop' + ) + ); + +CREATE INDEX IF NOT EXISTS channel_invites_hash_lookup_idx + ON channel_invite_hashes (hash, channel_id, invite_id); diff --git a/deploy/migrations/0024_channel_admin_log_backfill.down.sql b/deploy/migrations/0024_channel_admin_log_backfill.down.sql new file mode 100644 index 00000000..31800bf0 --- /dev/null +++ b/deploy/migrations/0024_channel_admin_log_backfill.down.sql @@ -0,0 +1,6 @@ +-- No-op by design. +-- +-- These columns/table are part of the fresh 0022 channel schema. This migration +-- only backfills older developer databases that had already applied an earlier +-- 0022 draft, so dropping the objects on a one-step rollback would corrupt fresh +-- schemas where 0022 legitimately owns them. diff --git a/deploy/migrations/0024_channel_admin_log_backfill.up.sql b/deploy/migrations/0024_channel_admin_log_backfill.up.sql new file mode 100644 index 00000000..518b0d55 --- /dev/null +++ b/deploy/migrations/0024_channel_admin_log_backfill.up.sql @@ -0,0 +1,78 @@ +-- 0024_channel_admin_log_backfill: bring existing developer DBs forward after +-- channel settings/admin-log fields were added to the initial 0022 draft. +-- +-- Fresh databases already get these objects from 0022; every statement here is +-- idempotent so old local databases can migrate without reset. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS pre_history_hidden BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS slowmode_seconds INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS admin_log_seq BIGINT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS channel_admin_log_events ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + id BIGINT NOT NULL, + actor_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + event_date INT NOT NULL, + event_type VARCHAR(48) NOT NULL, + prev_string TEXT NOT NULL DEFAULT '', + new_string TEXT NOT NULL DEFAULT '', + prev_bool BOOLEAN NOT NULL DEFAULT false, + new_bool BOOLEAN NOT NULL DEFAULT false, + prev_int INT NOT NULL DEFAULT 0, + new_int INT NOT NULL DEFAULT 0, + prev_participant JSONB NOT NULL DEFAULT '{}'::jsonb, + new_participant JSONB NOT NULL DEFAULT '{}'::jsonb, + participant JSONB NOT NULL DEFAULT '{}'::jsonb, + message JSONB NOT NULL DEFAULT '{}'::jsonb, + prev_message JSONB NOT NULL DEFAULT '{}'::jsonb, + new_message JSONB NOT NULL DEFAULT '{}'::jsonb, + query TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, id), + CONSTRAINT channel_admin_log_events_type_check CHECK ( + event_type IN ( + 'change_title', + 'change_username', + 'change_linked_chat', + 'toggle_signatures', + 'toggle_pre_history_hidden', + 'toggle_forum', + 'toggle_anti_spam', + 'toggle_slow_mode', + 'participant_invite', + 'participant_join', + 'participant_leave', + 'participant_promote', + 'participant_demote', + 'participant_ban', + 'participant_unban', + 'participant_kick', + 'participant_unkick', + 'update_pinned', + 'send_message', + 'edit_message', + 'delete_message' + ) + ) +) PARTITION BY HASH (channel_id); + +CREATE INDEX IF NOT EXISTS channel_admin_log_events_scan_idx + ON channel_admin_log_events (channel_id, id DESC); +CREATE INDEX IF NOT EXISTS channel_admin_log_events_actor_idx + ON channel_admin_log_events (channel_id, actor_user_id, id DESC); +CREATE INDEX IF NOT EXISTS channel_admin_log_events_type_idx + ON channel_admin_log_events (channel_id, event_type, id DESC); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_admin_log_events_p%s PARTITION OF channel_admin_log_events FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; diff --git a/deploy/migrations/0025_channel_member_slowmode_backfill.down.sql b/deploy/migrations/0025_channel_member_slowmode_backfill.down.sql new file mode 100644 index 00000000..d641134d --- /dev/null +++ b/deploy/migrations/0025_channel_member_slowmode_backfill.down.sql @@ -0,0 +1,4 @@ +-- No-op by design. +-- +-- Fresh databases own this column from 0022; this migration only repairs older +-- developer databases that had already applied an earlier 0022 draft. diff --git a/deploy/migrations/0025_channel_member_slowmode_backfill.up.sql b/deploy/migrations/0025_channel_member_slowmode_backfill.up.sql new file mode 100644 index 00000000..aa6090c7 --- /dev/null +++ b/deploy/migrations/0025_channel_member_slowmode_backfill.up.sql @@ -0,0 +1,5 @@ +-- 0025_channel_member_slowmode_backfill: idempotent compatibility migration for +-- developer databases that applied an earlier 0022 channel schema draft. + +ALTER TABLE channel_members + ADD COLUMN IF NOT EXISTS slowmode_last_send_date INT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0026_channel_read_participants.down.sql b/deploy/migrations/0026_channel_read_participants.down.sql new file mode 100644 index 00000000..7dcca018 --- /dev/null +++ b/deploy/migrations/0026_channel_read_participants.down.sql @@ -0,0 +1 @@ +-- No-op: read receipt dates and TDesktop app config keys are forward-compatible. diff --git a/deploy/migrations/0026_channel_read_participants.up.sql b/deploy/migrations/0026_channel_read_participants.up.sql new file mode 100644 index 00000000..3a8f691c --- /dev/null +++ b/deploy/migrations/0026_channel_read_participants.up.sql @@ -0,0 +1,19 @@ +-- 0026_channel_read_participants: store channel read receipt dates and expose TDesktop read mark config. + +ALTER TABLE channel_members + ADD COLUMN IF NOT EXISTS read_inbox_date INT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS channel_members_read_participants_idx + ON channel_members (channel_id, read_inbox_max_id, user_id) + WHERE status = 'active'; + +UPDATE app_configs +SET hash = 2, + config_json = config_json + || jsonb_build_object( + 'chat_read_mark_size_threshold', 50, + 'chat_read_mark_expire_period', 604800, + 'pm_read_date_expire_period', 604800 + ), + updated_at = now() +WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0027_channel_message_search_indexes.down.sql b/deploy/migrations/0027_channel_message_search_indexes.down.sql new file mode 100644 index 00000000..86ef5802 --- /dev/null +++ b/deploy/migrations/0027_channel_message_search_indexes.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS channel_messages_body_trgm_idx; diff --git a/deploy/migrations/0027_channel_message_search_indexes.up.sql b/deploy/migrations/0027_channel_message_search_indexes.up.sql new file mode 100644 index 00000000..b0312902 --- /dev/null +++ b/deploy/migrations/0027_channel_message_search_indexes.up.sql @@ -0,0 +1,10 @@ +-- 0027_channel_message_search_indexes: bounded in-channel text search. +-- +-- TDesktop uses messages.search with inputPeerChannel for in-chat search. +-- Keep text lookup indexed on the single-copy channel_messages table. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS channel_messages_body_trgm_idx + ON channel_messages USING gin (body gin_trgm_ops) + WHERE NOT deleted AND body <> ''; diff --git a/deploy/migrations/0028_channel_noforwards_backfill.down.sql b/deploy/migrations/0028_channel_noforwards_backfill.down.sql new file mode 100644 index 00000000..d641134d --- /dev/null +++ b/deploy/migrations/0028_channel_noforwards_backfill.down.sql @@ -0,0 +1,4 @@ +-- No-op by design. +-- +-- Fresh databases own this column from 0022; this migration only repairs older +-- developer databases that had already applied an earlier 0022 draft. diff --git a/deploy/migrations/0028_channel_noforwards_backfill.up.sql b/deploy/migrations/0028_channel_noforwards_backfill.up.sql new file mode 100644 index 00000000..aa6ef0a5 --- /dev/null +++ b/deploy/migrations/0028_channel_noforwards_backfill.up.sql @@ -0,0 +1,7 @@ +-- 0028_channel_noforwards_backfill: ensure older developer databases have +-- the channel content-protection flag used by messages.toggleNoForwards. +-- +-- Fresh databases already get this column from 0022. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS noforwards BOOLEAN NOT NULL DEFAULT false; diff --git a/deploy/migrations/0029_channel_available_reactions.down.sql b/deploy/migrations/0029_channel_available_reactions.down.sql new file mode 100644 index 00000000..d641134d --- /dev/null +++ b/deploy/migrations/0029_channel_available_reactions.down.sql @@ -0,0 +1,4 @@ +-- No-op by design. +-- +-- Fresh databases own this column from 0022; this migration only repairs older +-- developer databases that had already applied an earlier 0022 draft. diff --git a/deploy/migrations/0029_channel_available_reactions.up.sql b/deploy/migrations/0029_channel_available_reactions.up.sql new file mode 100644 index 00000000..5a5b28a8 --- /dev/null +++ b/deploy/migrations/0029_channel_available_reactions.up.sql @@ -0,0 +1,7 @@ +-- 0029_channel_available_reactions: persisted reaction policy for +-- messages.setChatAvailableReactions and channels.getFullChannel. +-- +-- Fresh databases already get this column from 0022. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS available_reactions JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/deploy/migrations/0030_user_update_channel_peers.down.sql b/deploy/migrations/0030_user_update_channel_peers.down.sql new file mode 100644 index 00000000..61412b62 --- /dev/null +++ b/deploy/migrations/0030_user_update_channel_peers.down.sql @@ -0,0 +1,8 @@ +-- 0030_user_update_channel_peers rollback. + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_peer_type_check + CHECK (peer_type IS NULL OR peer_type IN ('user')); diff --git a/deploy/migrations/0030_user_update_channel_peers.up.sql b/deploy/migrations/0030_user_update_channel_peers.up.sql new file mode 100644 index 00000000..7deb58e8 --- /dev/null +++ b/deploy/migrations/0030_user_update_channel_peers.up.sql @@ -0,0 +1,8 @@ +-- 0030_user_update_channel_peers: allow account-level update events to reference channel peers. + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_peer_type_check + CHECK (peer_type IS NULL OR peer_type IN ('user', 'channel')); diff --git a/deploy/migrations/0031_channel_available_min_pts.down.sql b/deploy/migrations/0031_channel_available_min_pts.down.sql new file mode 100644 index 00000000..7e2abdc2 --- /dev/null +++ b/deploy/migrations/0031_channel_available_min_pts.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE channel_members + DROP COLUMN IF EXISTS available_min_pts; diff --git a/deploy/migrations/0031_channel_available_min_pts.up.sql b/deploy/migrations/0031_channel_available_min_pts.up.sql new file mode 100644 index 00000000..7842ecde --- /dev/null +++ b/deploy/migrations/0031_channel_available_min_pts.up.sql @@ -0,0 +1,4 @@ +-- 0031_channel_available_min_pts: channelDifference visibility floor per member. + +ALTER TABLE channel_members + ADD COLUMN IF NOT EXISTS available_min_pts INT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0032_user_update_channel_available.down.sql b/deploy/migrations/0032_user_update_channel_available.down.sql new file mode 100644 index 00000000..45eee6de --- /dev/null +++ b/deploy/migrations/0032_user_update_channel_available.down.sql @@ -0,0 +1,23 @@ +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'read_history_outbox', + 'edit_message', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'dialog_filter', + 'dialog_filter_order', + 'dialog_filters', + 'folder_peers', + 'noop' + ) + ); diff --git a/deploy/migrations/0032_user_update_channel_available.up.sql b/deploy/migrations/0032_user_update_channel_available.up.sql new file mode 100644 index 00000000..5f7ab776 --- /dev/null +++ b/deploy/migrations/0032_user_update_channel_available.up.sql @@ -0,0 +1,26 @@ +-- 0032_user_update_channel_available: durable account update for channel local clear. + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'read_history_outbox', + 'edit_message', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'dialog_filter', + 'dialog_filter_order', + 'dialog_filters', + 'folder_peers', + 'channel_available_messages', + 'noop' + ) + ); diff --git a/deploy/migrations/0033_app_config_quote_length.down.sql b/deploy/migrations/0033_app_config_quote_length.down.sql new file mode 100644 index 00000000..1ebccd74 --- /dev/null +++ b/deploy/migrations/0033_app_config_quote_length.down.sql @@ -0,0 +1,7 @@ +-- 0033_app_config_quote_length rollback. + +UPDATE app_configs +SET hash = 2, + config_json = config_json - 'quote_length_max', + updated_at = now() +WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0033_app_config_quote_length.up.sql b/deploy/migrations/0033_app_config_quote_length.up.sql new file mode 100644 index 00000000..5e5ff21d --- /dev/null +++ b/deploy/migrations/0033_app_config_quote_length.up.sql @@ -0,0 +1,7 @@ +-- 0033_app_config_quote_length: expose TDesktop quote reply length limit explicitly. + +UPDATE app_configs +SET hash = 3, + config_json = config_json || jsonb_build_object('quote_length_max', 1024), + updated_at = now() +WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0034_channel_invite_importers.down.sql b/deploy/migrations/0034_channel_invite_importers.down.sql new file mode 100644 index 00000000..c864dfb8 --- /dev/null +++ b/deploy/migrations/0034_channel_invite_importers.down.sql @@ -0,0 +1,6 @@ +-- 0034_channel_invite_importers rollback. + +DROP TABLE IF EXISTS channel_invite_importers; + +ALTER TABLE channel_invites + DROP COLUMN IF EXISTS requested_count; diff --git a/deploy/migrations/0034_channel_invite_importers.up.sql b/deploy/migrations/0034_channel_invite_importers.up.sql new file mode 100644 index 00000000..8cb02fb7 --- /dev/null +++ b/deploy/migrations/0034_channel_invite_importers.up.sql @@ -0,0 +1,37 @@ +-- 0034_channel_invite_importers: invite management read model. + +ALTER TABLE channel_invites + ADD COLUMN IF NOT EXISTS requested_count INT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS channel_invite_importers ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + invite_id BIGINT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date INT NOT NULL, + requested BOOLEAN NOT NULL DEFAULT false, + approved_by BIGINT NOT NULL DEFAULT 0, + via_chatlist BOOLEAN NOT NULL DEFAULT false, + about TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, user_id) +) PARTITION BY HASH (channel_id); + +CREATE INDEX IF NOT EXISTS channel_invite_importers_link_idx + ON channel_invite_importers (channel_id, invite_id, requested, date DESC, user_id DESC); + +CREATE INDEX IF NOT EXISTS channel_invite_importers_requested_idx + ON channel_invite_importers (channel_id, requested, date DESC, user_id DESC); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_invite_importers_p%s PARTITION OF channel_invite_importers FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; diff --git a/deploy/migrations/0035_channel_join_settings.down.sql b/deploy/migrations/0035_channel_join_settings.down.sql new file mode 100644 index 00000000..9e1cb171 --- /dev/null +++ b/deploy/migrations/0035_channel_join_settings.down.sql @@ -0,0 +1,5 @@ +-- 0035_channel_join_settings rollback. + +ALTER TABLE channels + DROP COLUMN IF EXISTS join_request, + DROP COLUMN IF EXISTS join_to_send; diff --git a/deploy/migrations/0035_channel_join_settings.up.sql b/deploy/migrations/0035_channel_join_settings.up.sql new file mode 100644 index 00000000..fbcfbdc0 --- /dev/null +++ b/deploy/migrations/0035_channel_join_settings.up.sql @@ -0,0 +1,5 @@ +-- 0035_channel_join_settings: public join/send gating flags. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS join_to_send BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS join_request BOOLEAN NOT NULL DEFAULT false; diff --git a/deploy/migrations/0036_channel_discussion_links.down.sql b/deploy/migrations/0036_channel_discussion_links.down.sql new file mode 100644 index 00000000..25e8cc55 --- /dev/null +++ b/deploy/migrations/0036_channel_discussion_links.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS channels_linked_chat_idx; + +ALTER TABLE channels + DROP COLUMN IF EXISTS linked_chat_id; diff --git a/deploy/migrations/0036_channel_discussion_links.up.sql b/deploy/migrations/0036_channel_discussion_links.up.sql new file mode 100644 index 00000000..6f02fa8a --- /dev/null +++ b/deploy/migrations/0036_channel_discussion_links.up.sql @@ -0,0 +1,8 @@ +-- 0036_channel_discussion_links: persist bidirectional broadcast <-> discussion group links. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS linked_chat_id BIGINT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS channels_linked_chat_idx + ON channels (linked_chat_id) + WHERE linked_chat_id <> 0 AND NOT deleted; diff --git a/deploy/migrations/0037_channel_message_threads.down.sql b/deploy/migrations/0037_channel_message_threads.down.sql new file mode 100644 index 00000000..05aa8668 --- /dev/null +++ b/deploy/migrations/0037_channel_message_threads.down.sql @@ -0,0 +1,10 @@ +DROP INDEX IF EXISTS channel_messages_discussion_ref_idx; +DROP INDEX IF EXISTS channel_messages_reply_thread_idx; + +ALTER TABLE channel_messages + DROP COLUMN IF EXISTS discussion_message_id, + DROP COLUMN IF EXISTS discussion_channel_id, + DROP COLUMN IF EXISTS reply_to_top_id, + DROP COLUMN IF EXISTS reply_to_peer_id, + DROP COLUMN IF EXISTS reply_to_peer_type, + DROP COLUMN IF EXISTS reply_to_msg_id; diff --git a/deploy/migrations/0037_channel_message_threads.up.sql b/deploy/migrations/0037_channel_message_threads.up.sql new file mode 100644 index 00000000..23c390f2 --- /dev/null +++ b/deploy/migrations/0037_channel_message_threads.up.sql @@ -0,0 +1,36 @@ +ALTER TABLE channel_messages + ADD COLUMN IF NOT EXISTS reply_to_msg_id INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reply_to_peer_type VARCHAR(16) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS reply_to_peer_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reply_to_top_id INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS discussion_channel_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS discussion_message_id INT NOT NULL DEFAULT 0; + +UPDATE channel_messages +SET + reply_to_msg_id = CASE + WHEN (reply_to ->> 'MessageID') ~ '^[0-9]+$' THEN (reply_to ->> 'MessageID')::INT + ELSE reply_to_msg_id + END, + reply_to_peer_type = COALESCE(NULLIF(reply_to #>> '{Peer,Type}', ''), reply_to_peer_type), + reply_to_peer_id = CASE + WHEN (reply_to #>> '{Peer,ID}') ~ '^[0-9]+$' THEN (reply_to #>> '{Peer,ID}')::BIGINT + ELSE reply_to_peer_id + END, + reply_to_top_id = CASE + WHEN (reply_to ->> 'TopMessageID') ~ '^[0-9]+$' AND (reply_to ->> 'TopMessageID')::INT > 0 + THEN (reply_to ->> 'TopMessageID')::INT + WHEN (reply_to ->> 'MessageID') ~ '^[0-9]+$' + THEN (reply_to ->> 'MessageID')::INT + ELSE reply_to_top_id + END +WHERE reply_to <> '{}'::jsonb + AND reply_to_msg_id = 0; + +CREATE INDEX IF NOT EXISTS channel_messages_reply_thread_idx + ON channel_messages (channel_id, reply_to_top_id, id DESC) + WHERE reply_to_top_id > 0 AND NOT deleted; + +CREATE INDEX IF NOT EXISTS channel_messages_discussion_ref_idx + ON channel_messages (discussion_channel_id, discussion_message_id) + WHERE discussion_channel_id <> 0 AND discussion_message_id <> 0 AND NOT deleted; diff --git a/deploy/migrations/0038_channel_default_send_as.down.sql b/deploy/migrations/0038_channel_default_send_as.down.sql new file mode 100644 index 00000000..108a07f5 --- /dev/null +++ b/deploy/migrations/0038_channel_default_send_as.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE channel_dialogs + DROP CONSTRAINT IF EXISTS channel_dialogs_default_send_as_peer_type_check; + +ALTER TABLE channel_dialogs + DROP COLUMN IF EXISTS default_send_as_peer_id, + DROP COLUMN IF EXISTS default_send_as_peer_type; diff --git a/deploy/migrations/0038_channel_default_send_as.up.sql b/deploy/migrations/0038_channel_default_send_as.up.sql new file mode 100644 index 00000000..77d01deb --- /dev/null +++ b/deploy/migrations/0038_channel_default_send_as.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE channel_dialogs + ADD COLUMN IF NOT EXISTS default_send_as_peer_type VARCHAR(16), + ADD COLUMN IF NOT EXISTS default_send_as_peer_id BIGINT; + +ALTER TABLE channel_dialogs + DROP CONSTRAINT IF EXISTS channel_dialogs_default_send_as_peer_type_check; + +ALTER TABLE channel_dialogs + ADD CONSTRAINT channel_dialogs_default_send_as_peer_type_check + CHECK ( + default_send_as_peer_type IS NULL + OR default_send_as_peer_type IN ('user', 'channel') + ); diff --git a/deploy/migrations/0039_channel_message_views.down.sql b/deploy/migrations/0039_channel_message_views.down.sql new file mode 100644 index 00000000..5d193c4a --- /dev/null +++ b/deploy/migrations/0039_channel_message_views.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS channel_message_viewers CASCADE; + +ALTER TABLE channel_messages + DROP COLUMN IF EXISTS views_count; diff --git a/deploy/migrations/0039_channel_message_views.up.sql b/deploy/migrations/0039_channel_message_views.up.sql new file mode 100644 index 00000000..70ec3a90 --- /dev/null +++ b/deploy/migrations/0039_channel_message_views.up.sql @@ -0,0 +1,27 @@ +ALTER TABLE channel_messages + ADD COLUMN IF NOT EXISTS views_count INT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS channel_message_viewers ( + channel_id BIGINT NOT NULL, + message_id INT NOT NULL, + viewer_user_id BIGINT NOT NULL, + viewed_at INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, message_id, viewer_user_id), + CONSTRAINT channel_message_viewers_positive_check CHECK ( + channel_id > 0 AND message_id > 0 AND viewer_user_id > 0 + ) +) PARTITION BY HASH (channel_id); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_message_viewers_p%s PARTITION OF channel_message_viewers FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; diff --git a/deploy/migrations/0040_channel_unread_mentions.down.sql b/deploy/migrations/0040_channel_unread_mentions.down.sql new file mode 100644 index 00000000..0c401e55 --- /dev/null +++ b/deploy/migrations/0040_channel_unread_mentions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS channel_unread_mentions CASCADE; diff --git a/deploy/migrations/0040_channel_unread_mentions.up.sql b/deploy/migrations/0040_channel_unread_mentions.up.sql new file mode 100644 index 00000000..0a02b19f --- /dev/null +++ b/deploy/migrations/0040_channel_unread_mentions.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS channel_unread_mentions ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + message_id INT NOT NULL, + top_message_id INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id, message_id), + FOREIGN KEY (channel_id, message_id) REFERENCES channel_messages(channel_id, id) ON DELETE CASCADE +) PARTITION BY HASH (user_id); + +CREATE INDEX IF NOT EXISTS channel_unread_mentions_peer_idx + ON channel_unread_mentions (user_id, channel_id, top_message_id, message_id DESC); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_unread_mentions_p%s PARTITION OF channel_unread_mentions FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; diff --git a/deploy/migrations/0041_channel_participants_hidden.down.sql b/deploy/migrations/0041_channel_participants_hidden.down.sql new file mode 100644 index 00000000..2e10778e --- /dev/null +++ b/deploy/migrations/0041_channel_participants_hidden.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE channels + DROP COLUMN IF EXISTS participants_hidden; diff --git a/deploy/migrations/0041_channel_participants_hidden.up.sql b/deploy/migrations/0041_channel_participants_hidden.up.sql new file mode 100644 index 00000000..22858be1 --- /dev/null +++ b/deploy/migrations/0041_channel_participants_hidden.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS participants_hidden BOOLEAN NOT NULL DEFAULT false; diff --git a/deploy/migrations/0042_channel_colors.down.sql b/deploy/migrations/0042_channel_colors.down.sql new file mode 100644 index 00000000..456a973e --- /dev/null +++ b/deploy/migrations/0042_channel_colors.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE channels + DROP COLUMN IF EXISTS emoji_status_until, + DROP COLUMN IF EXISTS emoji_status_document_id, + DROP COLUMN IF EXISTS profile_color_background_emoji_id, + DROP COLUMN IF EXISTS profile_color, + DROP COLUMN IF EXISTS profile_color_set, + DROP COLUMN IF EXISTS color_background_emoji_id, + DROP COLUMN IF EXISTS color, + DROP COLUMN IF EXISTS color_set; diff --git a/deploy/migrations/0042_channel_colors.up.sql b/deploy/migrations/0042_channel_colors.up.sql new file mode 100644 index 00000000..f13d108c --- /dev/null +++ b/deploy/migrations/0042_channel_colors.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS color_set BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS color INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS color_background_emoji_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS profile_color_set BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS profile_color INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS profile_color_background_emoji_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS emoji_status_document_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS emoji_status_until INT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0043_channel_view_forum_as_messages.down.sql b/deploy/migrations/0043_channel_view_forum_as_messages.down.sql new file mode 100644 index 00000000..947b57d1 --- /dev/null +++ b/deploy/migrations/0043_channel_view_forum_as_messages.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE channel_dialogs + DROP COLUMN IF EXISTS view_forum_as_messages; diff --git a/deploy/migrations/0043_channel_view_forum_as_messages.up.sql b/deploy/migrations/0043_channel_view_forum_as_messages.up.sql new file mode 100644 index 00000000..51af5676 --- /dev/null +++ b/deploy/migrations/0043_channel_view_forum_as_messages.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE channel_dialogs + ADD COLUMN IF NOT EXISTS view_forum_as_messages BOOLEAN NOT NULL DEFAULT false; diff --git a/deploy/migrations/0044_channel_antispam.down.sql b/deploy/migrations/0044_channel_antispam.down.sql new file mode 100644 index 00000000..597db78e --- /dev/null +++ b/deploy/migrations/0044_channel_antispam.down.sql @@ -0,0 +1,40 @@ +-- 0044_channel_antispam rollback. + +UPDATE app_configs +SET hash = 3, + config_json = config_json + - 'telegram_antispam_group_size_min' + - 'telegram_antispam_user_id', + updated_at = now() +WHERE client = 'tdesktop' AND hash = 4; + +ALTER TABLE channel_admin_log_events + DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; + +ALTER TABLE channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( + event_type IN ( + 'change_title', + 'change_username', + 'change_linked_chat', + 'toggle_signatures', + 'toggle_pre_history_hidden', + 'toggle_slow_mode', + 'participant_invite', + 'participant_join', + 'participant_leave', + 'participant_promote', + 'participant_demote', + 'participant_ban', + 'participant_unban', + 'participant_kick', + 'participant_unkick', + 'update_pinned', + 'send_message', + 'edit_message', + 'delete_message' + ) + ); + +ALTER TABLE channels + DROP COLUMN IF EXISTS antispam; diff --git a/deploy/migrations/0044_channel_antispam.up.sql b/deploy/migrations/0044_channel_antispam.up.sql new file mode 100644 index 00000000..2b7882ea --- /dev/null +++ b/deploy/migrations/0044_channel_antispam.up.sql @@ -0,0 +1,44 @@ +-- 0044_channel_antispam: persist native antispam state and expose TDesktop config. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS antispam BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE channel_admin_log_events + DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; + +ALTER TABLE channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( + event_type IN ( + 'change_title', + 'change_username', + 'change_linked_chat', + 'toggle_signatures', + 'toggle_pre_history_hidden', + 'toggle_forum', + 'toggle_anti_spam', + 'toggle_slow_mode', + 'participant_invite', + 'participant_join', + 'participant_leave', + 'participant_promote', + 'participant_demote', + 'participant_ban', + 'participant_unban', + 'participant_kick', + 'participant_unkick', + 'update_pinned', + 'send_message', + 'edit_message', + 'delete_message' + ) + ); + +UPDATE app_configs +SET hash = 4, + config_json = config_json + || jsonb_build_object( + 'telegram_antispam_group_size_min', 200, + 'telegram_antispam_user_id', '5434988373' + ), + updated_at = now() +WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0045_channel_forum_tabs.down.sql b/deploy/migrations/0045_channel_forum_tabs.down.sql new file mode 100644 index 00000000..a04199fe --- /dev/null +++ b/deploy/migrations/0045_channel_forum_tabs.down.sql @@ -0,0 +1,45 @@ +-- 0045_channel_forum_tabs rollback. + +ALTER TABLE channel_admin_log_events + DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; + +DELETE FROM channel_admin_log_events +WHERE event_type = 'toggle_forum'; + +ALTER TABLE channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( + event_type IN ( + 'change_title', + 'change_username', + 'change_linked_chat', + 'toggle_signatures', + 'toggle_pre_history_hidden', + 'toggle_anti_spam', + 'toggle_slow_mode', + 'participant_invite', + 'participant_join', + 'participant_leave', + 'participant_promote', + 'participant_demote', + 'participant_ban', + 'participant_unban', + 'participant_kick', + 'participant_unkick', + 'update_pinned', + 'send_message', + 'edit_message', + 'delete_message' + ) + ); + +ALTER TABLE channels + DROP CONSTRAINT IF EXISTS channels_kind_check; + +ALTER TABLE channels + ADD CONSTRAINT channels_kind_check CHECK ( + (broadcast AND NOT megagroup AND NOT forum) + OR (megagroup AND NOT broadcast) + ); + +ALTER TABLE channels + DROP COLUMN IF EXISTS forum_tabs; diff --git a/deploy/migrations/0045_channel_forum_tabs.up.sql b/deploy/migrations/0045_channel_forum_tabs.up.sql new file mode 100644 index 00000000..62600fa9 --- /dev/null +++ b/deploy/migrations/0045_channel_forum_tabs.up.sql @@ -0,0 +1,48 @@ +-- 0045_channel_forum_tabs: persist Layer 225 forum layout and admin-log action. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS forum_tabs BOOLEAN NOT NULL DEFAULT false; + +UPDATE channels +SET forum_tabs = false +WHERE NOT forum AND forum_tabs; + +ALTER TABLE channels + DROP CONSTRAINT IF EXISTS channels_kind_check; + +ALTER TABLE channels + ADD CONSTRAINT channels_kind_check CHECK ( + ((broadcast AND NOT megagroup AND NOT forum) + OR (megagroup AND NOT broadcast)) + AND (NOT forum_tabs OR forum) + ); + +ALTER TABLE channel_admin_log_events + DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; + +ALTER TABLE channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( + event_type IN ( + 'change_title', + 'change_username', + 'change_linked_chat', + 'toggle_signatures', + 'toggle_pre_history_hidden', + 'toggle_forum', + 'toggle_anti_spam', + 'toggle_slow_mode', + 'participant_invite', + 'participant_join', + 'participant_leave', + 'participant_promote', + 'participant_demote', + 'participant_ban', + 'participant_unban', + 'participant_kick', + 'participant_unkick', + 'update_pinned', + 'send_message', + 'edit_message', + 'delete_message' + ) + ); diff --git a/deploy/migrations/0046_channel_forum_topics.down.sql b/deploy/migrations/0046_channel_forum_topics.down.sql new file mode 100644 index 00000000..975ceba4 --- /dev/null +++ b/deploy/migrations/0046_channel_forum_topics.down.sql @@ -0,0 +1,3 @@ +-- 0046_channel_forum_topics rollback. + +DROP TABLE IF EXISTS channel_forum_topics CASCADE; diff --git a/deploy/migrations/0046_channel_forum_topics.up.sql b/deploy/migrations/0046_channel_forum_topics.up.sql new file mode 100644 index 00000000..122b2dc9 --- /dev/null +++ b/deploy/migrations/0046_channel_forum_topics.up.sql @@ -0,0 +1,55 @@ +-- 0046_channel_forum_topics: forum topic read model. +-- +-- Topic ids are the channel service message ids produced by +-- messages.createForumTopic. Messages stay single-copy in channel_messages; +-- this table stores the bounded topic index/read counters TDesktop needs for +-- messages.getForumTopics/getForumTopicsByID. + +CREATE TABLE IF NOT EXISTS channel_forum_topics ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + topic_id INT NOT NULL, + creator_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + title TEXT NOT NULL DEFAULT '', + icon_color INT NOT NULL DEFAULT 0, + icon_emoji_id BIGINT NOT NULL DEFAULT 0, + title_missing BOOLEAN NOT NULL DEFAULT false, + closed BOOLEAN NOT NULL DEFAULT false, + hidden BOOLEAN NOT NULL DEFAULT false, + pinned BOOLEAN NOT NULL DEFAULT false, + pinned_order INT NOT NULL DEFAULT 0, + date INT NOT NULL, + top_message_id INT NOT NULL DEFAULT 0, + read_inbox_max_id INT NOT NULL DEFAULT 0, + read_outbox_max_id INT NOT NULL DEFAULT 0, + unread_count INT NOT NULL DEFAULT 0, + unread_mentions_count INT NOT NULL DEFAULT 0, + unread_reactions_count INT NOT NULL DEFAULT 0, + unread_poll_votes_count INT NOT NULL DEFAULT 0, + deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, topic_id), + CONSTRAINT channel_forum_topics_title_check CHECK (title <> '' OR title_missing), + CONSTRAINT channel_forum_topics_ids_check CHECK (topic_id > 0 AND top_message_id >= 0) +) PARTITION BY HASH (channel_id); + +CREATE INDEX IF NOT EXISTS channel_forum_topics_page_idx + ON channel_forum_topics (channel_id, pinned DESC, pinned_order DESC, date DESC, topic_id DESC) + WHERE NOT deleted; + +CREATE INDEX IF NOT EXISTS channel_forum_topics_title_idx + ON channel_forum_topics (channel_id, lower(title), date DESC, topic_id DESC) + WHERE NOT deleted; + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_forum_topics_p%s PARTITION OF channel_forum_topics FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; diff --git a/deploy/migrations/0047_dialog_drafts.down.sql b/deploy/migrations/0047_dialog_drafts.down.sql new file mode 100644 index 00000000..7d0cf407 --- /dev/null +++ b/deploy/migrations/0047_dialog_drafts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS dialog_drafts CASCADE; diff --git a/deploy/migrations/0047_dialog_drafts.up.sql b/deploy/migrations/0047_dialog_drafts.up.sql new file mode 100644 index 00000000..f0ad3f53 --- /dev/null +++ b/deploy/migrations/0047_dialog_drafts.up.sql @@ -0,0 +1,31 @@ +-- 0047_dialog_drafts: persistent cloud drafts for private dialogs and channels. + +CREATE TABLE IF NOT EXISTS dialog_drafts ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + peer_type VARCHAR(16) NOT NULL, + peer_id BIGINT NOT NULL, + top_message_id INT NOT NULL DEFAULT 0, + date INT NOT NULL DEFAULT 0, + draft JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, peer_type, peer_id, top_message_id), + CONSTRAINT dialog_drafts_peer_type_check CHECK (peer_type IN ('user', 'channel')), + CONSTRAINT dialog_drafts_top_message_id_check CHECK (top_message_id >= 0) +) PARTITION BY HASH (user_id); + +CREATE INDEX IF NOT EXISTS dialog_drafts_user_date_idx + ON dialog_drafts (user_id, date DESC, peer_type, peer_id, top_message_id); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS dialog_drafts_p%s PARTITION OF dialog_drafts FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; diff --git a/deploy/migrations/0048_channel_recommendations_index.down.sql b/deploy/migrations/0048_channel_recommendations_index.down.sql new file mode 100644 index 00000000..ce038d33 --- /dev/null +++ b/deploy/migrations/0048_channel_recommendations_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS channels_public_broadcast_recommendations_idx; diff --git a/deploy/migrations/0048_channel_recommendations_index.up.sql b/deploy/migrations/0048_channel_recommendations_index.up.sql new file mode 100644 index 00000000..bcf82e46 --- /dev/null +++ b/deploy/migrations/0048_channel_recommendations_index.up.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS channels_public_broadcast_recommendations_idx + ON channels (participants_count DESC, date DESC, id DESC) + WHERE broadcast AND NOT megagroup AND NOT deleted AND COALESCE(username, '') <> ''; diff --git a/deploy/migrations/0049_channel_public_peer_search_indexes.down.sql b/deploy/migrations/0049_channel_public_peer_search_indexes.down.sql new file mode 100644 index 00000000..0de12ecb --- /dev/null +++ b/deploy/migrations/0049_channel_public_peer_search_indexes.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS channels_public_title_trgm_idx; +DROP INDEX IF EXISTS channels_public_username_trgm_idx; diff --git a/deploy/migrations/0049_channel_public_peer_search_indexes.up.sql b/deploy/migrations/0049_channel_public_peer_search_indexes.up.sql new file mode 100644 index 00000000..8f21b0a4 --- /dev/null +++ b/deploy/migrations/0049_channel_public_peer_search_indexes.up.sql @@ -0,0 +1,11 @@ +-- 0049_channel_public_peer_search_indexes: indexed public channel peer lookup for contacts.search. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS channels_public_username_trgm_idx + ON channels USING gin (lower(username) gin_trgm_ops) + WHERE NOT deleted AND COALESCE(username, '') <> ''; + +CREATE INDEX IF NOT EXISTS channels_public_title_trgm_idx + ON channels USING gin (lower(title) gin_trgm_ops) + WHERE NOT deleted AND COALESCE(username, '') <> ''; diff --git a/deploy/migrations/0050_channel_message_reactions.down.sql b/deploy/migrations/0050_channel_message_reactions.down.sql new file mode 100644 index 00000000..0f0efc76 --- /dev/null +++ b/deploy/migrations/0050_channel_message_reactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS channel_message_reactions CASCADE; diff --git a/deploy/migrations/0050_channel_message_reactions.up.sql b/deploy/migrations/0050_channel_message_reactions.up.sql new file mode 100644 index 00000000..018ce7ca --- /dev/null +++ b/deploy/migrations/0050_channel_message_reactions.up.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS channel_message_reactions ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + message_id INT NOT NULL, + reacted_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + reaction_type VARCHAR(16) NOT NULL, + reaction_value TEXT NOT NULL, + big BOOLEAN NOT NULL DEFAULT false, + unread BOOLEAN NOT NULL DEFAULT false, + chosen_order INT NOT NULL DEFAULT 1, + reaction_date INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, message_id, reacted_user_id, reaction_type, reaction_value), + FOREIGN KEY (channel_id, message_id) REFERENCES channel_messages(channel_id, id) ON DELETE CASCADE, + CONSTRAINT channel_message_reactions_type_check CHECK (reaction_type IN ('emoji')), + CONSTRAINT channel_message_reactions_value_check CHECK (reaction_value <> ''), + CONSTRAINT channel_message_reactions_order_check CHECK (chosen_order > 0) +) PARTITION BY HASH (channel_id); + +CREATE INDEX IF NOT EXISTS channel_message_reactions_msg_date_idx + ON channel_message_reactions (channel_id, message_id, reaction_date DESC, reacted_user_id DESC, reaction_value ASC); + +CREATE INDEX IF NOT EXISTS channel_message_reactions_value_date_idx + ON channel_message_reactions (channel_id, message_id, reaction_type, reaction_value, reaction_date DESC, reacted_user_id DESC); + +DO $$ +DECLARE + i int; +BEGIN + FOR i IN 0..63 LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS channel_message_reactions_p%s PARTITION OF channel_message_reactions FOR VALUES WITH (MODULUS 64, REMAINDER %s)', + lpad(i::text, 2, '0'), + i + ); + END LOOP; +END $$; diff --git a/deploy/migrations/0051_channel_unread_reactions_index.down.sql b/deploy/migrations/0051_channel_unread_reactions_index.down.sql new file mode 100644 index 00000000..e2311f05 --- /dev/null +++ b/deploy/migrations/0051_channel_unread_reactions_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS channel_message_reactions_unread_owner_idx; diff --git a/deploy/migrations/0051_channel_unread_reactions_index.up.sql b/deploy/migrations/0051_channel_unread_reactions_index.up.sql new file mode 100644 index 00000000..f4248bb8 --- /dev/null +++ b/deploy/migrations/0051_channel_unread_reactions_index.up.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS channel_message_reactions_unread_owner_idx + ON channel_message_reactions (channel_id, sender_user_id, message_id DESC) + WHERE unread; diff --git a/deploy/migrations/0052_user_recent_reactions.down.sql b/deploy/migrations/0052_user_recent_reactions.down.sql new file mode 100644 index 00000000..cf28e17d --- /dev/null +++ b/deploy/migrations/0052_user_recent_reactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_recent_reactions; diff --git a/deploy/migrations/0052_user_recent_reactions.up.sql b/deploy/migrations/0052_user_recent_reactions.up.sql new file mode 100644 index 00000000..3e8b8267 --- /dev/null +++ b/deploy/migrations/0052_user_recent_reactions.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS user_recent_reactions ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reaction_type VARCHAR(16) NOT NULL, + reaction_value TEXT NOT NULL, + reaction_date INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, reaction_type, reaction_value), + CHECK (reaction_type IN ('emoji')), + CHECK (reaction_value <> '') +); + +CREATE INDEX IF NOT EXISTS user_recent_reactions_user_date_idx + ON user_recent_reactions (user_id, reaction_date DESC, updated_at DESC, reaction_value ASC); diff --git a/deploy/migrations/0053_user_top_reactions.down.sql b/deploy/migrations/0053_user_top_reactions.down.sql new file mode 100644 index 00000000..cef29c69 --- /dev/null +++ b/deploy/migrations/0053_user_top_reactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_top_reactions; diff --git a/deploy/migrations/0053_user_top_reactions.up.sql b/deploy/migrations/0053_user_top_reactions.up.sql new file mode 100644 index 00000000..0cd37684 --- /dev/null +++ b/deploy/migrations/0053_user_top_reactions.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS user_top_reactions ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reaction_type VARCHAR(16) NOT NULL, + reaction_value TEXT NOT NULL, + reaction_count INT NOT NULL DEFAULT 0, + reaction_date INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, reaction_type, reaction_value), + CHECK (reaction_type IN ('emoji')), + CHECK (reaction_value <> ''), + CHECK (reaction_count >= 0) +); + +CREATE INDEX IF NOT EXISTS user_top_reactions_user_rank_idx + ON user_top_reactions (user_id, reaction_count DESC, reaction_date DESC, updated_at DESC, reaction_value ASC); diff --git a/deploy/migrations/0054_user_saved_reaction_tags.down.sql b/deploy/migrations/0054_user_saved_reaction_tags.down.sql new file mode 100644 index 00000000..99294761 --- /dev/null +++ b/deploy/migrations/0054_user_saved_reaction_tags.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_saved_reaction_tags; diff --git a/deploy/migrations/0054_user_saved_reaction_tags.up.sql b/deploy/migrations/0054_user_saved_reaction_tags.up.sql new file mode 100644 index 00000000..88cd76a4 --- /dev/null +++ b/deploy/migrations/0054_user_saved_reaction_tags.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS user_saved_reaction_tags ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reaction_type VARCHAR(16) NOT NULL, + reaction_value TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + reaction_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, reaction_type, reaction_value), + CHECK (reaction_type IN ('emoji')), + CHECK (reaction_value <> ''), + CHECK (reaction_count >= 0), + CHECK (char_length(title) <= 12) +); + +CREATE INDEX IF NOT EXISTS user_saved_reaction_tags_user_order_idx + ON user_saved_reaction_tags (user_id, reaction_count DESC, updated_at DESC, reaction_value ASC); diff --git a/deploy/migrations/0055_channel_paid_translation_settings.down.sql b/deploy/migrations/0055_channel_paid_translation_settings.down.sql new file mode 100644 index 00000000..e65024c4 --- /dev/null +++ b/deploy/migrations/0055_channel_paid_translation_settings.down.sql @@ -0,0 +1,41 @@ +-- 0055_channel_paid_translation_settings rollback. + +ALTER TABLE channel_admin_log_events + DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; + +DELETE FROM channel_admin_log_events +WHERE event_type = 'toggle_autotranslation'; + +ALTER TABLE channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( + event_type IN ( + 'change_title', + 'change_username', + 'change_linked_chat', + 'toggle_signatures', + 'toggle_pre_history_hidden', + 'toggle_forum', + 'toggle_anti_spam', + 'toggle_slow_mode', + 'participant_invite', + 'participant_join', + 'participant_leave', + 'participant_promote', + 'participant_demote', + 'participant_ban', + 'participant_unban', + 'participant_kick', + 'participant_unkick', + 'update_pinned', + 'send_message', + 'edit_message', + 'delete_message' + ) + ); + +ALTER TABLE channels + DROP CONSTRAINT IF EXISTS channels_send_paid_messages_stars_nonnegative_check, + DROP COLUMN IF EXISTS send_paid_messages_stars, + DROP COLUMN IF EXISTS broadcast_messages_allowed, + DROP COLUMN IF EXISTS restricted_sponsored, + DROP COLUMN IF EXISTS autotranslation; diff --git a/deploy/migrations/0055_channel_paid_translation_settings.up.sql b/deploy/migrations/0055_channel_paid_translation_settings.up.sql new file mode 100644 index 00000000..8c3b7eee --- /dev/null +++ b/deploy/migrations/0055_channel_paid_translation_settings.up.sql @@ -0,0 +1,41 @@ +-- 0055_channel_paid_translation_settings: persist Layer 225 channel setting flags. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS autotranslation BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS restricted_sponsored BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS broadcast_messages_allowed BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS send_paid_messages_stars BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE channels + ADD CONSTRAINT channels_send_paid_messages_stars_nonnegative_check CHECK (send_paid_messages_stars >= 0); + +ALTER TABLE channel_admin_log_events + DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; + +ALTER TABLE channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( + event_type IN ( + 'change_title', + 'change_username', + 'change_linked_chat', + 'toggle_signatures', + 'toggle_pre_history_hidden', + 'toggle_forum', + 'toggle_autotranslation', + 'toggle_anti_spam', + 'toggle_slow_mode', + 'participant_invite', + 'participant_join', + 'participant_leave', + 'participant_promote', + 'participant_demote', + 'participant_ban', + 'participant_unban', + 'participant_kick', + 'participant_unkick', + 'update_pinned', + 'send_message', + 'edit_message', + 'delete_message' + ) + ); diff --git a/deploy/migrations/0056_update_watermarks_retention.down.sql b/deploy/migrations/0056_update_watermarks_retention.down.sql new file mode 100644 index 00000000..0e0cc7a9 --- /dev/null +++ b/deploy/migrations/0056_update_watermarks_retention.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS dispatch_outbox_failed_cleanup_idx; +DROP INDEX IF EXISTS user_update_events_retention_idx; +DROP TABLE IF EXISTS user_update_watermarks; diff --git a/deploy/migrations/0056_update_watermarks_retention.up.sql b/deploy/migrations/0056_update_watermarks_retention.up.sql new file mode 100644 index 00000000..c475c0ac --- /dev/null +++ b/deploy/migrations/0056_update_watermarks_retention.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS user_update_watermarks ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + contiguous_pts INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS user_update_events_retention_idx + ON user_update_events (user_id, date, pts); + +CREATE INDEX IF NOT EXISTS dispatch_outbox_failed_cleanup_idx + ON dispatch_outbox (status, updated_at, target_user_id, id) + WHERE status = 'failed'; diff --git a/deploy/migrations/0057_media.down.sql b/deploy/migrations/0057_media.down.sql new file mode 100644 index 00000000..81244a8d --- /dev/null +++ b/deploy/migrations/0057_media.down.sql @@ -0,0 +1,23 @@ +-- 0057_media down: drop media catalogs and message media columns; restore body checks. + +ALTER TABLE channel_messages DROP CONSTRAINT IF EXISTS channel_messages_content_check; +ALTER TABLE channel_messages + ADD CONSTRAINT channel_messages_content_check + CHECK (body <> '' OR action <> '{}'::jsonb); + +ALTER TABLE private_messages DROP CONSTRAINT IF EXISTS private_messages_nonempty_body; +ALTER TABLE private_messages + ADD CONSTRAINT private_messages_nonempty_body + CHECK (body <> ''); + +ALTER TABLE channel_messages DROP COLUMN IF EXISTS media; +ALTER TABLE message_boxes DROP COLUMN IF EXISTS media; +ALTER TABLE private_messages DROP COLUMN IF EXISTS media; + +DROP TABLE IF EXISTS profile_photos; +DROP TABLE IF EXISTS available_reactions; +DROP TABLE IF EXISTS sticker_sets; +DROP TABLE IF EXISTS photos; +DROP TABLE IF EXISTS documents; +DROP TABLE IF EXISTS file_blobs; +DROP TABLE IF EXISTS upload_parts; diff --git a/deploy/migrations/0057_media.up.sql b/deploy/migrations/0057_media.up.sql new file mode 100644 index 00000000..109aba62 --- /dev/null +++ b/deploy/migrations/0057_media.up.sql @@ -0,0 +1,143 @@ +-- 0057_media: media pipeline storage — upload parts, file blobs, documents, +-- photos, sticker sets, available reactions, profile photos; message media column. +-- +-- Registry tables (documents/photos/sticker_sets/available_reactions/file_blobs) +-- are low-cardinality global catalogs and are not partitioned. upload_parts is +-- transient (assembled then deleted). Blob bytes live on the configured backend +-- (localfs); file_blobs only indexes location_key -> backend/object_key. + +-- 上传分片:upload.saveFilePart / saveBigFilePart 累积,组装成 blob 后清理。 +CREATE TABLE IF NOT EXISTS upload_parts ( + owner_user_id BIGINT NOT NULL, + file_id BIGINT NOT NULL, + part INT NOT NULL, + total_parts INT NOT NULL DEFAULT 0, + is_big BOOLEAN NOT NULL DEFAULT false, + bytes BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (owner_user_id, file_id, part) +); + +-- file_blobs:可下载二进制对象的索引(location_key -> backend/object_key)。 +CREATE TABLE IF NOT EXISTS file_blobs ( + location_key TEXT PRIMARY KEY, + backend TEXT NOT NULL DEFAULT 'localfs', + object_key TEXT NOT NULL, + size BIGINT NOT NULL DEFAULT 0, + sha256 BYTEA NOT NULL DEFAULT ''::bytea, + mime_type TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- documents:Telegram 文档(贴纸/gif/文件/视频/音频/自定义 emoji)元数据。 +CREATE TABLE IF NOT EXISTS documents ( + id BIGINT PRIMARY KEY, + access_hash BIGINT NOT NULL DEFAULT 0, + file_reference BYTEA NOT NULL DEFAULT '', + date INT NOT NULL DEFAULT 0, + mime_type TEXT NOT NULL DEFAULT '', + size BIGINT NOT NULL DEFAULT 0, + dc_id INT NOT NULL DEFAULT 0, + attributes JSONB NOT NULL DEFAULT '[]'::jsonb, + thumbs JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- photos:Telegram 照片(头像/图片消息)元数据。 +CREATE TABLE IF NOT EXISTS photos ( + id BIGINT PRIMARY KEY, + access_hash BIGINT NOT NULL DEFAULT 0, + file_reference BYTEA NOT NULL DEFAULT '', + date INT NOT NULL DEFAULT 0, + dc_id INT NOT NULL DEFAULT 0, + has_stickers BOOLEAN NOT NULL DEFAULT false, + sizes JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- sticker_sets:贴纸 / 自定义 emoji 集(含有序文档 id 与 packs)。 +CREATE TABLE IF NOT EXISTS sticker_sets ( + id BIGINT PRIMARY KEY, + access_hash BIGINT NOT NULL DEFAULT 0, + short_name TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + count INT NOT NULL DEFAULT 0, + hash INT NOT NULL DEFAULT 0, + set_kind TEXT NOT NULL DEFAULT 'stickers', + official BOOLEAN NOT NULL DEFAULT false, + animated BOOLEAN NOT NULL DEFAULT false, + videos BOOLEAN NOT NULL DEFAULT false, + emojis BOOLEAN NOT NULL DEFAULT false, + masks BOOLEAN NOT NULL DEFAULT false, + installed BOOLEAN NOT NULL DEFAULT false, + archived BOOLEAN NOT NULL DEFAULT false, + installed_date INT NOT NULL DEFAULT 0, + thumb_document_id BIGINT NOT NULL DEFAULT 0, + thumbs JSONB NOT NULL DEFAULT '[]'::jsonb, + thumb_dc_id INT NOT NULL DEFAULT 0, + thumb_version INT NOT NULL DEFAULT 0, + document_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + packs JSONB NOT NULL DEFAULT '[]'::jsonb, + sort_order INT NOT NULL DEFAULT 0, + system_key TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS sticker_sets_short_name_idx + ON sticker_sets (short_name) WHERE short_name <> ''; +CREATE INDEX IF NOT EXISTS sticker_sets_kind_order_idx + ON sticker_sets (set_kind, sort_order, id); +CREATE INDEX IF NOT EXISTS sticker_sets_system_key_idx + ON sticker_sets (system_key) WHERE system_key <> ''; + +-- available_reactions:messages.getAvailableReactions 目录(引用真实文档 id)。 +CREATE TABLE IF NOT EXISTS available_reactions ( + reaction TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + inactive BOOLEAN NOT NULL DEFAULT false, + premium BOOLEAN NOT NULL DEFAULT false, + static_icon_id BIGINT NOT NULL DEFAULT 0, + appear_animation_id BIGINT NOT NULL DEFAULT 0, + select_animation_id BIGINT NOT NULL DEFAULT 0, + activate_animation_id BIGINT NOT NULL DEFAULT 0, + effect_animation_id BIGINT NOT NULL DEFAULT 0, + around_animation_id BIGINT NOT NULL DEFAULT 0, + center_icon_id BIGINT NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0 +); + +-- profile_photos:用户/频道头像历史(current = active 中 sort_order 最大者)。 +CREATE TABLE IF NOT EXISTS profile_photos ( + owner_peer_type TEXT NOT NULL, + owner_peer_id BIGINT NOT NULL, + photo_id BIGINT NOT NULL, + date INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + sort_order BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (owner_peer_type, owner_peer_id, photo_id), + CONSTRAINT profile_photos_peer_type_check CHECK (owner_peer_type IN ('user', 'channel')) +); + +CREATE INDEX IF NOT EXISTS profile_photos_current_idx + ON profile_photos (owner_peer_type, owner_peer_id, sort_order DESC) + WHERE active; + +-- 消息媒体快照列:避免历史读取再 join documents/photos。 +ALTER TABLE private_messages + ADD COLUMN IF NOT EXISTS media JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE message_boxes + ADD COLUMN IF NOT EXISTS media JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE channel_messages + ADD COLUMN IF NOT EXISTS media JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- 放宽 body 非空约束:允许「仅媒体」消息(图片/文件/贴纸,无文字 caption)。 +ALTER TABLE private_messages DROP CONSTRAINT IF EXISTS private_messages_nonempty_body; +ALTER TABLE private_messages + ADD CONSTRAINT private_messages_nonempty_body + CHECK (body <> '' OR media <> '{}'::jsonb); + +ALTER TABLE channel_messages DROP CONSTRAINT IF EXISTS channel_messages_content_check; +ALTER TABLE channel_messages + ADD CONSTRAINT channel_messages_content_check + CHECK (body <> '' OR action <> '{}'::jsonb OR media <> '{}'::jsonb); diff --git a/deploy/migrations/0058_app_config_reactions.down.sql b/deploy/migrations/0058_app_config_reactions.down.sql new file mode 100644 index 00000000..a20b9870 --- /dev/null +++ b/deploy/migrations/0058_app_config_reactions.down.sql @@ -0,0 +1,11 @@ +-- 0058_app_config_reactions down: drop reaction config keys, restore prior hash. + +UPDATE app_configs +SET hash = 3, + config_json = config_json + - 'reactions_default' + - 'reactions_uniq_max' + - 'reactions_user_max_default' + - 'reactions_in_chat_max', + updated_at = now() +WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0058_app_config_reactions.up.sql b/deploy/migrations/0058_app_config_reactions.up.sql new file mode 100644 index 00000000..bda96ae7 --- /dev/null +++ b/deploy/migrations/0058_app_config_reactions.up.sql @@ -0,0 +1,12 @@ +-- 0058_app_config_reactions: expose default reaction + reaction limits for TDesktop. + +UPDATE app_configs +SET hash = 5, + config_json = config_json || jsonb_build_object( + 'reactions_default', jsonb_build_object('_', 'reactionEmoji', 'emoticon', '👍'), + 'reactions_uniq_max', 11, + 'reactions_user_max_default', 1, + 'reactions_in_chat_max', 3 + ), + updated_at = now() +WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0059_channel_photo.down.sql b/deploy/migrations/0059_channel_photo.down.sql new file mode 100644 index 00000000..170ab9a0 --- /dev/null +++ b/deploy/migrations/0059_channel_photo.down.sql @@ -0,0 +1,6 @@ +-- 0059_channel_photo down: drop denormalized channel avatar columns. + +ALTER TABLE channels + DROP COLUMN IF EXISTS photo_stripped, + DROP COLUMN IF EXISTS photo_dc_id, + DROP COLUMN IF EXISTS photo_id; diff --git a/deploy/migrations/0059_channel_photo.up.sql b/deploy/migrations/0059_channel_photo.up.sql new file mode 100644 index 00000000..335be707 --- /dev/null +++ b/deploy/migrations/0059_channel_photo.up.sql @@ -0,0 +1,6 @@ +-- 0059_channel_photo: denormalized current channel/supergroup avatar for ChatPhoto rendering. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS photo_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS photo_dc_id INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS photo_stripped BYTEA NOT NULL DEFAULT ''::bytea; diff --git a/deploy/migrations/0060_user_last_seen.down.sql b/deploy/migrations/0060_user_last_seen.down.sql new file mode 100644 index 00000000..ec9ac933 --- /dev/null +++ b/deploy/migrations/0060_user_last_seen.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS users_last_seen_idx; + +ALTER TABLE users + DROP COLUMN IF EXISTS last_seen_at; diff --git a/deploy/migrations/0060_user_last_seen.up.sql b/deploy/migrations/0060_user_last_seen.up.sql new file mode 100644 index 00000000..a534caea --- /dev/null +++ b/deploy/migrations/0060_user_last_seen.up.sql @@ -0,0 +1,8 @@ +-- 0060_user_last_seen: persist exact last seen timestamps for UserStatusOffline.was_online. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS last_seen_at BIGINT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS users_last_seen_idx + ON users (last_seen_at DESC) + WHERE last_seen_at > 0; diff --git a/deploy/migrations/0061_private_message_reactions.down.sql b/deploy/migrations/0061_private_message_reactions.down.sql new file mode 100644 index 00000000..b3fc1ff7 --- /dev/null +++ b/deploy/migrations/0061_private_message_reactions.down.sql @@ -0,0 +1,4 @@ +-- 0061_private_message_reactions down. + +DROP TABLE IF EXISTS private_message_reactions; + diff --git a/deploy/migrations/0061_private_message_reactions.up.sql b/deploy/migrations/0061_private_message_reactions.up.sql new file mode 100644 index 00000000..d89ec6a5 --- /dev/null +++ b/deploy/migrations/0061_private_message_reactions.up.sql @@ -0,0 +1,20 @@ +-- 0061_private_message_reactions: persist emoji reactions for private message boxes. + +CREATE TABLE IF NOT EXISTS private_message_reactions ( + message_sender_id bigint NOT NULL, + private_message_id bigint NOT NULL, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reaction_type text NOT NULL, + reaction_value text NOT NULL, + big boolean NOT NULL DEFAULT false, + reaction_date integer NOT NULL, + chosen_order integer NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (message_sender_id, private_message_id, user_id, reaction_type, reaction_value), + FOREIGN KEY (message_sender_id, private_message_id) + REFERENCES private_messages(sender_user_id, id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS private_message_reactions_message_idx + ON private_message_reactions (message_sender_id, private_message_id, reaction_date DESC, user_id); diff --git a/deploy/migrations/0062_account_reaction_settings.down.sql b/deploy/migrations/0062_account_reaction_settings.down.sql new file mode 100644 index 00000000..4f42ed67 --- /dev/null +++ b/deploy/migrations/0062_account_reaction_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS account_reaction_settings; diff --git a/deploy/migrations/0062_account_reaction_settings.up.sql b/deploy/migrations/0062_account_reaction_settings.up.sql new file mode 100644 index 00000000..e7a74f20 --- /dev/null +++ b/deploy/migrations/0062_account_reaction_settings.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS account_reaction_settings ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + messages_notify_from TEXT NOT NULL DEFAULT 'contacts', + stories_notify_from TEXT NOT NULL DEFAULT 'contacts', + poll_votes_notify_from TEXT NOT NULL DEFAULT 'contacts', + show_previews BOOLEAN NOT NULL DEFAULT TRUE, + default_reaction_type TEXT NOT NULL DEFAULT 'emoji', + default_reaction_value TEXT NOT NULL DEFAULT '👍', + paid_privacy_kind TEXT NOT NULL DEFAULT 'default', + paid_privacy_peer_type TEXT, + paid_privacy_peer_id BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (messages_notify_from IN ('none', 'contacts', 'all')), + CHECK (stories_notify_from IN ('none', 'contacts', 'all')), + CHECK (poll_votes_notify_from IN ('none', 'contacts', 'all')), + CHECK (default_reaction_type IN ('emoji')), + CHECK (default_reaction_value <> ''), + CHECK (paid_privacy_kind IN ('default', 'anonymous', 'peer')), + CHECK ( + (paid_privacy_kind = 'peer' AND paid_privacy_peer_type IN ('user', 'channel') AND paid_privacy_peer_id IS NOT NULL) + OR (paid_privacy_kind <> 'peer' AND paid_privacy_peer_type IS NULL AND paid_privacy_peer_id IS NULL) + ) +); diff --git a/deploy/migrations/0063_reaction_update_events.down.sql b/deploy/migrations/0063_reaction_update_events.down.sql new file mode 100644 index 00000000..92d40e12 --- /dev/null +++ b/deploy/migrations/0063_reaction_update_events.down.sql @@ -0,0 +1,24 @@ +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'read_history_outbox', + 'edit_message', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'dialog_filter', + 'dialog_filter_order', + 'dialog_filters', + 'folder_peers', + 'channel_available_messages', + 'noop' + ) + ); diff --git a/deploy/migrations/0063_reaction_update_events.up.sql b/deploy/migrations/0063_reaction_update_events.up.sql new file mode 100644 index 00000000..cc33e3cc --- /dev/null +++ b/deploy/migrations/0063_reaction_update_events.up.sql @@ -0,0 +1,28 @@ +-- 0063_reaction_update_events: allow durable account-level reaction updates. + +ALTER TABLE user_update_events + DROP CONSTRAINT IF EXISTS user_update_events_type_check; + +ALTER TABLE user_update_events + ADD CONSTRAINT user_update_events_type_check CHECK ( + event_type IN ( + 'new_message', + 'read_history_inbox', + 'read_history_outbox', + 'edit_message', + 'message_reactions', + 'contacts_reset', + 'dialog_pinned', + 'pinned_dialogs', + 'dialog_unread_mark', + 'peer_settings', + 'delete_messages', + 'dialog_filter', + 'dialog_filter_order', + 'dialog_filters', + 'folder_peers', + 'channel_available_messages', + 'channel_view_forum_as_messages', + 'noop' + ) + ); diff --git a/deploy/migrations/0064_channel_dialog_unread_repair.down.sql b/deploy/migrations/0064_channel_dialog_unread_repair.down.sql new file mode 100644 index 00000000..d6c587fe --- /dev/null +++ b/deploy/migrations/0064_channel_dialog_unread_repair.down.sql @@ -0,0 +1 @@ +-- Data repair only; no rollback. diff --git a/deploy/migrations/0064_channel_dialog_unread_repair.up.sql b/deploy/migrations/0064_channel_dialog_unread_repair.up.sql new file mode 100644 index 00000000..adb5909c --- /dev/null +++ b/deploy/migrations/0064_channel_dialog_unread_repair.up.sql @@ -0,0 +1,21 @@ +WITH fixed AS ( + SELECT + d.user_id, + d.channel_id, + COUNT(msg.id)::int AS unread_count + FROM channel_dialogs d + LEFT JOIN channel_messages msg + ON msg.channel_id = d.channel_id + AND msg.id > d.read_inbox_max_id + AND msg.id <= d.top_message_id + AND NOT msg.deleted + AND msg.sender_user_id <> d.user_id + GROUP BY d.user_id, d.channel_id +) +UPDATE channel_dialogs d +SET unread_count = fixed.unread_count, + updated_at = now() +FROM fixed +WHERE fixed.user_id = d.user_id + AND fixed.channel_id = d.channel_id + AND d.unread_count IS DISTINCT FROM fixed.unread_count; diff --git a/deploy/migrations/0065_user_channel_member_index.down.sql b/deploy/migrations/0065_user_channel_member_index.down.sql new file mode 100644 index 00000000..59d72cea --- /dev/null +++ b/deploy/migrations/0065_user_channel_member_index.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_channel_member_index; diff --git a/deploy/migrations/0065_user_channel_member_index.up.sql b/deploy/migrations/0065_user_channel_member_index.up.sql new file mode 100644 index 00000000..cb025950 --- /dev/null +++ b/deploy/migrations/0065_user_channel_member_index.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS user_channel_member_index ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + status VARCHAR(16) NOT NULL, + megagroup BOOLEAN NOT NULL DEFAULT false, + broadcast BOOLEAN NOT NULL DEFAULT false, + deleted BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id), + CONSTRAINT user_channel_member_index_status_check + CHECK (status IN ('active', 'left', 'kicked', 'banned')) +); + +CREATE INDEX IF NOT EXISTS user_channel_member_index_common_idx + ON user_channel_member_index (user_id, channel_id) + WHERE status = 'active' AND megagroup AND NOT broadcast AND NOT deleted; + +INSERT INTO user_channel_member_index ( + user_id, channel_id, status, megagroup, broadcast, deleted +) +SELECT m.user_id, m.channel_id, m.status, c.megagroup, c.broadcast, c.deleted +FROM channel_members m +JOIN channels c ON c.id = m.channel_id +ON CONFLICT (user_id, channel_id) DO UPDATE SET + status = EXCLUDED.status, + megagroup = EXCLUDED.megagroup, + broadcast = EXCLUDED.broadcast, + deleted = EXCLUDED.deleted, + updated_at = now(); diff --git a/deploy/migrations/0066_system_sticker_sets_not_installed.down.sql b/deploy/migrations/0066_system_sticker_sets_not_installed.down.sql new file mode 100644 index 00000000..66e70141 --- /dev/null +++ b/deploy/migrations/0066_system_sticker_sets_not_installed.down.sql @@ -0,0 +1,4 @@ +UPDATE sticker_sets +SET installed = true, + installed_date = 0 +WHERE set_kind = 'system'; diff --git a/deploy/migrations/0066_system_sticker_sets_not_installed.up.sql b/deploy/migrations/0066_system_sticker_sets_not_installed.up.sql new file mode 100644 index 00000000..e0266f65 --- /dev/null +++ b/deploy/migrations/0066_system_sticker_sets_not_installed.up.sql @@ -0,0 +1,8 @@ +-- System sticker sets are request-by-input resources for TDesktop +-- (animated emoji, dice, generic animations). They must not be persisted as +-- normal installed sticker packs, otherwise the client can abort/redo its +-- installed stickers local-cache write path. +UPDATE sticker_sets +SET installed = false, + installed_date = 0 +WHERE set_kind = 'system'; diff --git a/deploy/migrations/0067_seed_document_id_namespace.down.sql b/deploy/migrations/0067_seed_document_id_namespace.down.sql new file mode 100644 index 00000000..7cb56e35 --- /dev/null +++ b/deploy/migrations/0067_seed_document_id_namespace.down.sql @@ -0,0 +1,3 @@ +-- Forward-only data normalization. Reintroducing upstream seed document ids on +-- rollback would make local media identity ambiguous again, so there is no +-- destructive down migration. diff --git a/deploy/migrations/0067_seed_document_id_namespace.up.sql b/deploy/migrations/0067_seed_document_id_namespace.up.sql new file mode 100644 index 00000000..8302e33a --- /dev/null +++ b/deploy/migrations/0067_seed_document_id_namespace.up.sql @@ -0,0 +1,191 @@ +-- 0067_seed_document_id_namespace: normalize imported sticker/reaction +-- document ids into telesrv-owned storage ids. +-- +-- The seed export keeps upstream Telegram document ids in JSON and filenames. +-- Those ids are source identifiers, not identities owned by this server. Older +-- local databases stored them directly; normalize every catalog/message +-- reference so RPC, media snapshots and upload.getFile all use one server id. + +CREATE OR REPLACE FUNCTION pg_temp.telesrv_seed_document_storage_id(source_id BIGINT) +RETURNS BIGINT +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE + WHEN source_id > 4000000000000000000 THEN source_id - 4000000000000000000 + ELSE source_id + END +$$; + +CREATE OR REPLACE FUNCTION pg_temp.telesrv_seed_document_id_array(ids JSONB) +RETURNS JSONB +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT COALESCE( + jsonb_agg( + CASE + WHEN value::text ~ '^-?[0-9]+$' + THEN to_jsonb(pg_temp.telesrv_seed_document_storage_id((value::text)::BIGINT)) + ELSE value + END + ORDER BY ord + ), + '[]'::jsonb + ) + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(COALESCE(ids, 'null'::jsonb)) = 'array' THEN ids + ELSE '[]'::jsonb + END + ) WITH ORDINALITY AS e(value, ord) +$$; + +CREATE OR REPLACE FUNCTION pg_temp.telesrv_seed_sticker_packs_document_ids(packs JSONB) +RETURNS JSONB +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT COALESCE( + jsonb_agg( + CASE + WHEN pack ? 'document_ids' THEN + jsonb_set(pack, '{document_ids}', pg_temp.telesrv_seed_document_id_array(pack->'document_ids'), false) + WHEN pack ? 'documents' THEN + jsonb_set(pack, '{documents}', pg_temp.telesrv_seed_document_id_array(pack->'documents'), false) + ELSE pack + END + ORDER BY ord + ), + '[]'::jsonb + ) + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(COALESCE(packs, 'null'::jsonb)) = 'array' THEN packs + ELSE '[]'::jsonb + END + ) WITH ORDINALITY AS p(pack, ord) +$$; + +DROP TABLE IF EXISTS seed_document_id_map; +CREATE TEMP TABLE seed_document_id_map AS +SELECT id AS old_id, pg_temp.telesrv_seed_document_storage_id(id) AS new_id +FROM documents +WHERE id > 4000000000000000000; + +INSERT INTO documents ( + id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs, created_at +) +SELECT + m.new_id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, + d.attributes, d.thumbs, d.created_at +FROM documents d +JOIN seed_document_id_map m ON m.old_id = d.id +WHERE m.old_id <> m.new_id +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + file_reference = EXCLUDED.file_reference, + date = EXCLUDED.date, + mime_type = EXCLUDED.mime_type, + size = EXCLUDED.size, + dc_id = EXCLUDED.dc_id, + attributes = EXCLUDED.attributes, + thumbs = EXCLUDED.thumbs; + +DELETE FROM documents d +USING seed_document_id_map m +WHERE d.id = m.old_id + AND m.old_id <> m.new_id; + +DROP TABLE IF EXISTS seed_file_blob_map; +CREATE TEMP TABLE seed_file_blob_map AS +SELECT + location_key AS old_key, + 'doc:' || + pg_temp.telesrv_seed_document_storage_id((substring(location_key FROM '^doc:([0-9]+)'))::BIGINT)::TEXT || + COALESCE(substring(location_key FROM '^doc:[0-9]+(:.*)$'), '') AS new_key +FROM file_blobs +WHERE substring(location_key FROM '^doc:([0-9]+)') IS NOT NULL + AND (substring(location_key FROM '^doc:([0-9]+)'))::BIGINT > 4000000000000000000; + +INSERT INTO file_blobs (location_key, backend, object_key, size, sha256, mime_type, created_at) +SELECT m.new_key, b.backend, b.object_key, b.size, b.sha256, b.mime_type, b.created_at +FROM file_blobs b +JOIN seed_file_blob_map m ON m.old_key = b.location_key +WHERE m.old_key <> m.new_key +ON CONFLICT (location_key) DO UPDATE SET + backend = EXCLUDED.backend, + object_key = EXCLUDED.object_key, + size = EXCLUDED.size, + sha256 = EXCLUDED.sha256, + mime_type = EXCLUDED.mime_type; + +DELETE FROM file_blobs b +USING seed_file_blob_map m +WHERE b.location_key = m.old_key + AND m.old_key <> m.new_key; + +UPDATE sticker_sets +SET thumb_document_id = pg_temp.telesrv_seed_document_storage_id(thumb_document_id), + document_ids = pg_temp.telesrv_seed_document_id_array(document_ids), + packs = pg_temp.telesrv_seed_sticker_packs_document_ids(packs); + +UPDATE available_reactions +SET static_icon_id = pg_temp.telesrv_seed_document_storage_id(static_icon_id), + appear_animation_id = pg_temp.telesrv_seed_document_storage_id(appear_animation_id), + select_animation_id = pg_temp.telesrv_seed_document_storage_id(select_animation_id), + activate_animation_id = pg_temp.telesrv_seed_document_storage_id(activate_animation_id), + effect_animation_id = pg_temp.telesrv_seed_document_storage_id(effect_animation_id), + around_animation_id = pg_temp.telesrv_seed_document_storage_id(around_animation_id), + center_icon_id = pg_temp.telesrv_seed_document_storage_id(center_icon_id); + +UPDATE private_messages +SET media = jsonb_set( + media, + '{document,id}', + to_jsonb(pg_temp.telesrv_seed_document_storage_id((media #>> '{document,id}')::BIGINT)), + false +) +WHERE media->>'kind' = 'document' + AND (media #>> '{document,id}') ~ '^[0-9]+$' + AND (media #>> '{document,id}')::BIGINT > 4000000000000000000; + +UPDATE message_boxes +SET media = jsonb_set( + media, + '{document,id}', + to_jsonb(pg_temp.telesrv_seed_document_storage_id((media #>> '{document,id}')::BIGINT)), + false +) +WHERE media->>'kind' = 'document' + AND (media #>> '{document,id}') ~ '^[0-9]+$' + AND (media #>> '{document,id}')::BIGINT > 4000000000000000000; + +UPDATE channel_messages +SET media = jsonb_set( + media, + '{document,id}', + to_jsonb(pg_temp.telesrv_seed_document_storage_id((media #>> '{document,id}')::BIGINT)), + false +) +WHERE media->>'kind' = 'document' + AND (media #>> '{document,id}') ~ '^[0-9]+$' + AND (media #>> '{document,id}')::BIGINT > 4000000000000000000; + +UPDATE channels +SET color_background_emoji_id = pg_temp.telesrv_seed_document_storage_id(color_background_emoji_id), + profile_color_background_emoji_id = pg_temp.telesrv_seed_document_storage_id(profile_color_background_emoji_id), + emoji_status_document_id = pg_temp.telesrv_seed_document_storage_id(emoji_status_document_id), + available_reactions = CASE + WHEN available_reactions ? 'CustomEmojiIDs' THEN + jsonb_set( + available_reactions, + '{CustomEmojiIDs}', + pg_temp.telesrv_seed_document_id_array(available_reactions->'CustomEmojiIDs'), + false + ) + ELSE available_reactions + END; + +DROP TABLE IF EXISTS seed_file_blob_map; +DROP TABLE IF EXISTS seed_document_id_map; diff --git a/docs/assets/tdesktop-alice.png b/docs/assets/tdesktop-alice.png new file mode 100644 index 0000000000000000000000000000000000000000..41b2897e8c201b8b218e2f2f8da910a8b18b496f GIT binary patch literal 623532 zcmXtfby(Bi`#vq*-E1f*9g>3)BNPxt1*H)XlrG6JV!)7YX$ciYKtQ@1BnBvqZWu_8 zj@2e%>!MC5di@)fPjF=lbr14 zjlxfFo|`{HFC!fdg7OjWt(yUfgSvq_0YPN~71oCIW=!F(`_hYmfVS)3L)hn5Vn;xL z|MUQ&{>;yE=i0&9E--5&dR}hg`NYKU`HA`JG9JQl4L*|Z1Y0q(^goG=XCla&WEg)k zl0aWjQ0+!>?7RwS9P-)PvKS4_5KLJJbPKa#k6gHuxeyrOHm=%vl;;;76*bhnwb%08 zBB|XAkH#iMGbPD$;Q8`VL74pOGpl7Q91Io#yG|HWFdL7KEq}8%Tk+99fMz$NZZyz` z4vIP*gZZ!#qfVEaE9p?fDoIzT;&bwKlHN{S3$wEa^V6aDJ@Lr>U7MYSa>o^1yyIm@ zt>bA&y5rum+y<}9^4}sJb7*sflVPnEDt~XOx7uPZpcdG8;ce!Po}Ofwx0qv{w=!~B z={RKN6?a)4JAC)p(4-cEG_KQ%5y>w6G$A&&@;B1)jmy)Y&8@dh%@&!<+?=E3-ZUmq$)eTcMJgliiQU=)AWcabOX7jGd3!v7?X9v9-O4H_ZRm zS$eI9S&bw1`v%e(x6^uP$99xZyNf{g& z%Dcsh!FD{viTex?asgxdvHahZ0ICFERd&c)U^fZiHThOkZA{*F(`IXH%*ZxL5|6j} ztj6q#7)Kd@q2w#Fq&%l9b^fg;4EMFXmG#o3o`P#z$Z9&~Qb%n~T%emE>1&{}j+z~g z!DgEt;!4IZNCLPC?|9U&OSh~m^I|WLk%-HMg**ZZw&T}oPuMRk*z&#Jym?S-)fyE2 z#W4SsogImcjLc#1aZB*O#9my2o#8L;YS7|ha4EweS5k{hL4#n<$i{`I$G{BT$k`sFj?HX(9>yjf-U#RXP^N)7Th zr%MW2)G4WOWBG2u>P&gb$xM35_22X$6CcZIjk7QTkyLuSA;28<+*H){1|fG{?7md% z>8Kd7c2J!!skZjMkunkB&k(d#h#0c@-aODbBqIo%sRjY_MI_N9;5XUq#>BSb4XPj3 zQvM_!ro>Mv6i|*6J%^tAe{`i?-nuwK9}d;IE%xs0d`Bd9Hb~$M@q41U*#l?hhH@J_ zQpY?Ct>G!AdcI}}#6gtb{|}qvzpxQP&atlFesm{$V}s}}ZspENZ!9jfxF1PJaw&t<4U#RQ)1QN#vez@Lo^ zf;aA(qEU~*C)C8gmLFY$iD{SyU)e;d2_9?F8|eyMyZ&Vl33^R00Dt^i9v(C|vM(x5$czZU45lMxxK^zSL$vqL z82?{Iz+@gzZIgR#s z5h@-XGGUuA8tBERB7eFTb?zJ1Uv~X}`6FGjC8e&C*c?bBzlBL&(GjJ<+U*(i#h{dCSw?=*)}gAY`2HaKA8a|o zdi*UBQ?=%J^^d!fTiS2@0m2EY?h)d`$W1wb4vZ<9i8xV2nb86ciHAAmjz)cl9m#`1tzR z23(j*cwvA8b@De^FH`uI_M+6Tu#r#+w*N90UIanU23Rzw|Y|4Lc*- zym`%g?}=sj_6Up}l$Bo$tMLw`!yTV^ zzbYvbFeO7!FD`VH)C0&62iG6Gw9bRe_+LFT{SwQ~MXY`9e-Bae-*>|5tBkvd?4!T{$>Ri9vq1jPJfVLg4^i(9JX1i>Yh0GQMRScqU4#e( zZV?IWQdJnxA2ODC-Ez#H*V!K8HFfYM(I)4wIYK`1ou;>e+*b3n=X0UDBIKWDi7MXy zRW8_G&1urM7DHm5#MRVc&af}$qea_Q{k;J|lC*6|r{3Mq@l3 zaB$FQ;j}l7>qCBi-}+#NmY0{8(VO=L`cza@RUv5q$-wLK=mYi5ACZn@9Ls^hhilP< zfS;9SY_U_O7sF#|Jl>Y`X%eLO$N`~(ZS)9k{146l$c6ePp&it#E74xSDU$B3kBgo+ ziH30;h-^c~QFp9~n*kRj*Cf#K<9i_2hvqsC5(e>&i)%^m*^(cQa66Z1F2JMy`?yXg zvxk^>;yVS?XOv+mT5fht2srd@u}V%PB>lJ6R$h||OO{bT?0Kc={x4nkw@ywe-Cn$_ z4*h3^dr>FB#u&w>#zu00wtg9|w`BE~n3P~1v` zjk((R+$I}R~h!zUY1 zc;2kq@YQE{an{0@TTFCu?%Hdu>O}y*2-VNmBI=| z4JZ#00q}bck|P1Sn3fmnYlpTza5-ZBDkg%Yu5R2af0fq|14XFr4X`UnXAFKCQshkK zcr;YCfPPv`(Babh+R3T(S<1>QODnyuN^cKCNdbbOMgGpfAKzH?_LOoT9UmX}%ltS@ z+X!%v-=i5kV$?q8uCQujO{`u(-eW>=>xS{csC=Xm><{35>T8N~b$K^TQkLj^&iA6VyDGyUM5T(Aayru!w`z{5H2=p^;}nSfnq&=JF@`zPb>?$5Y2r_+~pCAV&vC%F(Z;LCRT5eI>>`@`qFG7Ht1`I@`s{Y`3+ z&g#ipjJJzW$-j~uPY}Un8_7f1CbQ}~v{dE^yb9|tuxA%>c;DzVnQ(Heit6g8=5%F} z_L0ncUTYqCXV7r+{3v(zetz!z^74cI{*h5WzP_ut>#L5V6ZD^}jSB8Ca@Z1*t1SW$ zOQH^+(7VZ(O91&l`R8z=8HO;1ONaM|Q9$CK4>s1_UL?g@Dtu41UyG!NP|L&T$Kzf{ z?3$d?s@vfRTr%iJTGEC@WI+V&oqyh}znQrR1A=f+UMAp~76ekl)m)y+i8;ePb<;PvY!i%lcC#cb-_viq_8Z7a$Uad7O)WT&_C)bikp$iK-sWx7vnxNs$GvN17 z=%vh-@UPi}^=%a9WJk4iAo$r;q}&ZCoS55X+Iy6maS+HMpzpYzsCaneX`k-?bEOxDD6VB`+Pf<#Z+dbvHz&PCJy0QrV zDP({Xd~%&v`2t;-+rWZT);6RKHl)|6?{_d#Xw%z-u=6vL4>ndMY(%Iqcqy4mxD|O| z?~<_^OI}!W8NLe{oA&q6#I2yMmXP=zD(oQ|7MTyVY41nk*9;E^VW({8F)7GvsSdYWA7BM>LaB3oRXeKhWI-VTa4+asnYgo z?iPdn%51%yCLX(c*pSAyE2>*h_iprx%gNJ1xU!o%HUjyIRv#ZleJzV?KUIPmyV%%5 z(TGV=Qww0M^DP+dFJvdns>9lEd+`rnRX{Ihdakv^zYzyC3N>Qv=uo`-Hp^qG;^3*i zop6<*xy7F2YpDLba{E*+lzQ9 z(~S^?PgcVqj|=+6f`{+++%hTCvBpT4OS3D`y5`2`Zk2s`}( z?y62+H)uZn(7bTZ=ra1moAG`=7Jg%%#gHeM-5H& zF@eb1_3FSM?C0X93sO+Jy9h_cMV>8@s^-e5^*ElfUmGJ*l2V+!yzx$EvjCjg`A?6t zaGeYJ`kMH-vWWz4a8!I#7))0QCv$OTaM^O;p@hSCMZJOMy7o!4vgNN}R-^pR4^(+o zUg6`T{0`R`@>HR*4#>QIOj_FQ?`4&B4^2&p+jmVDXq>aW?8KmMO>Nyrw~uF2XiP)TnLi$9Vcyah(&Xzl=ulJL zg=IIsGEIVxST()VQdJJl1ARuB?)$DSWS73M@{bj8s5@nlTCt}aJR?(E`@28^g~Zya zzW>Co`A>MlN&=`5^s+=?$AtW@)xarpm_RZqP1`f#wl~+Km;)c2qz+O4Wbd*rJw+#i zLYH=)-u9|X&2FC@KIb1mPm)xc@b1%~LaFW;LuMcRQuGztJBKz48ak2gd~(5x*Na`OlDk!X1)N*;vx-q#t`tEfSK2Y1Q%J{c`0hGo8aav-Q_@sPmw_-J3a> z%`uYu|EP}^8r1I34#JbPdJQcqtLq37@KExKaAm8Pxw?|@!;1uL)qiPC^u^w3Szko? z#)p#f;9X!dj&s{x+BoI&E*|_=|M?YmYU~_#u&NED>v}QELFIJkQw2%#MBJLkw%_?B zGmIA2EXWXK&)~$hJ*7O=vvJD0r&1piQEKwT-G}$cgw@^O=8^Uh?j&2@9lzYd1WrII zj<54Ok6!R27+R@s+^?9iox~$;d}10w4hL#~&+}^S>Ti8ZQ+WKs2tDFO8~uMou`eQG z=RSfBmvbLs$mB_CY4`7gsYUdjagl21uWnpM>cTAVCT=2Sh_&SwfwCe5fI($K9XQyR z&LWA+Ft+@!37?xa4x{v%uiSQ{gYz?=v-Mw`Qtw`w&Dx$4whp!2(h^-T%jLc}17GbW zBde&;^JDg+y8*aN3$dC(?xfxZG?4&}m&6lrb-*x zw<3XKY{PED;;pbNH<8T8QUqgugL}YN)A{esI#k+4UfXh=lu4Sx;|&& zjN)S-^|+!5U_I4pF`;c`f*fBtA9jep=9T1j;WFVZTb)^bZVs!8%tlVSJ9zN;vYL3f zdC;7t8y|$2T6U(Sv4GauE@vq7%EWA>KBm>IhZu*qaXc?`^MomuoRryTrbvApE@N?a z6@#uhvf_DQnRcwwzxp+mt$2@qel^Z2@cHn-*GV+P_ zMh^Y6VUO>w6uw~VO~q*W3m+*4X|KX>V7w@KyOc{s^89E*_Zcd`O*t8OqX7Rr;O~U{g!LcnmJuMFDG&;7 zu6YCiGB`Vbl|(_OL53AM{6>_q&fMW!U8bc^OO3;C?&EE-J72&ZH&H~N10!Eu>%gLJ ziWdoiVf$`)JowTJyCZ?4#Q(Qv8f}ETr(PlLaEC&AjEe(81}LD zMbaQUjj+`9p{{jz2p!bY)hmA1bG{ON(QIn=G6RX2C4;*RqCe$x=s)NDh)$n0CN}L`xyLr(DHn_3W=a3Ow%oP8^6gi^uc^EEwYTwebJ8=eZuB$9vC0$Br)oH((t79Y^Gew zaykj5MGT?sX878KW%LA7#89w#2XnPGc32)VpE5CTU8SVC zY@BiSJOlS{MiTD9Ajxh^|T=>!RJxmF(%GCox ztNC+b7tvf6R58t<=&ebuW2bXZ%OWr+Bti#CL*D?SCrCmQ0g8O{xcEJ{Z-eSAUv2h^ zC{(nCtv@ z#!u^rTIthIXCRk*e-qCIotj&(ovx_vsm}Jbd{PNbiW&@L{CjHW1111A`PFuw|0Vt| z{xtA~*>vCKU-idaPRPpETDCvEm#T7;z6rXWT;K=VV~7N%3RKXKgt0F;z{(L8Yki3A zi|a*JuQrGjV4^rPw<~L}F+eZrNng46*c^w5Mi!-^Q2%WoX|lsoRr}QZfl&0QhpjJ@ zneuE0O7V?Vo|D(`6nNR|!W`(O_5p%~;)r4vZ<{G=-xq6bO=aI$Bu4ela%qnY$77(# zqp~w)rNinux=`JJ4)&JUd$9GhxjIp$-z)g99kUekY9jUJ$xg@O<=Xk8Zo1ZM%WVwKF)^f$| z0a)G1?0eWr<;sd9i_@$K-Gb1Sp-1#;>CN;-ntA`6Z@tz6o_0^1=Ft~@m zFR5N2A6R@>)YiinGgV*T0(ZdTonSPBSA#F6&Cdm8a9qTRP39$X5q+pQR!JB%YVZ#Xr|{^g?X{zW0mI+lmE36?|n27 zefHbK!Vv5K<&8<;SnuR2&~!E^tfow-w_k!hINBN1JKoDAS_WnkD_7~MK5_7CJY`Nmv1-v7 zvw6V$DSXq3OwC#6{b~_T$h_st6&X=bAiO~+LGtRH;WaO~gd1l)zhpWpPt6?h`pWp)0_cF7CTR+ z7up>wWmQNNtoKTDmV>lThEx=I_X}+HRF&L7+Nii&o2?h*Ep>dhOC!LkeO37C0&)Gm zO2}CU(2oxkf?tNlsx&4<_nk>ytnq%%Q|*>rJge^e06Qh2iZS$XxwuOr2hLbBDU54Yo(fK9eeK&h#0f*S% zC&xW0OeZ_*Peoe;BKvM7#P{ow5mQc$dpsx(Q<@b7Qe1uDMf8~Sm zSx61^Z=9no-xNsU&&ZMHZY#lQUYla!b%vY!0j0VMjHcO-xH-UO^Mp&&ZJ}9x)9~hp zY{E5@9Y{-(;SLrbS)UDOoA_P7rfa`rsvILF{XC&^nlV=Ybq6>vbU9i-7PBksgx}OJQHH0lnPel1Ij2$2fz12`bJj_qB!zK(x2pa1x>Gq*j5Bk%@4#H3`M2W1F#9ZSny9|dYdKq_ zbJSaad$r+5!fRW15MTM%7A9q&B74)4+8kdLH^F|2|>}mh{$Q0c&k9gyv{>)az zsHrJYaWs4T2EvC1?S+=QqOd-|B`8$uUR*ecNO+2-1y^Q^L4t=d68KyEEh-2G=L#J2C zp)to~TbFI3LHY^0UxViCmW;jV_9Km8J;866zaQHhYMrQER#pZ`5~qbL*8j;4Jq&ez zM!Qk?9jw>;)%n`ko#m@ZBeg%MB zS80@92fasow0)G>Zb@D2aH2~?f&B5@}?{1x3BvG zOO!TynuJP==v*+LHY_((Gq+C_E>O~r_p;p2olGS$GZ1|x0${LUjX(e9s3>1_`?eol zUdlxJ9h6;7s~N}eoBr(~^>=1>lD8RcO5=6@d?Gg&21WHJyTdI&3CHQOYwyvOju&3ez8wKa(bkt?xwm@x=L@!7@68@2T9ZV)sK4M@=*PqPP+5a ziGq>;e1q;c!_*}7)LW(lgHsejJR#ZNvI{+iTDl(ujkoDxbH8_H=1&F{SIPUG$rB{D zknKfrAEW!ddO)QtKjV3(*w!J3r}CG11#rvV=Nq2NRSCKHf|ZtY-;~J@VIQi5yPtRW zgZ9h%ghwzhp9Nj-^y)Lqr56s5F%kVcWP*d^f;#zd)#SL}kLjg45WE>M&58p~Wdh@| z@ywW{uSP@W!@b875PgdWo!vp8UI`&VEe$vBRRo&8IO}cK?ZV|Mf9Ubbc#F+P0m?H}9_bA64 z>ES0%hMRWu`wUoOSGk3r@>fKua71^EQ5_s%BLrj3AB6de^X5$}oQn&!gWbX%?htBv z$<;eBEoad>%4x|;rp2~KLyjNaDY8`{v{-$xnWw8&Dls;yEB#kR&N~)eVE>cApy0uC^^fv zS1wetBKnK!JMQLcb%dc zE}@lon0C@NpInW1S14g$t(NEZ35{c*!`JK)xRI7nW;>(uDK;#CA7SvCQW&j@^~r(u-b!r#ytJhZmrTc{`#x%rXhu} zn8L={ZD5q%*zb9QiV|NWNE@!dhF;j&ogDaWaV zcN~uI2z*je^#j$2sE-&rMr-I>umhZs9h2N~o0n9cJXYWup!&$_QL*S+INxfEyRNm8!^ z-+7>(b2Qc7w%6vxOP9OCuD>+StTq30A}*OOk0@aAA>@Q>T53`JfOtes7fo0{<4j<+ zlt%;e)1!tDEm@(8L<1U}y$(i_aw&*`2HDK~JY?cUO7GVQS$(mf83pKVrr##x_ApOX zmXxEde4m{9oXF_GyKX#vXd@}rj37Ihvm8Z{%m91t1GpQ25I5i#t1mRrd~#WN!5k}5 z{OZ_KI`Re(@+&*QDe!^;5(RDBrR)ctn?Xw2tmDhM2EC)+0FcasgW*r@^4Vl(`kdy! zzq%Vbw98~i6f`(1e32PdPJds%%uKECP~;%Wbp6}^2a@%pmmt+Hj#~C={PC{7+UyHf z)8494P+s`wF>~42jRG4%z8WBD$syav8LL=NE2)Pqw}nsASCZ?9dNl3(z05UU<8QAu znI+CW2gcxcaAd{a&j4k0yaO#Xx|ManfrS{mK7w)caKyHw_Ng`7d-M7F*0$KKJ`@Ij zb%tlPy0N#;k@(@Zj_#HwKQjK-a^C1%9$k)2O>bYWWb+nXPhUW78!yG_TRXqMNqa-?_f}l;n zLyI>h$DfLR<%d$bao&qcezI$zb?wf{scrN`g<0a;NpDdHTa-2o#Cct*J`vY!AXPZC z?3e;b_&UcF$%uYw?wPBZ24?iJtt{b*Q~xBP5x*5YtSkF1s%VWnelFuEvgpZ10_4J- z`JRsL;}r_cM|kUB*QntFMQBkY zWkb>g3^ry@VU4ag=5U9izuIdtl1PV$GWUV7_l(`50=jrCa;}494dL7&HOrge92gec zn18=r(N@)opEmQ_Lw7$evTT=#v8gj@hAo^rklS;9pr7lZcBBd~1rM9`HnonCFPz(u z521CbmJvw7b;D^ zqc?)@bbwVSqJ?H7z{YFP%1l?%5OT~_)7(TbIbg+lJ34Ut!yH#a$)tNug-Qe!jYHBX zM_g(sq?kq!(6AUe6)z00GjG4RltJtvAT!p|&QJ{EO#?{<5ORSvVT5ydO>6-7^oJJX_~ilF zSOX#3SCb)$n9OJTL!Vy-sQwN~xzHz8U66hxW=HmMWwjThD8BhyQZZiuct$}yCsgb% z`|oY^AhXr#8m1T}1dr8!`an8xY|ng{qbEQp04Tv_DsDb55GHFYq@t~r)Dk=!Cc@2et5j@ z*9y+tc@NhVe_LiBRv?UT<%MAIV=*BVO`(f4#t)fhZ4sU9y0YeY9O zf6~l&ydkdfwUoeqPve;%)nnDv+pZx>ME6^%H5Z;e3rv12lM3fdpsBI!ns+>&C6Z*6 zpw*st2ffu<`26AzbszUz!OIWdf}RL_Sg9>9aWFTHtd2isf2gu7e5O_SR*=ko0b#r1 z@qE_~@E*h<>~29G|ML1v;yx(h8%6d|i(ZEIO5%2N2zQ_V6Vv^={!MV4hN0m5ps{}6 zs3LD!-t|>EW@@c|%%42BL=`_;(mmm|rdNsku}tfVuacD6fxt5=b4=B>+`5u&(kenT zT`M!ZQvD^mKG!Be-4w=b6ab{34JOMBf?7#Sb64u_P%zy-(?T#djsU;PbAKg%qRSVn z_*ph^C-ANh{|3V7vPcV*KF7ua5^6mnVhjYVvDck|O8pD~_vx?It4?&FiOD-sh^@wu zgUR28n!I6IXA?nj*<1z>Ej-WHPVJrS1a5^4&qD@kzdP0+F+HIQqGT@ zzKE@h_tySS+ZPq@&8_{}McqLomt3P`=y}BZ)kl3-@y55hlsHFibCA$h+`%)V$S;fu zpp1Ap-N;dcimjA;oK}}2=k20`7YaJIOWZLLP9WNA)kx=__e=ose)IPVfV?ecKL?}b z7Jd2wdIs&y$m6);kAZ?5Ew)>myn32ZV3zsSmqb8sNMoPpsLXRj?HKnTI{QNZVgfPS zj8O%1cmRxoGji~;ym{z7H^sNu`y~gHAH7S+?DNP{1F{~kxN79J@Mc0M>J|QkrRGVm zDLN&}JdRE+kI~(i(0OacGM;Oo`_6~Iv4VjkSebE`=f{aGz~KiEOBZyrNh!VwDKCjR zklO<-hF)(Fd0r_cE}$ZJ3M?VMfB1W*L*bzTg4BNi6E72F9cYp--zWWf3jc%C>t`3$ zNv}47&<}V#2e_3mHat?2yXfZ;h7?q@XMo^e8B+tqSH>T!{+hceIbeQx{_^56;jhTU z$6F$hd0mFL*oYE$`@(uK!-QsL3C6B)GCNcW)(Cgv8FTNYcu4haG9%2?YCQTI=IPxM z_Nsy*mINaQ?)ST6^;3M&$sO#O6~}^wznpSRlymNVJH0&^^i`QKBa$&`o+&kAVny*OVIqdmr$fbK~$?2)mp)jw*W(L*>d zVea?Z(O}E?BX;CE=6FGd2tc~`f}zffr&-lVdk?KxwD|3Ydpx~KiMJyK3Rh*te+GyDsC8pl2!7c6o2IS9 zWCG8Bg*IzVSB!Vygo&K#c|y8gC#3hBH!=TaiuW2`1bo{L6Fp`kE3_A{Is!eC54s%_ zJd%0VaDdJcKVAGZg%gIni(0OmXwzs`A$y{-jM$1vZIEyZ7VAUG!bH40h{q6joN0Q0 z_1oqcdiDXo$x};RGbqes@2~7UL2Rlf^yg7YBca(cteEhzD!v7uNAW$h5XyEq!=ITE z@{b=1B4(q*E2WWVJzffb{R8%zy0`s@r$4*VF9gexs|Ubt$on2H_aG0eJci7Lu?2~> z);&G3qD;kb=Vi;KBA(S5ki+<3!Qbg$rPIz}6W=fLozM@l zvFAu_=wS1{Z7W}j9}W~)J7gi&{)|p08cp(CQD{kg)!v`ROf0VY%9}=~6U^cydMd#c zgJ0fKpa{=u`L@m@uiFQ{Gvuvj$5O;=7LB>b z!Ml`MVhW-i>-#8xN$#b% zX%>|(Id7U-Ny!I7f;_n-DH#1@eY+y40*yt{^k(KSovRn(Z*$%_a*9K?{qAx`={-h^ zXs_xj{?+;RWW5X$S3%3Npp@$WmzaMXz~70D`Hss90eVu9j+m~^h$PNH8McT&>F!pj zWfk#$&7%3~!J|KMN|Js~y!U~Rn41FK3%-$W-I$S{ zzIx)ijioFYmHUY8uaL%SLXLQ)sm6ziQ!m55cigj6`j)u+O^FPk7|&RIvQgOnBsQj; zF_l{C=MX`X5nXN~5n)o+G9xi$?qY>tA};mguk3{J4r=C;F@^#4GI_H@?Y}{pC2Txl zy5Zneo`;&b^6QUVi-CaRKYl5s(&UoxSV|KB7DeNOO2-4Z}%A+@0(NC=$Wr;~Gf65r( zGsYD2$}lIEbVS(hI5)+;4CGME@NNCQMzlO{PphBZX`ZHc7Z~8cukkVLOdxiRMq6N( zH`F|^cnGabz3Dew!vBhwf_plg%x^O|UFDvFA^iY10`eB7u%e9Q|9m7{Xx63<*+%JZ zDv5+7E%SpQ{KLF=50bWG8R#GI#U`(QC1xOEe)Eo4cGI!hsd%>I$fvke{R6#25m;k` z0>EIf2>5*Tom(hY&^tn)6-|^JKy&`XQbPPz$C8RpBLhk78PAp}z1-$Ia=82Qg1@#o z?a1$Ce(M~!OKoexs_X9|6lmmq*zJF3UYXSVc#&Vcl6PX6bl_B`7h)F_Kv>En`enD7 zr{)xht=Pl}aahp^={vt!|CdYS4NHX?>Bv$%GN!dY756s$iiEvxMG)Yt-%2Cqah4OGwOCKjVxzJF0K%~Pw6z;PUw>aN{Arbl{xmD_ag@tNbn+I{8n==FTx!H?%urr%fPpQDZxU&s)2M^R zK0*bHS+_$QV0DCdZ`j^j-$(FQ$(dfQQ2+JC8RT4H(a+Q}9~cUcr^YY%fhuKaw@&Ul zdPjQxyFc*H0k{jhx%y{JFsI{X$Q?eDa=BU=R<}9ADT&avJIRK<-2TuJ zuSG>}GE_gQqN+5L$d9#LL~8N7J3mT6DN^pSmLayHM4C^f4)pte)k0+%HGg9ctX>D} z7PcccJ@4f_eNb31?XB?HXmmG02c=82^K^^-!w9oUv9B}}^r_+|`vXnXi#v@%UZYA` z(f4#fS=v>jaxW3RNuUQa>F|>=y%p9!)EPycCh^j4w|_NH2p>%>Cx4;6h`(n?lUm?j zqDl_%4^Qjg6idJOgOesqvh(FKYEjYk^Z~k*&+zHx?Rqks*wCdE;WqPDl0@I z7tG>=v-=Yh+kLEL1<1k){CyyvU0@fYP zE`68{rc~rvB)li2F3H+(>bOQnFZaqGZ(+M!f#c~q6hLt2$dNF|))Z59AMI*gWbbjfQ+q9C~XdbF9 zNIkUdC#axhwBclN7LyZ9$4db3=ZVs=HCew2rYe0Zg% zSB`Ckn}U*rB&_RVL+1SRsXP(Ax3r(3css>I^F$f$7%$G=ZnV6{hu9x78&UL}A9Oms zM()#p0NUjvks#JqLn!!P#RH=@a@Jf9TCt#YoQ1Y<+6k79CllCp<-T_0I6|G^WqIhw zZi#Ydy}=$hb=3%_6$$pAxiir=$*WOd-M55Pn6 z+;EByjahNg4G_YZ3#YI3K@%adTZ3{OK6n>tHTYu;hLe-3Z^|6>y5h^L4bJ&nwxRp^ z@6-K>@2Ogn={K7N@Dhesh=oXT2_gIZ_hwrhmJaf%)KV}z-zBxr50JfN-`?EruXO@( z$5%>*CSpqO5(%DGy4T;aCDmZo4)Pjc&oYR#O=hW1UT(>uMa%LeDTF444#;|FHTD^e zg3}^GGr2vqAt{QETn}{Ct*NuLrAOt`5_*%k|6Ej!_1`nK)hao)VD9@U{aCSd$Sl;; zibdSN^zo)Ot@hUtT~76G3!r=Qbl?Lu!A}*;U*kk_cSg=g3P2P~B{|o#>ik)XzFuuBYR(`j9UYjrdp`Qd<@1Q&C5gAs zUhh5lIAa_q^yr=)#xD~{{pr*_Rk7>I)_d(VAy$?=_GeeCPj;&d{;)*93M3I%1U}xA zi+@htGthr6UH9Z}4BEmIvYZU$>@nXwgA}&&3XOWa5$1>pC!y5LK|6H%yB?Wqkn7z0 zbRsJq&?(5RuP*2&seMY7{sgpwL+JHLW9U&>DpQY5QLvh(-DeK?K4a1hhbH{D=SJs$`b=ziDC=ccg!{|q}D0+Q0G z_;9h$ht@fusu7?+I1Id%u`e*vADa8fHT3G0g>A_5C)Q5M>q)>;VaSWB%=ztHZT>$B zk~aYD=Qr_Q#K&N{^{e8~U%$hdeN|J-nwfQ9zNK{7pBRha-?Gnxw4k5aJx>-D@BMIq zgqRSaxOhTeVs_R3!KY^^I0qqp^!dYthe!YJ)(t+ifBzsk`;x=*CrPnz!M{tl`w_l- z{Jwh!nC#xSv|@3gNMa`Uk?P%?#thQSQ&+ge&sNxN2* zDBVu03wPoSy0~BNTNaKx#4`DE_p3j~oR}M2SSW8Zvpt$X%-E+)d`I$q{MC>4MK63L z&^^3j#wfr--gwE~5Ccz_9VL2u$jdPb#PnD9o2Cj{GS}uM_Xa4CYZ(Q)Abr6~h^bFx zTTAtTTtzwrk&>8iV=MRym(t4nM7;@fd{fs7*nUT>vb$2+^W&ZGvq`k_n92vhj$O)G zvXYlO&|F8|2EAr;epwp26(hZeP_S&Ad1&%#a4d{~n+j6p*XA(UP1ZUL;do`2?#XX} z&lA^$`(2GEOIF{mgEQCE1tnW+$_~1U#yNeXNV*Ni!kd6D zX@k7s*@&*`{idF}x^^jj;^>xZ{d4RC2M`luk*Q-UPPkL~r@;Of2UP1Bopdv2*S_wZ zE6G1!RKe^{1d8uHkFEIX<$7eN;l4|6d(?$`cg+^}hPP1k6q(|Qdfvs?E5Mh8OHsGc zKuz6l6u!+O;ykP|!-u|arxE5*yz~HsUUY&j`)^sK!^t)KKD2l^yT{K>b~w?-UJ5$EqaxBs(wc}i~NZa(J-XltJL^HkIHFgUm3jKTCS zPg)oH_^00hK4E22}jLzgg-T>#V%*)3Z$|52Pp0IpNh=f)mv;G=^MqSCs6| zb(%K8Ph2oJ%)@hLvz@fmvQJ(}c0}fP-W~o(S$b>U$<)#&7Qeks7LbLC7}j4FPdMZ& zkN^3rT+NvJl;3!uX|vLm@3h?E=5HJAHYLe>`*n-KYS|;k`(crhkqe|1XSL_2D#yn` zDeH$1p3YWIC9XJyRf$Mu$qxGrYt)^wODQBGiGnR^TyyLMFsejekbG3{G`UdvvXF8q_;4apBhJJ6Df8e;C~EM<+X7mS_;Ln z*MZrY_%d;f9Rw8-mN(AAy?fp`sL~Ptu)i7~-h1OD4nk&0JeMH_FH`}v`e^!Bo+VV- zCrQz=0%3n38~fcwz6y|YE862>Bc4pn+BZZki-(@0Bk^G5^?BF&RF5JzDaN59V!EwD zYE7vwEq@&In9N|;u|e5!cHyK$${i8TX-QK)VDL8PJ`m)lqfv?BHq(;)Xdj;x&E(2r zju@GXYN5CnvroWZzN70?f2{lMwYI+RW10K@)!6Z$Xb z)jYTZgdX?aze(xs1pQ)KWj~L2cZoeV1;+*OeA@FbLaA3ddGf3qSNpLquLxxC^kMFo z6b4{~xW6lyhkvBU=j05xuv}Lbk5XVMEx4_~I^?qH380-HJ>_h~!4m$S&-5Sv6$w%V z!-<>#lu8SIXuGGZlMu){k58vXGA!n}(ewg91dwG`_>ma zx3`Vb+cvwoO)L)1%l4DUL5m&q6x5<7d&gnSt})_d;@NINLyYsAWzVMN=X z7T5zt-Z&PW7V->-DtC?t8<|_9ev4bH3=V}}MLuC1jnMu95hT)68J7rYxa!5zQptzO zdn8|&#lPez=2ZXuQ7mT6D^#c|l!pveoftELI)&PO|JYQpjlCSB0m#|7P9O-!5L2Sqw(QksRQ6rbQ^mVHbO?KY-9Rz=rMTzM zU|Vi#uk}Z6Vod#tLI#<5L<(Jhte=%j`#49Q)=o`58!5CsjYpc3HE+wkx+2!Rl(8;xPWUG!C zWUdJ)ZK==mr+6Vq7mVUceS+;`;_I19eUvFhF=B!<0h*-Yr8+wF7I{xui+4!{#ED~k z)IVg@8>gF>*7y1)7K$)5iRpBrL#UQ6Iz8sI=h6p@7+Q&^VaP`zodmd?+H?N$d@ZP{ zPO~}foJ%uFkhyUHi zt?#WgihUXlHHyV(knX$}4knOse^dp%eXK5|fIHw1%Y;9t-0_#1o{5p6=8FK|rJl{h zWEv7(kN+N684iM~qJ~KY=bRhi!G(19^IC)tZ!p>3Tn2>d(#_I{hkM9?Ujb>C(_*L{ zf?O(rAPxQ8&lrBg9<6XDrtoFcHA8I0wHq|W`N=GQNCIZx^spI@C+jQAJKnIM9{o%*;1%*HT z;b-bTrLuZNijV0a23dSr?v;@u*~i}e7tR&R36jWAHR^j1wi`;zSy5I3$0<{|@F7W2 zSSSU2K1jB#=fQq}5D_L!C?c<9kd;<7+&lNxUo&m`6kmylsF{wv^7bY!1ghn02$RuK`iOt9lQ+%np;^*KDA zD1j4A)~2SWLIubhvfIlJsgj_LnAqQ3?$8h-+NxoBOP%c~^_);_ZepYLR_$iVh3Y(<4{f)Zc} z_B*-8`j>#)b_C!eaaHf%HL)m+6{nH;+1Xjh{&H-4j~?aLb58ukH@GXt4~o}*;fx2t zNS^-oBbTp;5SdACru;7yLsd3~YW0xH6ip%a6qdfi*`jq(!$h_|C*||+-uFNnp+?0S z+^=ni{N|%zswnM)@B-ZAm0~z`gn-hlnV|Jg(FRfQSiBQnD6JtH+*!k*gIe-1(uJ$! z!JTPz3zNM4dgqZl7pVAPi{SvnV(Qhbq`x(;{#qL(6QGom>eVa4Tf6RdNL!GNukc0OAw~!mti=LV*-9{9&UnveeXfKK!IFr~+>ks9K^)x|k36EZ$t%1t=2jm+Bndk?a1q;Vh!()=icYpio4}Cx4UF1`!=dndfG^<&o0}*DmUm>8j67&le%G$w!r;a5PK5 z_56@GtHV$+8U=7%cTKj|wn{WKW!>w2#;BCf$Nk=WqI$Yn%|ak{9CI|$&zXUP^^=)+ z*H#iH$WK8tp5#0RGu>PC&+oY_Rw}PHz$9#OfeaM}3v*#tW?n5YoNyb32-|VSrf>X^ z@z>!^>6VddMq4~NyXl9m&~Us*tNfI+rp=SJGYOC~ zUX_m~uMsh1sPYGom6%1REK=R~Bp+D}eB`+xi-Nz^f#Y*LgaPR2Z6i_rPVAoNDu9@XVNbC9kv*uH@HPC!R zaI${y0;g`-Xa!0Al7_!K5v&axMz*f79Q;BNe8AnKQWpLCo63?jxJAc4dsV?8O$=Rp z<~}P;kKP~c^8dW{NU6s3>4KJp?TmI%D!4mT6%7caBxBiGI|^qg-@y%`x$PV?57PlrfsxI@v6#yKV!r(W)8T%da6bx+y#c&L)XY{Co?I-dyd3>@l#z}M6` z=wUb#A70^Qt`FD=|7c9{`KpZUALWQ#5R!-AnAyZHg>tjhtBr|{%%^=6adWHS`XX^= z%|$J{x{%pRdZ!6G1-&3$|0eo5mCTI^+8+sQ>Rpo!whF2lUI`Q0rBIC2UQkSItG6SE zG$>WK=Pj(v(w3A`HxF|V_>)iFqf<%?mJ9Ffi%v{6#`Yu$MADY&^eBnTFru7Sx4 zM(_Y8k|VVQup0wRJ?=Yr{fTDbcXHCgIWklUj0n;M3mNKP=J?23x>^<%j1}S>WlK@yf=;H6uxE(VBs{L5j^TXC-irhZNLNmaO<+m88U5 zGapd=)s~Xd49nZc*WA<%?^ApQ@;RE#4 zM)7w&c2&gAtKkR2ZYj!{Y+bs#=LddRG%uT6gB=vs~lPFOli+_!=s>8J43tb|kE3oCj-0x8$FQ_U@@49&coBtpBP3j(%7$9)p9} zL2=+Wjm{d3Zn3Z-*0E73yGCoxTY#wX#Cj{t@7ceHw+{0M&t}(785tRulOlYe2yYBo zh9z;yYSsd3ssY%)MR66?AcWHPt?jen@Iuvmo&a;HyaBUDLF>wFN1SYY9`~D&B)7;n zEmiqCdZ)H)zA~vOXrbs3blsaHs~dk$GQ_H+09!WdCnZm|LUc(yATh~hTFe0jw-f`H z3lkOk{t^@o_1AJYTY3UuhvT}omb^}ZngsA`557uWhMgNvfcB#B)0K3DTLc7XHC9wS z`T8_sbg*ciiz__W;bus?sqJ$zX5TJ89^R(^k+`e@u=|V6eNO!m^E_>F*K=}xejpu!32A7{UUeFWIzFm&+!;G#wwH}R0=He^jcwMJRyY-KPa6b_Tm|qSb*Jw zdqVNbr8jH2@LY!FqfXq>(IyBd^NZ!}+>#npLH580Wc}!_oh(0vcL*En#Br@=Tny=o zztdeK%;hWe%_dW^4Qoz*OG8>s%@X`iyS;{nX{%6%el^3fDm=$bZ0@{rn)D9Y1t@j( zFVek=vr6yt=WkVwO|6yfj$qUH&Pell*<0ytIyuP^RV75YYaTr%ZErBB=o1P8S-7a6XlH z24<7ZT>H|Ku?QGFETufxjUM)`_G*C2b;mA0tq>p8h&nzx|>rci*{k!`Yl5&O7` ztEp85b8@qA;zkO`{R^+O?|_)5$8t@~mS1wgDS;hrDCQ%2SyA+Olc^u{;1F)6x%c! zj%bJ`L7LM;E#pt%d1Kay#K*OF=Jv~~b^4~S=10HXH=khZUUuHS4QWM2=q|APCTTz# zAV4FYq7~^uFp9@9H9Yi&Iswf#LYo=}Iymc0sxBYCfjOQ|l52}G1rCjV>kC4qC7Hfs zX{_B>pV823*3@pim_~t=Hb`9!5pL!moJ$Yen6V~HZ!ISJ14TgtCwFotPw0`erOW`x z5Mi8vHnjubBlb^R17$oo9R$06s4F*05J=9_LE?xQ7$xSGJ@Dy#cJjKLe z@iGznvp}-Ygo+OwqL;k_=O?(eF`ir)x}*}2{BrgpPXm)+^Yqu{C#Z)pRf!&trDvaP zx5u7je|^tHh^59#H*@vLY9RqpR{%U(a^Wt!SpxVqiVc)g~zED}m zRo2NNgf$}+arB zKTsPxyT=mRNn;-5Br%?UX(NCauXrC5^2>c4END7hlI#b&4p^2Fo6tx zIcU}?k1*K~IfhTgb9#r-_nOh#`D7Kt|As1S0l|DUl}SL*%v+&MI$_K&ttdUG(!{y4 z&@IU7;X5vPP_cmXJ*^|&4&s@hqTbw70qQJAN{|^?WCykUlnhf5Unb~nv5X*zehXLf zKnsSo?c_#Ow8=%WN_opJmAgdGF>cu|lL{7pt?AV?__#b4E^C`U3``KIUkf9p93(v7 zB%No_tmpp?%HU8)$t(;jgKC|7QYKK5IlK+9rSY?sf|c5_@AaE&vKu$8%nK(hlg)f9 z$THY8xTlGBzRsI;hvk=AH4WMvBl7Q{cc7X32@ z{cvfcUH87JIJ32KG=ke9OJ9r6yh5@XrNtFw6%w6ptzAXsqH;X#A>lC?wU&$fxd`*} zdnFoJ5A;wvuT+PZkmqgi}p zBmE`xVk#^k$X*Dtg)Y_H^`%cuO{ZpEO}xtEaCW)mA2vT$IxdTGa+aMQ(aOG1l%=<} z-W7?-z(eND@r1zz6$`RlsqA|#|#5!&jE`;alrJD2)IDw`tC>f)^)q>d>7LF z3a4v7IFQR%3q_hxRko=+fr(We3%JAMpI?X^q#hz1UA#bVjqTbZNAQqGTSv`Wn2@vL zo};XYpi%m<)!<-#jF0?peLJWmd1LmSYb@LA2~mmw4os1!rk{PNDRRXA9Q_UL51W<1 z#jZs_<#_XfBR12bciFQtxBKw)^K}Q~Yjm#vbt zeIU`H;^4Rox!UrVBulb+f+kMJN#}=|LkZDT_BqkdNtr+TcM5`_r;=b5pO`n3SJJyR z^l0?8f#z2_k<)Xr7(uD?>#=t85CjvP#Kg+e<$!@wbAfL55Q}Cpz`82yz=j*}j^u$Sz zf_1)s6oRb9B~>5(LXZ_wWLTuM{`>fKf)QE#FHwx7-HD?EYw;p-syQRR`PKF^A$-rl zQa~_^L5X|XsJgQxbh!h+KJ=wP#(X53-rWUc&2c7q4|_XSsu>CV8Now*ebS{=m?6f8 zhpn5BVgs0Rdw#YTdl|@5q&fP){y-a}DnMjseLo)13Q!(Yu;^($bttJs1^y+mD@=qw z`%!Ty(qgYVVaeJ?)F2OvgPica2sZmmQdx*8D5O}a8B03C%(>JCWdcBQQfK0Q&3k`M zZnM$9*6}wT)~Y83_&Zf3-tqC`Xjn7c3u{d|8S8#0-8hu^aP4(jT}P-Y>S?yqJtK@= z%QoHL8^zU{CsZ5yq{F9mz-@1f(Y1ECwG?lZpo$L5Q>jlHzdP^HP_dhiB(J`8j}(UT zh3;|u4L{>;dVdK(b6lNR8{61sXF+Q`4)K-JtzB=vS~k}F=D2KWuScG&cw-q-f1Btm zE9JFG)A;_c{#%jq)}K=U{a&bxF&uE;7Zf#Ky`n)(A$wO<^idNlLEU$$(ZNnXn!2Y( zwF5y=pJyw_73*nkXMZhL ze8{@Q=fe!%%CQrdFvHT}EKRoP8V^*M=x0I$544{J`IHB@2q()J4$SJ!*|u3$IbpI$ zF8j0aLBnpquN~KT5t!UeEFqbLCDd!rkjS($)N;OI`aF^eY1GGO$_r6OeFT~`iRkow z+eh-w?POIH%SSof8|lK~TTuZX=U+`zA{s%#MPKPe|KsvBC7LW}+bG>9XJSWMA9x7Jy zED*aD!6d>Er5h|m086@sel*!N&{^?&L3F9nw@}A+Jx8X1HESgpkYkJSI*&$3HkK?~ zvtyoo*YQOQewU31&;&McM-1hbni0n7XBM#V?O{Ulzqq+;ta|?G&2K1g!TzlWP-*=< z*~V3}cAw*fELa7C&nY+3L6>|rh)xT@Y5k-3*D|wt|G3{8lr}LePlxaZM8~8Z6m?W8 z7A8ii%dN8LDiolq&@l$;0>FtrSd72cEfy*nl&f$;fm73^!402*s0r&JZLlay2b1q_ zZECd@pTVIf7!9ECMyjbA9fDA=U1(tJMfso{&Y24;>!6=9iJ@>q8xM$s&$HKF{tlY2 zHXcA#2gg-#m z8!9?Ei_5t)HbeSLniPBo@nq_7Fzp~Ru`-tKaWg*%Oo$~F_|NP-%~`^pgy#B?Rgt-X zN!R;N*zs*;iQYUAl4Yp>cHeI`inY7}nB*SMS?k=03LLRt>1Xo0%iY+<02yfRZb^$0 z2=L`>Y@naDi2%lr+m+x$Jnz#t0ehS9+WNLckJmFg8hBi-h0+2u-rV-+Mi5#-Z7Yr@ z8pyBc-`-Q)?1B=0vLK+4&HGr!5r>uTfvJ#W9uayZ15{8+&Kx>sa`79Y5L%c5rj3;; zWb5te2l^%0d1VQ*%uc_pkij}P?#MPpgyX5!?VsH1U>*=mJ_zIK=U~UdIv}^6SdF@J zsHygGpAL9gkwIn5g(gTRo9vGS860#hpsl_#8ObEEMTV*YBi|>@eYweBoLU@N+H-Zh{_>DRZR% z%88ZV=@tkE42N7ea@N5owrvY=4X#HoPz1R4v0S+68^lj_7*(|38+4Ueg*+NSa|i7v zubs!2_f(h^#&1f{kx2fxGn#RUeE6Vw;L97MryO>WNFWXrHqS^$a6IoVT-TFZvfuVJ zp=Ww9O*DK6vOPN`*W%0EQ~UindYgKtS;7{(Mk+IC?^fd3`Qi}6>~no_Qo=mf(h?YU z5Fd7+e$Q#zw^gdX$E)mih5glIK1oe)<`z5^t~bkGYXtD8&!?21U)*)#dbsQ-HVewT zX$D8ipPw6<@{YLMM2lF+3rszZQ!$x;!-iKmR-siluK{ zdYfeknd!;#&eT-$8#pP)F6|_m>pwUtjiMr=+7n47pQ=fcbJiEc=wZRC6MRc8Ke)$0 z_wq|M3crSX__%@wewqz(ZXV`pRh_8iu8h&4c2KwtnWwzM0GC7ygm-5FQ-0ThIEzZb zlAf}+?Kq~ox%GM$gGjnqS4TO6ATUY=%jL6P|>GeG5F3vDdOrL#&NN) z6-AW&gsQbxv&Xk=sv~KHmGh2rJSRt zU*{aya91u&p9CmeZyzMj4u5Q%zE7JiXv&d)^GYs}RAX+t!>%)?L2MKOhJ1ctgAV!c zye7-&<;{J1P2x=d7R9-$vu+ZhGGHW;gBnMGECFcN z-F(eGcn*fB8#2jGgB*k*h*0eu0cvE{?$@gbq7h({SRdG|qPFbFNZYn(R(o*mb=jOa`ArlLQ-r5s#4l}dkADc92eev_fdIUZ8O_R8jqWsTkp_NcZosv;>nq)Lrf*{ zzgDM&Vg-=4%3i;Iq|zq6{*XLcorVi5efuLx@Rj8ma0Qfy0ie-qO=sx^0~!igEW1ho zdY}Xi6|n~J=L)&2ntCO7Dnv2+#&2^EhLp^=$y3m0=xu&9@&C#$=ooAm#f!g%6H0 z%04(qX_&1^<4>UiO!gc(qiP(e`d5QAsY^_zPpS;pl2%GELJ}XkqivhozWCAryCiJ! z7fUV>G#dH?1fd~Q1f!u!Qki~I%)~N4p#j1J%x#lxBQEI6U~w{PyuJFu{gy`f-J%P0 z2<~${CffXc9wycUJjd~$0;|E#IlrFd>R@;2X*pv#2qbPCuuw|@V~ax(5m4@&4^XGIcOE&7&q zJ%Dxtj^uu0W5n!P&2@=3#-vaNH~P~YDhs%k@VjFuXOv{vC+?bjmsTD(diK}#PqPwI zz@7Ee8)>L$!lH&J0G!hSQBeO;En?gvRJK%@F1l}+IF9vO{nyO?*Y*3?3~H$~G)=Fy zS>!C!T?xe8Jilq{>ILRpSLtbCJ}k7`?HOa?-O8x`k}3UrsQmyag=Xaz9;bn)%($}g zbrYGflb#iK8B@6II}x>!ErutZBNLFZ2#$(jEf{`$D`RY<{45B%Kd0V4T5-2Nc}~-) zjz_jzh%9|aXQ$f&zp4a(S+ALCGbybvc~>jU`WfZGqL^C|`MTWN+-fFv;A9FgRE6+A zM_Aj@;ydf=hsJVDSxE_^#(0l(Iu-dDcNGq`t*Do;A81*rBqD(>$$0~+9l*l-WU#v( zZv+KOA(Ic^EOx0dun=5?7E96l0M_h5{K!K-)$2H zb6$kS;2}I$=Cj=i1i2M8dL0n=@C|YO>E@u*rb!R4HTdP%Mng`ZF}vcdAi>qz4Y|!X z%%PaN7111}`{;Q|7^msCB+X<=``NTt9iEVZSM@ptcVuiQmJ@aU*}jb;-@7gz;fd0; z7+)@v26wvL^_V?u z9J=xp;7PwWg3xx)c9{g?ACO0-ey}<5p2yH?HZIrA<%H_reFW~UJtJ=9%CGWPecpy;mAH%eC@U$rQZd0utm zlEbrGy5!j0-f;ENR$vtPx#=0f)4ADVXZbDgHk`Qq6%4spHRg4!R(GXPqX@v&yc2su zlOI^P@8^$QU($W4hmR@x@e%9l)9r_6s46cg^Adci7}I-qpHdvi6dVU ze0Evm=je>{r4$QB2^%|%uTWQavk%whqvc;8h5YgPJ_41!rNH!s7d5G^7Y4zUF8`K> z-oAY|u5cu2t##roL`26CKD&m^Qa-=mf1xs&fdm7RN6?%cVv z_uG;EwWPuH!hHKL8$h13+4E@&P_JFG!LAN%?=rypssP*NXPvc!@Ioi+FSDb5xFosv zEq{w$k5B0T*Fpg+X&g2Bs{1#?pf347G+o8FnDZqWF zH>*iCi(7w#X8DO4+hx^RFj3y=9OHdj zA1~>=8jtQ&59HETz;BNVsca<)t|hG&+?Ug_O;4%uj!ysHM;DaL!>_KbeK;!H=N?Cr z$0;VC*KM@OyKHQmkrhAgD38Yxm3e8?3W~6eaBrEuJhPsd0)Vd;DjM^-?1-z-r;8?) z-JcW(4jvb}+!#hV; z4RFkz1#0_c_wc%&7Cm?lRqaP6D@iOQcYiLhzm~pp*zJERkdE=twG2OWBe41MMZcQ? zc=hbSieAG^PrMU$5n;pq-O5zu5GQE*pJZ-KLs|MqT>L@!202&DXNqq%DF6KZ;d%!=9he!$=OYECU;bK0%IC50F7R zV0I$w+JoxLU|PbGC5S~;MDPY5!vyz*J!}Zv+qL;d*Xd##$!%ZI-Z;v%y@n8Xb29cM zPCpQ{ac_g_;Dkls*MljQG!&yKeV)~3==$3>hB*Lo-Ys=9ne;fl+BH?kNzwaiqml?O z=v$8vlegV#3YG304XJ24rSPeRv<+Wqd~)s5cgfzIJSgi&rq}9cry8zd`!d#f$t)K> zKlr8plwa-|5wbnVE_(t?FAf$fuWCEL=h!WkwdeMl6}6P1|yJbRVB`@NT&I88QZp} z$TLoP|A1Mh^UrTFsai~r9khd>O0wuE&&N`9@ha(mZn)laF&du+6t$|7qz^n`g%7iP zQjZqdQ)Tg9uF{*my3qX*u}*c^rJ3Oxt7#n<{K{1AVjduulQZo~*={?P3R#n*Q`a_= zRSpEB<3dq<8JDRq;?!N9Xn)kC_L)on5<47|f1LWgFY1<@eN+mtaeR)+sJ>V<;Y%U< z$fg$iH|~+Q;+W~0F?R$NaqlpBH~cHd^H86fUa5zYX5`GIX3D>Ox~ezYr@5lb#!uRO zAShNOu$P{^Ha6>s3!A|Ue-R7l`r{Z}{$t;}p3TE5Q|t~;N#U4KoV%~!7t-Ag-Zzv$ zDOjG1Qhh4A;I6WL#w3Khyzu5q3NA+6;>WW;5IPa!7lAT8R>BvPj`e z{zT>^s0sMupyXJ<`>k9|v2B37nk0khaASdV)3WKZQkNj?p5;{Kmeh*DeY{WxN7Z$B zte>;jsg1-Pm=*6mt!YOf5kWr%#iZr^U?W9K8VKCgh4u+2X|HLZYj<`esXp6k+Kc3@ zrlh~%&%^h71|FELTNnSZ)o7c!$D(G$L5V?F69H^NYG#0=P3^2W zik~elDJE2?d_qCtuBJoW$cw$gl9G6gW3Q}P`kdK;$y3t3)%a8S*Sx85>pUO?3KuC2wYHmSkD7~~m@v0(2{Fy+ zaR9PjXBvDByd!i@;Q19h&cTQD_B6ecEyQ>1&mBYESpSQ}&uv63QrZO!oSbIW`Qtr47mV?!f+F31B?MJ^T_)%Q+QPyOacS#7o{`HGDzu*tMZp_(wae zr*8Ca*2{IU5QiqcPFL5x1V@}A%etOCwponJfL5|t;U_a;TqXa;Qm;nIqmtm{)29?c z*j$eGL!WB8^8@=^N?Plky7JLVU>_L|Mi5zXT8}TWW#_^$A_32F5uF5zUU!Zaja& z-ucD|RkYr+nlXATbHa=WT5WPp!#0$hv|uRT1Xi1t@l3+*a74(4piaIHrsmD_^Sn#B z+^R+y1Tx7(7M;jl2PC+Sg~~^fAHg<0_zcr8DXkARA%Vf_iNUEbVDUWx;;OX zV=sWj$LWxZGB};p?+1T@?AZRwC1M$G#<2>PSiQfxS!iD7{Ph);isL(ttCp2jPX#E% zAxA;1&#sK5KTVf}Q>$ND`r5k$htdxij8wlp;ySG3w0iIZYGAzU9OvQh@OrgA<=D&RzOZ{r(46RYUe--TnVE*Fj_GXc-1ixpyiH9spwS?}t|G@7jv_!}M zEFdor`Bkp?#B&2k3zznXDp%c&^9|h)zSNlTeg^S54pq?={|$k7t%gfX>VG|1lDb92 zYB;ssQ)0-GV&c=jT{P*2V@_@rzdPmLqe49eh99S^hz1$t7#FIDUhfQzGm1};*=LiT zechiI9iSs|M=j_|fZM8I4}(Jry++cgB#JiAFC+R#pY3MGuy*X@VGu3sdqWbhbgwB5 z5aSl4$sgxzGNMf!_M3P|9b1jZy}ow-8)~2KJP3+oh>ltG2PtTCxS=!sD(@=jb=}Q3 zm;xN*Oc(fQ0X!SH5D+Y%ULABXh?>ZCy|NnorwjdAaE67x{Lz!Rk;27Jq7>|ww}Uks zFm#YPSVg%gbeXJO(;1a+cAt<0O3(eyJPsJr|18k}f&qa#1e)BjeRz~-S(ZLF7T|f2 z#a?`*?GpsYRs6YyzMluvn)rhn5Ah8*AEjS<%clke7wanP?K11&W-dg-d~Q)z&;Wc* z0M9{5UVor)Ty{*mqja}HgXUQrhK#9)s%{WW)GDPDET)#0Q~Jnw95EFTpIBYc7xCuC&|cn&RiWiM?U+sb=xw2{zH zc>cME$Y^q;-sZj#OY_|ery|5ejiM$r3p0TPL)r6t)*q`euPx?iXe^Z4myVQ7Nq{{= zxEhZ6jzjAws;Mc$@()vf%{Nu8Z=A9KPVf>>IjJ6Nid(gNe}SddLF;pXv#tUVy?Q>~ zG@o8aI{`m5CHoZLJVup1Fv(nSOBxsZ}SH-v0Y+Qw$KH$Ym#`;pD@n&vaOQup=AA(^w;um@u9=y)Z-l z@PR*H{T8*zBasf7nmt)v-;~<(TEJ?4x?}Bobd;_^V39J?%XoL?>!h5Z`d!LsIgnon z!-MCpgg*;8UOR>L+9rJIe5@{-(5ERFV zCH8IGSQ|W=zNYmbvb6I6ikn>l{8nT_)wQEm-)VHoyJ^0^M5p-(!kcFtb6?fek?3Si zOp8wU1k_$*n-I;^YaFeevUEthdEIE3CHy|H+hs@+493^D`0f_>_adBd?^aWwx*wSE zU&8TE5h<$-*kY})^s=OUw8e&u!8%y*ab*zS=bSW`W9XGa56ZkwX%`JlK8n{lbl$u^ z$t`~RVxBr~T9PPpa1Ti1ByI^BWL1~DDUN_9?YGTbB?8>`O5~JH>{QlGLAt5+I@oo9~$DOy_W7 z6!=g_S?)eQJPE3wz~GY74z^3DMq@TbGR8tJsI?f9155!dxO1T}QfuM@MG^;gYCJE* zS&`DM2Guk2sO$V*NA@~p+Kl8$Q|G;qqHFlk5+J}JqH%zAHRc9DLo(^2y=#G7+Rgq7MTlI?aiJ zJs7WGoi{EcCEvC22e3wR`VKzJ@XHKu$j_c-rjn(bKgEtl+Nr%B9CZZlq||3TKPI=2 z8mXzF`RgyaI24`}nInfDkD5rfF-p%i`95%eOofbm4OTNsD>!syfCOLUdZl1!`26Mq z`)-K}(#FhEh$98yb*|r{y*>vIb`D8-Fc0FKe>NmAl*|=u{?l1huE`^uMNZ)3Rd#Z9 z-LI}kQ@m{rjAztZvfZRdeoI$ZOk~ph=dS6wi-xmv?W?WV4HstG2mf3j+v&UxL}2c&h*Jf9-4EYj5{n zWOJ>|mN3X^UHx6cJ4IE&NS%xc*iiaKivnBs{<1Pfj=Q>JEUHfAVJC?qE`xG6E` z2$-*P4X0hhqhfHc-f8;^a$0u30}Bm(apC$TgRZ!ZD9f6rTIP|^ zI#PJ*kD$}#mtp^u9%}I;u}ZLTK0%dHHE6Vx)%Fd5jaIvL?p3ISQ0NGN!hcNUld#b> z*f+Ck)`0mQ?tsG?q91yHc+zui2mj**;3d*R4vo8^1BL^NLdY{@)`#5MR*K4avq*zuxnL$dR3uh?Ub0UWN%oGfMUn9i2cG-GkOEohp_bv5*B|VjaIo;{ zlhVb_h=SLb=l)pGkhI>}=aB=ul(Dd1`7wdHf*Ac78!uGXGCfh)?}2L7Gh2wfn$poi znEkWGbG36GK#V{7_=aiT{axq3RYQF(kDt-!b_eh8YUbTTMfI^hIejLmRwc825DuPC zI#c*Q&`Ky9soord+R1f)ilLggfBCU>{Ql)}bb00ZHLvH~0ovbf7n3A4w=1geHpQ#MO%L9CpDYOsjSTDc zv!#5FkBbv5{m~Uk<)utT$axiBBK)$hp)T<()4Wkpamn^MI}nqdro~&M_o-joc@g%r zdZB0X6+QJqP<>;<&W^R;lSaGHGtT4dru#vjWXIKMK&HT9Q5+0?y70W?pgjz1=MbP~ zq~W*}WC79V$UV{oe8O?S-K~p$n2B;c4r&#wl*7bVgWoqT_LuepO{WCfdyQsG#`#j;$0j!HK{s;C|#WfqY%{I<5PF>9=RfTmYFfL*B zRyQV*RG^3i%Q;NHGZWlkxOIzN1X@O|0p7i{2OP8?&k~#p9}3=q;YtFUQ1JVJj>XNn z>JS{r{*U}GTZpktw6jDBDa#{j}x0UALXDc+Rw1 zNq(qr!k(EtMUBAGJ$A#6!6x^QYX@CQ8w{?)wg--bsn0qF-+(L8n=jd2au65<;;F@8n zmR;;buK1q>`E02-%F72)PJz4I4nMklH{Oh3j?V7e+R>YRKW0LHIMwy@-w>*n{6KuM znqHVCUgPC^RAM{~tgC;<7*u8V@(@nEF$r1kno1!Z7h zpop||x;o;$$2_spv}VuU75aW>PTR`&#)O=lfrWK+b*{(u&6^MdyIn$KqkPNA+WzwI z?QDsjj81j6A3D7yM(&R}UNxCT#nY-p=;-Mv>WV)S+}`R+(IVj~os>j2w6qE?nO|`0 z?X_(GWB2I1;>0wDNTYf{iKAqQj&U!5JoH3d4vYYl0b%{6n@S-Wz|$mdPXU=1mdF^q zkj2C!iTg2Sz|!Nz%UdS9C)N;-g2?5Zxb*rD0GA3N8`?Fe3Dz^~JEgn`fgba6#WWmUa9+qc~m zvuV(pMd>d{Ck<|R`#r$7D~~~(*rqomrl>$S!xR7PF#*Tm+n6n$aLW!hK#S; zu2!-=7u%e_{<$>+sXv3@d|c#el=%Lc2*XsO2y+Mz1q8Z8yer%TD1xn^;z=TaP7wa{ zF|cE)#^bzS2*rvMJAk6lir_K75x-=PgU|!ZC^X3K3KF+u3dmM~NyGO@0(e}&b`+98 zQE{{eo=V$*G=S)tWPoD?1|)*f%R6D3Ac5~8F3L6(j178-P9_(#vQSMhqWs31pnQDn zCdI@Mk|v5TtC@%HJs>6f5qANUA*dnP5Hj9!a?8Zo8c_Zb7^9L#Nf0~=KXkhrhxQ8A ze6#vQJebj7J{s{&e3`fpkA%80uvgjnAZ|WyA)|MZ2vGv_eGTrh)Qb0Q)BmkSx`d&j zAP9BUO3;DWC|Hy36Tt? zAK>|2ysJ~A*v$lhZaQ~pOi#bNJc&B&aDt4FqHnT?>PK-{kB)w7t2se_kPf8}WxJ8L zdrPxTY4cG9H|C^o8hUY-Jf)z6yWaptu;f5R=l^pv9x61GiLb`i`bp0d?tJfRHLN#P zt&o%0`jQKMtrg0SN19uq9Q}8jK>a(F-MFK>z-Ax^$(ar}aThFWL2SE^v>Xj9xbHl+ zbN1vGe>6Dzb*Wiwe)P*;(puea|H$+^Q2%7#Xl+YJ)Z3{$+wQ*x0^5X9xOQC*%6F|T z0$;xdOO5*N{VAE#J5S&CaBz5NXzS>^Y7Th5pBt9IeD>^5g2@1x&codP7ph-m6+^#V z+vx{p#n;R}XF<_8M=1I;r`=!hVk9~|f{GU!?HZc6Cjqd29{S%{xZg2D15fSj3!#a= zW-p_}18>*ic(cm&GUbOq8b2P~Qk9pV;k` z7vpa@QviIy-Z*ou%e4{~=`2no9OJbTIe9>g=U)`tHDC^q!Cl9x3jkK8i1Ko@l0#yY zqiCv&2N;l`3T%b|IMt7E@=i?33@fI&hP6RScS0qLj{*5%CG`j1<;*(Tti&%*$7eg;S!78!%M)5mPJSnI$V2u&bgV~gt7Tgf4I9_SlLG}0{$oby%KA7kN7*l4fEP-{E6+sGKBbqL z$R?Lc9gcrIvL7P#^Xx(3$bseGkGUlen|`yg4{oM;$vn2kiDUPg)nTo zQ2jyD#9mX@4-BNyxuv_Bqou*<_u;^Y_IfYW_eaxa?w+N;+@bmKXklJtw4-@=)Wm3% z5gs{6Clv&krPh?hunwIrobnx=1u!g9`C`-`oLT={WeTT?1PtaJ=oG+aP z5qy7Mi`8bvWNnWwBALcT)%*UuxXrl{ksLBSmDE~7U16*f-8<)`sT}u!J5q-JZW6PfOV5$7#_PVrj^Y=1cxbL9)0$$@2 zqoT1;ny-Dyh6-LNjUs1RNig3$b2;J{uRR+{j|2r>KlxpC6u#E7 zL!%$NUO^JcnIdTV{#BD*NCderdbU4FfP_fHRVx~8!8yUOfulG+ zf(g2iwnjU;g3}bPG<`K1Eu~9NCbs@v^(uXu_bp0mo^!)twts)!WdFd^v<-+|FwKyk zo~x|b#z6iWxx7I_UrX6E@>(eUNF_IsLh<1Ur;PNUMWHB>h4h)5+`PxNTzp|s9(OWv;u-pu@zp{f9n1+L^PhN-)XkfhbZ3@-wojiu3*F$~`&R#) zPDNv_@~%L&JeU9R=3OeNM1S(aQ#f^K}B?{*gyiPXZG)Mj?Q- ze}@y`n=GMIrm1NK8_Qbg`2CFM~CDA|=|p~wQp%vNJ3mu^(xWaox+>?vjY?N>0UZ^FH8#i90VA@kMIWj(>uH@&TCZ)PsTi6N> zaywiR2_m}4PNUz@wfR+U=>hKwCzG4j+6r=8Osh3t?yzD!E9|(=*l&D1b0mp?w0|NA zFh)V>J-|(i)8`e>ZKEp|hzwbbf`r~lmX3Q@_110U8ZRw!UT6f?W9_}jLq?`MW>0G! zf)Y}9r-~1aOqJrcwR6EJoD?;QmM>-qfpamNz7>iAo;3YrVCHwtff0ZBMLP@m=dy{| z-37*e0Px@>|JQKQMZ4sPUhxY(#aJ=>W5EczZ_8)@Xgl5YU?>e7V4s)FWX(e;py4dc zVQs^#1+?fB&80|$afk%omK!!?#-56FQX ze9<9aUg0jOm|W_*dhg84A69h%_(oR6V%*_w0?fV=uX$-ZTpHOwTQrjqwipL9f-FFzVRb3exO+!O-n5_=*}C#_!;P2tLO= zER(aNtr+WNis(AGa6qwcbJ{V_l6vnOd}5}1dHs}fGVW)A8PIPPJ|8G+ol^KX(frBf zK)7HkJ>d=fTD)ATpcPCjI5k5{F{&?s4<22VJLu4Mw^ACKQ&diR;|4S5vLggJi?3Z^ z6}n%YF*>d=;wAndfAf9<{(#G=cV#X;aihrZ7DwUHUziV50y5SL)guwb^u$CM4+!FF ztNv7b3QAPYK}Gkvc%Z+k^}z)_s~mA@J$}GJMgx99b^xy*$3jYbkyti#2A?p$WJY0B zt1Foc55C^ST*LzY2Z3-N&euRQiGE4KkE4%v%zqf7=SLANK+$YBYgrUrO2Tn)fBW>H7CS* zP#wrI%rlDPIAnPs{-W}0kpkW2^0)z>L@jW^Bx?Pi=_Dzls;VS*lSZ;_gii6NQ*>L} z%$P#HIFUg6gr0R`vESFb>QV|A=-IExndtvBGOHs^kU9p&7xtg2(U|g~+P&)D|ID5C zmbNzKysdL6Mz$Ib{;GXEaoJHOu1$o{d29v$HQu*A4_wbyZ+O<>1YRnW!mFs#_`H5v zl=sx{o|4g{6!p9HfCO~EuSU>3hqWPtA902lwWdP-c$_npDc}>lp;c=Sz#EaQjn}R1 zTz4l?xM50afz7xvinBIge=mVIdl|(tv707Or}vN&x;eEKM#cn!dOX($j5=!F*xtW| z5%&clGJ4^T%RbqiF{H60qG1p*FaYwsO)5Fq{Y(u*8;m81k8HGkxnW4vLnl0UJrW-g z*TRDgNAWU!xgKTfZ48iYp$(eEBypev)r4!=#8V&ha26_z%w*PmYFfx7_ttJ4hvz_r zv$zw1F?s~7clz~vdo~x{q!Gk>1Sv00l3327v?g0JiYLXB@MdbuUdVI9xDH;=Ep&ej z?I8D~6E1v;pbt&iAQegu?s7B!D<~h#L=FRsPzDs!T0*W3U8w1sE&1~VzSucXXL-M2 z?_&095amAdBkwQGA&;y$(XY^ZbHp75vEO->Z1?WV!Yo$UpDZ3Mu1SU?A zFbV+X@pumSKjeaaF)WzRP~hBGg?o8xg87E%$455j-W;IDV8+1SC&Vl7QfocIB@cS7 z{?Upi{qV921w$p6cKY%?x|cf0)QoKq^nn(zcxd67``MDMD4hGCM)?-td?V)sp+@U| zYk>tIZ|luNNn<(xS7?QYSZjyi_W{HUMk5AnI924OalStX%r|TIRNf{%lp+}l3{U>6 zWFF>A80@W)`raZk{fEkG=uy#)5pQ^nZNbInn|0y?lJJvpF$*;t_9eBu^E6SA+!Hte zoSOKXe3luD>X6!I#ebd}cb-sVi`yj#bT=>b5dv*8yEJd|Kl&T%@n}7(g(maa^xX7^ z^w~75=;DRB55_5t4#N5T5Dmg1(RFUqFX+k8X2hB7Dyi7ba7TZWVMIu+yEKWT_o5fZ zOkvA6tbfGU*5O1bP4QUpwjK&YtXJp8|JkdLb_b5_3EY<>trgKaN=iGxvj~jL!pT19E}*LbE2^rdJ*e?XL-CEdPkP;|yTBY*267T{`r= zAPoH;2SH`pVMZGwz$byy9u0S?b0ASv5mCx(kx3{KL-Vd4I`QQ+a$;xnN5twEC7L~C zH|%ooAX(c%cTg3?cMW!Dx=a!XeCk6A{i zo<9txbBUVA2p9I17>seMO120G1$#&N5S2Kk{A5+FvUCV;ipu7V6E_PQh3$XI7 z-DAt~sHRIEH_Fi>-=S-J1%Qep`Gf@O+bbcS1W}p|Z2t;{4&50K^pLxWqo+4-P0yJU zpC>RC_VOX_6n#oAmb%oWlrJW} zrABN^7g|jX!wQ8$zUTqBB@z5zg`8_nfw{%yW}%{?HW6GP@b2sp%UQBxF*nh$($)2# z8-V)2^gP3G6Mm*#p+MN1B&j$M)3+ct!iz<|NXiBUpvEtSwp~JaR2>jd&eKnxJ8jA0qwyz~c^nn&Jj~|J+aSJA2bUEv>cFi+>(G zpR8FEq1#he^BB!nQC%l3&wj1;w{Zd z*wU=y&00-~4WuQ(oQ3|6e!Vgt2Cy&rcq2%Gz;ET`si!ueZ&aS-+9uaeUTXBO^+V5; zVpcSHQd3xgeEVnMoWmaDBVAX;FRRfWq)HTuZIwwvUEJ0R8QR8dG~rZfRJdz%Bqb z({TM3($G}27gJyN6A5v_r%fczkP zxVooOP@A3CosZqmGgm|D!7@QF1oiTO9ie6e9rzaOEGz_RT=_^4mFS&|M6cn_1aEse zbEG?5*G!%Z?n@>E+4qBz>AZExRY*5;blEf4KCdE2Wu6vANkyu1y{L9+3|Q_7m0^i_ z^1Ll!SaY0|&-lVCyKI=B+n^DjGcg!^x1eyhQ6rW}W!JOL{`-G@N3E<$Zzo0SQ4TXq zCgvd&yGhMJSGoDuBq$bwX>3sGRsSD>V{SUmd`ey5CeW7M@8F=GL}WnFX00>p(o9my zLUb$V9U3_Mn4`vOz-e`kJw_0ccK^p6i;Sxix+1}SWcFYh}>;_CN`(xBcPYNuUV~Ei(1}EDc^F_!mG3LuW zEEQls-^~X$#lFq20C6Sk=O3uGr@sCb;DX)d3%=2&xI6d<;^DhAOzv_G?VALg5|9YyZF)G(eRq`yu2^aCFfy>uT_LK5uVKhw{>$P z)3&gFXVJw$9w-1>!2w~|Sk*?K^uh*y?i=Zj#D~-*iEHKe{Ittrwxn;;;>V!y1o9k0 z(_^&<3lP9HNjW#Dxd1t zLiYXBk9MA`0Jv!m+`M(v&3ujxNv*_qi3*P; z2Tl}VcT6dKZz*?}1!kx47O=VINr@%F-sqs8}d#4+<&oKF7i~9jM zD+p0_H)IGC|HA#0fhHu=`X}C{S0XX{9>mVn|KFyJ7$1Qr{u4zM2s9ngC4cQ;1#=Z&gAy%Jek& zsfj6IH;fZNMI)IeeYA;hz~nTvPJNLoHh>f7H_Pgoxv@Q~3E!iIpEJ->K@ca7Q2XW{ z-bI!?F;6@j>#E z(I;Vt%8Kod?V9(-fQQdD`R9?eE{WL!#xCn=QX{NPEM`OTfeV_bh?z;(af){)pC)Ej zw#;~;uSOoQADe8nv#9w&YZzhy(rj(vR7XHAr^V-3+Y!3ahK}jJemcY)aEQ*D!?Zv< zr|vZO=YAgiR#xzgTJuqz%oT%8d@sB7gK&nP>RLic`#(?4$jX{uo(FZZnm;=jar9R~ zEQgW%V45WhEb>GF)=2+?o^4*c*0BvOUd_sa9YRq17X;5T@xY%6U%NZJHDkm^TVn%H z68`;%1K$>;rKHS>p^Sj}@TdJ{+FS|hy zp;_b00#0KGf35P-k;IluGan86v>KzFU)vqaKa*d^4&K5o=C1ke`GXPUlt2Ttnypc8 z{i|%m@IXr>0@5W65;16900+HkeSgz(d)Tmymm^aSAJrYPIRU{ zbbL;I01e4RWZZWc)*YI-iSw)b;RU0sOyM2tWsTLk@JaxI!;2RJ`I;h~Da7t6*#cLuMc`9Z2cQE%Z^Ez*oZ*t0B}(#{phmyby-KnC*t^8KhF&V9;p@w z`cOf9tuxl=LUh)mLtA?^FdTqR=m(QS2q2szqc&xwGZZ5)WA$IjBPjr_X+iK%^EsK> zowAJv=8Z}CVp&OuA4B3skR;GwlE0<)x#E`0+CFk^_F{a9cuuyVmVd6#3?r_q5>a@d zL8I@QAou5;5*^<(1B4%ONN0>@W7P=$pBicKVxrCtXFyI5l)WehYMc0(V$~Fe1H*;i51#uAPgs5WofRmRFg0xOG@RCXnBNfmJ>p9U z1N#vdqNKp)XOgHDF3V=-Tw<50#uR4sw2SB3o0_hfsX+e#Kf>M9d|I^k&fssGSiQq{ zqdzr^cVB%AQSZ2J^HQVGwqWVe*Sk@9Da53IK0FndS}YtXD$K<%|2_Gt`?w%U2w?3G zz(TM>VDSY&gaq8t3&ntD@7b}WLT)rf#&qc17-DFO)2I*L;{vFwb***ciT#tS3Th9` z8G&KZ@u9;72i}a`Wjf;wc=k)nnwk_yAjfoeBGZHE(iCXGewos2Z&Xi{gh?iPCg73{ zQm4w?>kYdNwF4M<~Y9L1JSt&ce{>Yr0VwCp@gSv|pkCm!B+nzp;!kxZRQKmU@s= zpqhN`+k7taRGQmhIpa3?m9g9*pWinWC8o_3y0#DMZ ziAohSwz#IhGs?Ca+}#9F$@_QJUl11#L^c^UQKQQLWV?rHHA&;=3iYb`1wf8A(?WMT zOw0!}V3{e*@ae5h+;2*${C?IKbAM_3x78y(>7g%5BX$7e*q=zLgAQePz3!TTD_;d0N}EV~IZP{Z zxnq_Z8Y2`8boV-Z0;%iQ^)7#_0XqYf45$~}G_h$0aA^O_&jN(t<*N38#4>%Xa$u3X zKzk|bqzOIzt=NNJvn7CWSn-pW(4Y&0@1qJi` zj8-!-=IET?!h%ENN{WT&?s@PT$G)6X8E<&<^U@H>zaP3TYb)gN|FJ~1{v9>wI60xm z9Or)M8u=?8PI&Pv5@?nH6=HP%wpvCd6);2dM`SP(4J71nJZH?eX#!R8Z8DK~%MOMZ z0cd&+4;6IYWOhvXRYd=~W3dM18jE#6!o&d>MuVV{2&!HfRpn!&n*spz&?`p_vTbm? ztgO3`wI#vv=@SdZ{D@1v=9_Aq^rJ3Hf0zx85E%ycsWjJHTORQc>R^qW}(oLBd zPK@K5A%9B0W<(YBEocs?1M5n+B&PHrc3-T~`1$7~KzKp&AN`os$1oqTz$X-B|F#ex z34zbE+0S7u+hlUW@yUS5NtW1>d8h!_n}=cl97`vbVwr?{WwN5XIEm!&9)l~#37O8? zi0*I25Qm&ADZne{-*_|<*DPM-r$M)F;60+Y_>m_y3}Ab5U2Y55<6%asEb(z&;0-vD zspBlCG*P5xtL*oGqs#V-6sfZU25Jx~ed^vM2^BalaiYMCvmD6Y8`p9clx@{7=x2>G z^5hu^*_YMfD4^jbh&-Wct#~j8Mc*O-+0VRvBt0~Us2y@BNkl%XHp$JD ziUY0&%|ZqmohT;0d!0*{0Ug&dpD&=-hLrhV%b`IsYj_txppG4xdM;_t!EP6>kC)ga zyP?iu(W|^wcqq70XxbYkRwERG+>QrzWkY<6x>M#}J&iqajI{-NDclPVAsrz|M6Xs$ zG@ENiS}XJGS$(DPJ*mo}_+Wq8p`HF)zSCRJ=tKk7*D9V1XDh}GhgQiKCwj15F`S+Z z5T3tZnsO$XvBiH3ABDU=x}Zc{NYg>FWle9bsyueUAubtgyveJt6bym*8k?I&@8pY< z{z4A_ocVsD3Ow3;wG?J{6#~$$;AT3!02F|uPjrGnsaAZ{t~uE!hJ#BIsdU=&o=eaP zFhhM_0E&(ipHuyAMX1!6-3WaA$1%(+gK2qKqwocD(*sqKNDv@$iir}i`%xQPC}Zt3 z5efs$>`_RAuv#>A@4ejod)P6QzAQyW4Ed;+xPT@Yys zsCa@Isz)_P@SEmj?8Ir32J!J7dsC1h3?O`K&)qgDWtJt73lCmW`e=WhgEA3I3M?o? z1gUbMyvmD;1pI<`Rzgs2V288B((Jx%RQ3Dydnh1mCp97(825NgIK~hDk(BvUBe3o1 zt04}8H*rz`6FQ=Ll2iznQuy~kWc!A=J~)e)NnJNHX(tX9mDR$pttA<4FH;WK!RG+O z!h31~KP{j0kpf#JPr6ziDeUYRnK`(w@*?$J4Pkm;5->NX!IyzyQIX>afaTLe12chq z;!S`;k-(Ts)9@dv6xZ1m8bN6n)u&u~zcHgs-xi*~$Ft*`nu`$bj4`Jxw^#qSs?&e9 zqDx0gqtnXorw05aT`Lcc5l#8hJ4*wNjNMX_Hi9g*Y+69*){T(LR^hm@PT*K;vjDLt zsnKSxcwqKo@d#kFW?$HQ>n{Gitf&xf#ucOm8c*(+7WDrmhc+-Y8~@$@bw$%x4J>k7 z?Y-3P#cNi7d>#ObB)G+bDQfMpowX@x!ABY3{Ks_ndzib1JXRf>fRg^bJJOl%e(KU4Cef)cH(Z zeSCj>cK7X95fH_b_`{|xuiHEAGuFX*q51`VW{YQQH=VP^ey-7bF^h5^XZ7fUYc+JC zu2!d7RX#m#RD@_I&6s=d?IAMXmFTzGxeR5({fa*f<_Vnw1XPk!5O$BDWnE)@!t#Z+ zWtpO`IUTO?z}@4RiDn=Ont&8jy*le7N#J>|YP=*3f90omB^ zSdYJYg~*EVFPe#=Lv&KnP{NqR;Wxs;VO@XBSnKMEf2*JBL3(nqdb%N3_vH7AN-&@_J zmLcw-Sb=Cl7DXJ`8fr^^7c6Q)z8C(bT^JHe1|d-c!+QG4V172z<)id4bRL0Twa<~8 z|4BHuK zNim?LD1|65_w7A1v(;}fmIgxi+$(mZ4cX48|I^^(zO*d5w+kVd^VAsPCf0+BM! zID>1_BnvaRj7ycMw(?mBr zks`JE3;cjiSbu3bH;nGWa_b_gVwJ|C3qz|Wny%&CpsV?Q&B=S_xxo#4@0Rbivm;?7@xNzJM>6h}8eq1&_tZ=y zWD}5iTg!OXmyHojnuF4^%e#@g3yU`${x00a&dT+)gJF|2Uwix=R>;R>Kka!$|lCy*MK!p7OXI)ZXJuMJ>_>pTAcg-#SVdgM@bPH;0<#V9gcYV z9al(scU3O6F(E&hTtt-S4kPvPAop){clYn%vw}9yvZ|&?qmEntZ<7j}0*fo&C7P_J z8H&A?20wgW`6PiptiH;=)?{Y2EWp>!CDE+DM1=gJ_nXK@mhlV?J@fm7k?4Y`)av^R zEwQ4{98es-%_^o`Y7_e?lpuFk=d(~V&ReB!^9*kn_z`_2e?|Fh>sjiog%= zNj$r?zdREdj6jq18lST~h{ROcHV3ipk>HD2p@qWDwNw;d-U4Ww3p@XtSFoiI;U5;2 zJWl@G5w-X4J!$B3p4%kwTk{jHz5t9`besux(#?$#X%CaZK|tUKA6r$lou|!1YdTC9 z#c5&uYM zidOEriEcHiN>DnY=?6(v1wb^Y!ny1rz>}tHLaA+_2%-sAoe0$lqp9Re_`*zzAG_BR zeJI}?ux&yTmXxLif|m&Z50;J~C4oaDh!m=cM-{v273gmI0^jN&c*yldS*#P=`=r8| zl>$tnJ-|hyX=~p%q%ZWgWHpi$hx`5En+_ESu5=yGp>Fw@wiBd=Y?uc(?u zB4`Dx7hZ`-#AaS<@7smrr(PyKX|lt-Q&oMC=)hs8NAZ@C6d^A!NGFJ*|8&TTVkJQ* z$5{lLj&kL;)8>4 z>Ym zoPF@c&4O?xKoZ|m=J6dr`HoIt!C5L^x74t1Y;6`Yo)zW(hc@J^QclN$!^VDA)MT&O z>F*FV@rwisMhxTq>MD;~3co|Sb1QQvO~W6}NLB<9${ zc+2u*y5!o87>DjyZtgcrh-hf(lX($Pk{NxS$^->$CbD09;Mj?14XqTCNdO6#A;=$w z(hHCSOeEEMyKfw?JjG}r5n{_L3Qfuu_iv~ff2Co4hk)4ebC`c9hkXM>n_*G36y#t# zZYl>pXFf=BfPQt%OGai)Jv}r$elr`ySM!RI`ch{d(8X&ob@%@Y!%x;PA4>e|5&DT( zayTV)q5%&Q$ov>Z4)YOjIIsG>M1r0zjJqw)$MhLe;ZjGTeyxVHidvkC9ERd7aGT!# z9bp}guXHN#0P&lsg@H-)YpHWSOOE&ssh69U!MC|>o1>L68IxooJxj9xc{f#g2nEIa zlDR8g(um054HV*ibGrloz>iZwf@NGHEE?n*GIdpD4>qi_#v}6_R>!LPBG0nr{2-mT z+}+onffDZgO7P_ac?YG#$;!N9{43!J4%RR2dTD4xEfO5go$NkEOp2<%C!;~V>i?tb z0og}Sovegxn>Xj2)FOz5Ul&-bwcg$mM`?p)GL586Cnudh z^qX(jUf_xsFV=Yf%xCLT6_Y%w_L%y%dup=u!L;ffQr#`kniNq|^QVRpamWA%K_?pR zpM%5CQ9&ju^`hpSkd5G@_PLMqvF)dM@@p}Zm^1-}_B`<9m$i?1l zDJZN+*c0E~t^;CZNmNpJ)VE+buhQ^eYuaQJh`AH7p_$wn)6Oro`oLNa83tYvfhUkj z6??aYNbf-v)e;1D?lCFeK2+u}y$?C2msX_BC!=tj%q^|~+b;iwXDxC*eE?%(Nd{}L zD;m{_-V6iJ&yX;}aJd5J5E7H?tQ7Z)o%)(Vh+h9d3oAK98KT(5@aIgqxx4t21ZpIc z(L334GZPdzST~F%C=E#^)T2S1Gl-4X-?`D*7ekGxLoEBWQzk2lr9|U9!F^J{E>Idh zb|4aUkJQ%`scpAQ{adR{S7Y#S+{YM2u|>TS3Eh+A+$biZ%)=02JG?YEpD}dcWaO;` zxZHdt?S!`LnZ5NcFW%Oe3WM!_ZEX%QW-zL-tY%-gr7MX{6;BLLLB(H*W;^qJCk^l# zQaj;6ao&p5Q8CcqF+o)&k=I};HQbGy^HRla$!ox3;4Y=5i4G9;K^Q%ZCPo^W z)%!>*@~qezI@E{r@Kv?T=#pEO?Mx_&t8Jp{T@9w^*60tL$)$f#YTIH1+y>bX37lA_ zRY7y(pn^~88cV4ahlP!$$bg=M8*Gu->}6loc*oo*pY}hSFYl6ryc>ECi9AUlrEPXQ zJsSp)z2#Y16(Q%Z;C7!b_#&H3DU1#jtsDkCnnijvWSw3^mmC~ zKyJNed7_xnKq0kGW*_GD^}gk^bl)-_b|wfFGN7Hmth?Hb_khE3ksFlLrykhJJtvmuCH#__Pxvl)*P z)Zd6(>N#mNykUp}B<=ezkbHeduI4~TB1$J9e}i0q{bNU~Ptx=h11*F!0fgZACev){ zE@rUtajvSX-TmIPcVA5`T`S!mFqJ%@y}*#o*V_H;xQC35=$x4**>uBF#%17r0nMQ4)MY4%np5i9BPXr!H|eWUG9X;41Yl!+ zJ&2~%8p`8fM5SbaoioMxN`0Xn`p%7#M8OMmzyp+1wII4m*uol*Z*<$SyT}r4$ZaX< zf!EXFKqc<)#OViGw;^`8mvt?Qo$Gc}C5)lAy-7ep5;9N)(dUypw6M~r(v@19kAtL? z`Yd6+C-twFjU~c*T`j$*Bw3OKd&M>cm`n$J#L_0~f@s*XtQ$YZMR)bYj~sf3@b>AC z*B4~^@@u;psAwfPlWU(I3u1i zzK+QcxmKqs5@kIH_6`5t>kPSZf-zjOcuo0{wPSXz{nyPpw8POg`rF3LWCL><$-A2^ zQ8%d<@1O5Fj3tn1C7ekO4~Bf4o0Gk9lR?ArH}}fn^!XLNQNy`L@$S^gxpN3`sADhi z9M@-6ehrA4={Lnq)^Ah9-VngOg6^y~R&k)+6Buc3Q7VC;AhI!#Y*n0>rOBl9gXI}t z^oF(3d&FIE{?dT?^|yVS;5FqnW2x^#^gN1j92omOY^i;3S3p%<&xS=dnbEZI@#NQv zzZUQ9!gO6vPNJNjQ^WD^c{tqq?ZCL6 zkS37lO5G*xBL_iIc!Ll%pIL++2J6MdynO{RhUWJS(>efkA55Z-9knpH2brjkTR4w> zQ^j3e`2HCe-4?m4d93mcofXr=8IcO>0{bcweJ1dy3Yd^G++OR42`pkFoRrihKN`eM zE{3Py*(T#Fe$q0Krdr7uymDqxI70*ezI5pxYn@6Wc1v<2PjXeySMwt%PRX*6y{S8# z6s;r_e<1R245z$a$So2^r$L_Ym7@7EoF*}x=D20LB%ozsHQ0(~HjcWMsJsI;T|)Dc zpywJPwfJ@bQNT|&RwaXIG49Bh>~$Nxr2>>pCwz8rJrH%`q5pes8C>nC@BXtgP@C|c zMg_}vJEhLaE+yK~X^5lz-a>BF-O1 zX>D>K! z6p;8b9Fp)`|C6ri2!glLiTgs4iqQHtYChu8a@j&6W3k5l2M3y|3JGIhH&GV%6*L}I z#JE@;)k3x&MN>gYZX4)`TybkS-g{R6fs9#oHT0nAod?cjhHkQAv*632q-IPwFqH%( zoOSAXqI$+ik|gGlYUB3Be3a-BlZ(1dK(%$c=AyUsO<2kbm!*ot-H3Vyu2a3n{+h z^TvIy5=uGxdQ}~Y`TvJ7$(kR6dG~8`hpcYGPOIDPR@E~z-qD8#5`p>M7oOdftvQiW*fN0`HAD`WosnSOo+cWfz2SLVv0byeD(zl?Lb62vYt6fnZ9^+;hj!(CX>W42*RQM}nk@@&N%iO|%U(_wweX@E|^M zH(yPV5c$P>QiA(u_9q9iAONegYiPZMQxb%@X(MEr!iuYr#mlI`O zc|{Bb35F>$$Qs1VLI4z;u7BdfxjqDWU00w5=57xv4EU)z6&l3E!elz)bD$X!Y76-B zN03_LSSrC96CeFFX)ZKq-N0QOK>8+cqdiK)5lWI@d#49 z*_qeZv{y`2ur3}Osw4>TSXP*{Zc861_q9hz3sJDULT;t+QCRSN@*Z8FkHo66#T0ao z0cawhbArv!$(ebNV)n7QtSDKBHvR!B2+EAt+rNrKoyR*a?G?3Aw%Kg(xTinGT`V0_ zG&NOUTWMF%$zEu-_SEE&rGsW$-r&l9_NhfAHsnUF^J!=8$Wx+>ZP(MjlhsDm=P^90 zQ6m+s8)nzr<*1T2Hg@^?`dKMnH~Uqv9fxE2@rUi_*8J;7@Gvab|Izi;QBA)8-=jwi zHl&*oN(m^fz$h6YA=2F~As`K-CtZqC0uE`UyHgrzkVd*C1%coF`96O?=deFGhXaT0 zzOL*2%8S#&Y_`SA<2Y8Y=h&!seCzq)4T#Up*3--JA<45xtcR@F-rbp#>*TF!tDTD2 zCH0L|h~UaqkCJ*n31y{byqCaq`^zvMNhe~ljnGP%V`8+9XN5TXmnlo#KT&Vk&EQlc!W`DF*7XJ%H3oRI)x%vz zL!+6jp2h-{wg%8}f2gccE)>O}FRFim67=8J@pDJ9a9LeX-Qj@?aL4^C5iyJnt?U{T zd}m}=Wsg|SVLD_@h80BKj_ZkgjG)xlli)3YJ3Dwkg$jl>YYED_M@2@A>|$(IDG6H> z{$zkypH7pG$E>j~Dqm>|^5DaKBgeEEzg`bjV!VEIHYUnIX#ZYJ0KoPiL?KOAn}!8f z*oq|2BlTk9M_ty;k34>SZ7V8kU%~wE%vl(R4;$x1^2cFf8$>@`U(0FM?h{}kP?Wk$ z*qSKH$)wqsrDVZHbB*`r;q3`=>fIc^>qG|l4@Lw@QQ0xG%;cLTw243gICTV(S;vl! zlO-9reD(2u1f&d0Y(XV#kFYl&K*EBOy%ddkT?348CA_k4lx>1(i<#X9bpEL{cG+t> zi`ji4j5fNTzmtLAX=iN-#?;>vM&S~Hqu;jUc(w^P?#V{oy*oYYi3T zot_K7rs|Aj1``s19WeA97f!l7Gx{Nd@+klN`PSP8ll-2F(dFp2;)&vaq9@Bxj(!Nw zV#Zj9QCSlV)%&Wt5~<*#I-W8`e1pJ{$zPiUd?z~w&dZShv0-8)4=TvQ(0!k%Uv7PF zWyPmB>=I^QDW?)B`RI`MH#{LQ&+%t1?)ulYFjDw_;_h-(x{Qz+7z9|W+;#eJVYjI~5spiZSgag-2eArR@*0Xu1zaT15PL@Gsu=Kg zSy>m`y+L60|NQr4#j?Prl)mJ%BZ2+(E>>FQnu4mKBntv;<7u%wAB^?fr06kHv+@ZM zAqX4YtYt`(l0aB&Leipe{NHyN!s6@jk=+8|=Mf?LEi30=A~aiB`5X@fVhyI%A54?< z7r~vd9;|#7Wr88PiwIUaT5lpiOc?XO&o!{Kv(p3~qLjW#Bh^=egC%v47M{Rdt0TSU zD(YFx7C||*`}kLMp(MshK~hqK2Sk!@Pb+{eV5LRorE%#mitq^M%eU*{@F$duxk&VM?xL74gVFZB zUqBBKc+u|k(-77edfZt9RvE$f6S*V|_=IK=4HwbgKac#%H;neV-ph1DVLVUpVK@pT zyn<#J88*o*(tAFD1v>&KH}FKejX0q1nf_&>#1P%~dPC<@TSl%dx0l;gJkcplR!O}j zJ2;6_|6^37e(d3d0Q7fu`wRD!|3-QQ){_!%F<<4mC^jA0j3P?l|4P$Vj{+OU5sFHA zX!EuAuvq*+`-OGmUx*p4iPB59E2^_tfU#H>BEEp_^*iV_ z)AM_P@hgn?G4n^hbrlDy6P?8$Ao%Gtgvj2&Wts|4`}Yf$5W322|N703BtQ4hLqmu{ z=&%!B8qe{gfZrfes&4exLPN#^N+_vNbXt`;^-7WRJ7e|;Ir*Lq#ex-rvEun#8gRou*lw8(2P_e89WaIRN0*`$t6P#x1l@g62r5hP} z92T`%7dOF&a)7i5rQqdZbGF#FEN0y|JV$r1sSQ_hWg_r83P@2PIPh0afV-^Jb}szI zIDoxW^r8HYE73=l+xco|kej6{FrFaK*L2nSv<57C5omUPNhCECckw+FznDDun2;vfXejl1Ec2AY!%jN;tWnY(}FsNU(%P z!$0n%{^_&z!|uZ@wxK%l#X_C^$duwol7p0$My;vh43XrmpHp~>SupnE3WKf|mJ%#$ zXC=JVXCP*KXhqtqIzCYxMmDV$@rStCSpM10DKikl#p%Vu#Q_4tL+ei`Zm483y;e~O z3m}-yXQbjKjyIZfm(i!4$7nhC6JNTrb?XrVkW_2dz*O`(3W=PWPdsB$@=d0|E6@JT z|MLMF^aubg4%QnirQhijwN?kNR7v2XI7u|}U^%em&Var>bk$o4UjRflaMd^{akG=c z*UpYomU}hL{KRXRIIU(EuSMSwO0tWw%1Ky(`S`6DXox!=&IgO)Ce~NvOhH+!Hs`T& ztoWCpzV87gk3KOxL7byY$h?PmP6`fsFB7J$4+1X%IC;Dh1-nMCkoEg)5agtSIXx+$ z2;glcF5>!?PVdh)5$!J2RP?9-?&I?OK}w<%;c^m#D*xzc2-=*zMd3gorvBIU5JX{> zMjLk-jF1&K9omo4XQZurr>83o9gn%{2|6z~lVv^|n4r7*qaI4utwA0+1QmWT5#3gK!TeAv;e~BZ=i?W|$-r#PqYcui~v+_hh zYe8U+AcD5PvfIxrw$1jGwE%eqON&M=W7E>Jlh~-1*Xow1c{S{W?5qEG>Op*1?Lw+; zZzy?sAB0{Ig`qwxU`NQ8Sk>zSn&$%XGC?cMH19F_;p+L$gS%tiA?Up%Z|(LXS>(fas&2%lWUDu9{94Z;t%Gg5=-o zs_L#bD!|ZIYwr;3!BG2Xncrd68`aetG1cc8)d$VhTb0#&via|_9Hz38OXq_c2{pqhk_`1^^}yeeIcyDlbLci!{w z+v3M2_ES5YJ66#i-^5?Ncr2={yPjQW9_>-6-MGMMtkeO9kfu9h!)HX_MrXGZ7khX= zuR$H1OdUL@4#q3pCHa~0ep=vT#-72xrzz;$*BhQd3dLwlanfTIhXDp#7eeb#nsCk4 z=aIvo>*o~r{B9XccBFS>*BXPEMWfQ&8W*25Ke=c0@cUBMMOqUX)W&#F+KB)aR>=NIDmyJfg_ zkW;w?gxpGU&f~Acm^f(`o@V|EeIM{Tcv_hKxPr-U&Vb@VcX}&A4==`PU}oUjzjXFf zex5Mf*+qo8flm5Y;q>TAWu(Y1Q4K=^vZqpFl00T#iMMokCMm6(S$0ev*ZQ~cb(|I{ z@vk}!b%Dy{0quDB6M6UPGh%=i3W@9ny_H0>2guocXC~LIq|&8yFjr_b;T|Qv`92*x zy|xP%b4Kyr!Wl2Z^BT=An9jsbPb6Ee?i#pz-O<68bU9BJ>SCpQ0#)Uw zFYDhU;JF|EV%M35mDU_+hwQ)8b8PE|_C2Q)# zoO@WbNpY4uye;y>{i4V@6;;`s^1FsMGk^v2Q24iv`By&|Y=QPqamzT`VXoG^#Vo+` z?P9@7!F+(qMe$fK@`A|HXj*~gKXtDi*w6GywFCQkMug;0i_uU^1Yv`_fNX}qC>nQNsf%udg`vnltZ6hM>qr5VF%!l_VH!}=gscFp*hZ7 z8UPER>O$R$X)N`^6blVod%27maA@%955uFdQL`jRm%=Y;(EFDHVGF!}UiCeCfnffe z6yo~aU%|rS@-*BQ$tBBhxp@<<4Qsu$HNAFw_ocL>Cy4%VrxT|RTM2jH5|E8LY%Oql6{tFNog6(*uclUPdP; zvLY~1f1>;Nd*X50_OGubus#`mPy@cKDH5YopLymzgxWVV$&;{B|H7yo7_*EojMJ{k z;lQZ$c7zwti);5x7f0L^WpF|EL!Cb$GMI@O%yRR8E0sXkco6rf-OZaK=y4ZX+Hh0c z@4Hw^RwMClJd6i&jAU&0*#yUK8sUcbScuOvSDu?>Rb zYFJ43kF;2Z=3=vJPeQ<>+uSD4<*+FV7%)CLqu zMTAJiCzI=+mRy$wF$_ILpJDtL9$9=L1Zn<62}<$DxZl~Z$g*{3A-V^Yw&$5-&^&J{ znEx@;nu0e1A#gw%NtU=vXm*N+71_Od<*0oCE-j%gqnzrJ#brivhLDFyguHJFu51=J z(3E_R0RO5hiiqHIwQxbYAi4!Qr&-qiwBWcsD!|iHlc@zAGre|xXMLDLf?56pUyyjR!`x$5CV)6bCq(WqfNhns;RX6#59D| z23UJz3$8M%=BNbwye{m77~{XWHYxFALs1z{4JPfD3q>+mFKE{5>BFqxziipvA46E{ zh~e$10C;<(UwHW_75MKKE^Gyh5|_q}vcz`l7BM<>8)#>jdN1KqhCn2=co$(Mw5XHHp)(OzA(O3o(`jhq%D4p#!Yb|JB#b8^Ik>T1S8e<_YLnD#g<{&>$k*=5TV0p-mv;=&W=Tn`0b&Rl;)Z9s_|_pu`F`nR&iaqL4IoQ zn5Wnm$IA23w=5?#$^-!lAlM2HjUd`7$ks{Y*>h?;ECh*4*#af%5e@?Yxd0=Z&m@e8 z?blLXBNdk^=IbJZyh9y@ot#`5w`d&rRh9TF4PRn-0V{sen68y$Ft_N2L)XWDm|Ptq zgawp!`-e01{p(s3IovtWt_`dTTeR)wM$@x3vOP2Ggvu%{Q)X4EeTKF8e z^34gbcSkkCuw6+KMF1JA5mV-SG z=ea-MW9HmcWn>Q>-FP(!27n}a*7=OUKamG9;i6Up znL&|#gmJ+sW5n#Q*Jg^483P{xBUUzAY|7z>;aL(=i|hv2`vf_D-;qInnX6pyh4)Ak z3UvBwR)ANsADSYZJZyeC@Pkhw^lHP{dfx{Y-9mYR<7# zVrsJF>#(ZC!aSynk56KJzRYqslQ71p|4QKr6O85ec+Ci|{F6KA&@Sl5-wOb@#qw#Y zxZ9%1@Z`N0?feY+m!FNik6Q0Ccy43z+>)MomU%k*2kFN+hko3fgFu(fPam$?6HWi| zM8qt0M-ss$Z|hwBSjbZ+Rr9Uq%J0k-=w8T%m)=@_FaD&k`=C8oqO%KtlV7p&hRrFuXFfgSTGE zga2_1Sq9F=YnC!#3Di;)_`c36+I82`d8*Z;E%c6ot`~3?3VCC`2F`4=(3{XP$&XE9g3@zJMb5L~4=G6E!@k*1Ix2VyOzBdYd&FqTM-3ge4HifaGnWU-*^HiuTgL-aGN zdB-a(pynzPR8v*QF|$9ILVuiZ_z{P4W5+aW0Zv~SOBe;;N@z82Eirea91@^ngu?{K zt5Avk#e!w86&z77_W(A)SDv#v@$9Ux$w z4hCfXo)?}V+^I0P@wzO-tFO@gsz)0$E}HL-jq0hv(OSjyylS*K0<#2gBR4LQs=f}I zEjw>J%I-_qaI6=zCUx}(tm7=5xeLVZUfOkleGKN7M&YhE`9MeL`JrRx^oC|@I|#bw z>REP`6)2r=K>Kc=Z8d9X-S@EXrplUWT@LH@fcSSNCPbLoGBkxxl_AfzrwDo7qzoW1 z7u2gvw_>A(__nG5#L1;UPfygJ7E7I>$*`$tXZUWx`SE!pdPx|;e z8nP?x*iBh)q{Jg-`?RsxI_GWuFJC9UQ4xWmp_9M=J=TCb+`Rig=gA+zt%diJz(a}& zZAhX{kT04(#CcG?-ckXK3j#%S5k=KDsA=7nf*Q)vVvF^agfwlPX9D}lSv}9Yh;|8p z!>Ij*!s0QADF%lFL^uF0B0L4bdM8#ANhx5O6=)jy<7m)BL62@gfhr!46~~hIm%>jh zXQrJ9h7N5%g6qg0htzBFa~beBMmYS9=$HdZp#c4TpPFmQ=o3V=8s4 z5I*~3q#~V&GoTbf4;d|x<%Ekj7YQ`aOHg9t(DC$1-eTs6I59jPk;-*ea}pM}?5uJP z1z5+uO!;W;l)WCW5f(*zFpzP{KfU8Z5l8FtF!7o+^hWF7?~VwzcX*04pYA<7TK`ii zO3dG0irWon_~XRioOKE>NNkaGGd!6@DW@1GA9BvYY)qq5Wv0+JOY;ff(CCBj>?>LalO9 zqSjCNl!#J#I%d&Z!Qe??JSVqh&EEbLXJ*56L2_)Y#Lr2LV3JMIR_ifxB{dJdGusZ+SaMCn zflo>;=ZTO%jm>!$f<3O@$VENaAmooZ!i@$zzVh=mMyC6Ytk+3PJrwNFA90;y?!3Lw z>SI^yQND_5%3=-HWosWTl zY46j-TyMm=MrG;}YaJHwz1kGEtVuCl8Q$KF;GwWHY=43zlsul{w4vHdGo{V3me4Y-INlynwBM zSx0Wu=?9rqBD=Pu9V&`Oh=#uDt=V*a9JU3`>T}EXACaptK%{7qDGBQygI&@zP{v@) z*S{rlRjAavpl?FNh6l!AJRA|wX84JbBFRJ1=z2}6Y;%xcm@A3v^EPnupM(-oT*`3w z2ppXgm1%PrQT^xf#FEgz3M=5=Xwd^|;fx8@!m?9KuHDa(_mASQ&o(cB=YQ*0&| z1!57Wc${5m&8f)wSjr4=XliS-YQ6h=rc;hGf6S_n9W-PMI ziPy=)Mbe(y=}!Qi&Z>{gAFf2WoVk@(nu>{V)a;?O-@x0MdO9BRdOYLx_$Qq4-)!R{ z$>UL{no}F{f^)yZ-Wy9Sq+9_7>rATe&2LG#^gk0BY_S0q0yW0EbiX|E8jMp7<~?&t zRktMOJ-<~+wgb!Ky(nNrnXZjtDj?_k($}5#3 z2V*k0XkZ%{nD=*_K1$Ff^Fc$_Rxi)}yqXHBzg(1_1^>98FrB%5?{ckt|Lod~#R7rO zVa1OW((~6dhhhnl(5sCO$iDc7IT|+OfH3X=c4U~FWtSB%7FoUx?Mn?=WWNO|6HK#e z;k272Om=Ou1cNy`P6)(Hpm5pF_x!+b6|Yis6UoCg}ri+Mt(h<`j2v3ZN^O#LJ3}D7#+R{r>XNhvK= z$Zd9#6>yh{{v){B)LVn;`Rfc2$ET@v*P0R2IDX38{mTcAHVQfUAcD2YV0v#{k-v!Uy6|aIMjzAA|ycbJ1OfQs%au-eN-HLlp={KSg+iL-&Y7LB#}& z!2YC1OL$z7rS+YRK%Rsd6n67|Hk%1YS%QSY{y+RtF%I*3<)y%TVT{FtPdwrV36Qr4 zQgWH7SOY!!&qYIMuis9P1^vg`4&DV98W|0Sj1#M^pzydOZ1^?BZLszL3%I1@edOvV zafYXdoZceO{K~Y+(!T~nLzR5Zf4$a&u$B;AQQu@kv$vZiS&H8Q>nu93m;r%96sZl# z*QX^Zi7IyqQY@*Yd0ap8&2uTd^MsJug&Aq}~_v+Q`{HbMl*x!q5%n>C4%M+hDis7T_v140MqK5FEe3eM1-Q;F6 zUq{K3tHHdtP7xOOW2L-Q%oH^yTruyTYV397#?9H0={)9WsCeSb;{}YgzfV)(RvzE= zVuPpoep^d}N+?gf<7U&aZ*;`_%<+qN_UgqR<@yq854r}&KSV1TH9J6+%zi6dD&ot} z1IjaFEHjCG(yhEWa!n35(Yti!fRpz?iXGZt#QoFn24Au{XlCop>m~y^4?o}xxL-G^ zyYos8JU?^Uwk3dF-lJJrA?(cTv$+jBfn42RYpq=h@em`DPR(znN;e}RY#{&Vn0NHwX2Le zz3^60Q59VLpC;$ZiWt9D^05K-S#Y5`2q3--*#1*r083(BEXfN`%_G^vWkEEB`t6{x4k}Mvn$=Gfe4z?-vOuGE4)#aZt`Fr# zA=Dc6`ty`U=o}8FHb#Mp-BstE2$*K|iwVoTu(Lvc?zSPC@{wEKS zkafQmpV6WUw9pr*0(6LDe{~b6+32<2T09>lGw-QZBr+dpYUN%l+ z%)>=9N25z6%b%-^+k|aXTP1DPIhUsDHd~9AKc#kyZaqA4B!0J(PF%ZZcuIffN9=HR zL^We)*TecD<3pS$a*JOjHXn0ws_FM|Bhb)uQ!}#cRZ(4qBE>5h|o)reR1u90H# zNL}5YredFG6gc{8ZLNr~a2-DQ1bcUUquBh{LK5!#c(gP);2gIe*@964& zDm5CH9`HFFy&O4de7m9ij&^3ev$J%sr^c2)Q;EtE^Sq+k(f+7*+OcsuZB|rlAKAV- z{^1l0oMemC9Oyh15seh8j5B+lnwGW=P4e$$Zz++>WPIcB#-3;td6dxmGT^ZdIfLom z%i37R{)XZ=N?%}E#|Dm;%;|Mj4Vfy--KMHNvf*W+)r z<}!7M+o<4lD1Ht6_2NJ*(HkePd8)n$8XksL0>kB}5Fw^@gG)T~M3))sbY{RkF&{5h306OfpCkxVOCQ*tS5j zuR=NV%N?y2Zbm9l_o@`UqunG6cdAN5A?oAvuWo>~=8n}#X#!ihAP&!)qWRniMV3Y< zp*lDK+}Mi#hVN-u>Tf7-$Z z>5yf-qb#Oz`)mvf0X?T7uvl}@<^5+oH=xlaY47wy(&%rL8%s~ofcoa`>rZ_&qkx5L zHEWdb+PitCDzn84m#Fp3d47aX_m!aIUMO21a*S6m0xWyXw_z(ZBXRLSWX)wLHbJ60^gywV-`@2Yr~d z0KV0QUY_xE#k{*b#k17!PCva&=JH&If0eoL&VK#QH=F;RPb~ZUuyn4=ke_Lr$fX{6 zRs1%+d)#)hd;D(nY5d+v_`~z<++<(xlUI`e{+LTP9y11<=e@aJS4clQD{3*QY}0-? zug9X4A6K(?g||bYT`7T4NjYhm|LpOKK%Oog8y$uW2FzFpOkSb(!NGw!?A={wUOCvZ zWpaEzYw_}8K>OMkIYYN|z$#o(M!h{FhYUxxf;8(=!TPGpls;r$A2NiX~)=8-LuLP z-m1PwX&##6g)fCN>}Ea>`Oq={+37UUeAD>u!%r#bUxn7II+d%QwnU(t$O{K%(lPJ= zi%_P@oyQyW-kj>uWn13k0@Ylue|}yDrhi=S=BEaYFSR4n(v$Di8rOPRurr*N3T+~_TV-i$I6HU%uH?IBVp3Ktsa z%6~4VB5;vXeC96~EVzzBWq@4m?_#I*H2sWtTA?=-!pu5 zwGm{U4oDyp4um^fMSz>`)RF3|lYF8gziGgL(M%QcSDOLqreuTzvV27Ih@PRTyIAE^ zA~sT?%rClJ%zAr*Exo$=xJ8pmGiZq#JuQJo*#XzktWlw1A;K!Wc?*KT4AB{h?m zY4Yp*aDaUME1!a=K!VWvjl^fbo@S7)hOA)_RURBgj+O8$^KlyqsqssT~l8TylYu7w|Y>MsDHmdeGJGUj-=IJ!rcjk~g+uFY(e^#oQrTLkxZ4)vMTzgkOm zzfkdb*R4wMpa+08#VsRcrUg~R-D)WxIBPsSI1#QVZ+!kZU0b)yl`zF;+opt(ls3jZ=z}NwsD7b3wirTQd~{zBgXQ2 z7{RX9JD;XCgZ-i;qI@F_7*s-w^hqZZ*8qd0>C=~c$eto5y63Dp$~wK6Ln4DmtSW;9 zz^$o3u}2t!RtB&Ww#K~e2DJNS32q>N!ku)-dRVR|*Hu@HZz~CZ1aI&Ar!5T;2ywgp zKwirhLJsI0@{BpT)1@-qS_^GaYq_(ub>)Joyg)b*Xi4O|Xz_C3>;}Zp#=D z3lN)ulcqpfaIJB#Xwm^YCoJCti+vx541{Ox4|ymUCeW7<47cD2=aWRz)X8>Amf^&| z5$iZW2ys_m1mR#o9;a9g5I+;GL2M*hvs%>jzM6oPuUJ2rbFh=TO)g7 zF*l+Qtmwb;`_E)l-$M?Zc4z^+EA!~%JA33P&>9v10KF40OV?wXAmY?L>*d}cmV~xM z?UZ!9nZK>Ab@6>=+~X;teU3_Vjx9K_d>bsbsmNz?22{|>)quj?nCd_EXMbRYL4U1p z4^t!p-uQ+FuGg^nI(0}%R&NI^wq1oCntB+JJ$_!SNS!hB;B;h<*nM=bP<-pWM?C+0 za$#ci>b+0u+4et2UptxP!=OSXVCjD&FlotEG!g&WN6k3-x~gtl)JA7Vy17CD@7ZIA z4eK3ar6U!Iu)^CSj~cng1zzL&@yC@KkLzmp-WocB$U}eBOp^*nL`3w%)53Kw&k8?g zqm7z9IEq8EqY{!l8(?^Hjnm^Mut+whr7%c>G<< z+aD;a5X2(LA1H^ir*o9-wScVl>FoJGYc_ngb5Waww7+j?<<#HdAnuB$MnsVsx4h-T zN*fxCR?+&h65MYT9ffNIq8%htk=8fLZ~IiQeDC%u^KO05@C@)=>`C($jek1W(&FE- zdqHW=TKui`Ps{z&=EgKNPL$8;c#l`d4y7ThV}U~2 zy)&6Ji*Aci$TwXHyw$E`bZ|O(iw!miiyA@IQP&djN|(kT^I)WUpU(3&WqrVg?iO$~&?%i4m;Gr$gmz%Ny6Sqy(b{HhRh^5sIThpkYWtANycvrxj!5N_NBI2yXFc_SbF#8Z2 z4Q(fYK(K)R0)hOAb|{#2L0=NWY7fn6@}-ksGlAD(1HMcwx(ao6p{EN8sQzID;0Oqh zcF@0wG&87klQd47AtG#rYLNp$(WL}2P=XjLNsx!2*zPZglJ_9L!Z896LXi{7{;Zgn z5aJ#Ec9iOF0UfG)Y~~Abf;m(WA?6u9$umMPWMmz^-tk@PK^dzx&*EpG(#UO!cap5V zt9+F+0+*%Jjf7H16R6gRt8VL_@m^?mV`GsaWaWrf){h|1w{>$$uAJ~$>v763!1Jy@ zaa@A&U%N#Y;l7-cYQ74FSovtDSxS5ggXh=CgHI z=KKUV2+R1<`BtbXE>woGqkN>{>a&Fyx@$STjh}~E=hMH%)4IxWD@xwGI2Q_?MvJ_j zd$ryY-fc;A3giA{#8=JXpSbeeWYu`_K$p{pd>}Kcv}_vqB1pnL|IT%(>AE*&@%qog z<=KaxzVnslE?=`%!o|Ad6ejPT7$$$EF7q)sm8A z1oxAh=Bx})*6#Hx{_WG~$9o-fM|(LwH|Ke}WV?gWUVHCpr>_5GK(4EF_p1LyotfAO z24Fapgd0P2e=CpE;h5FWC$$4XcEXrFAcT7SvfNuDU^i@YB(wM^=>Fz9GU#9M-LCY% z*uZ_&`AN6)kh_Vt+p^ZX-|L!wD&#psZ*n85 z2?$wz=G?Jy_4TpRMvRB9)Ig+K5hEfsrY$MIS5KMv-e2OtLiXtF?_t~F4WBEU7)u>C z=^9bhA_N>^;h5LXB>c~BdK~$%xOEPXw9I#CZ&zw0uYb)fPqhVE$G1urtF?)LHwmge zle}G3@w{Hjr30ck`7`e_30KIaRN=Vi!9Vz~!kG(8ziRWn8KyNQ_n$4R{G;4*ah+aN zP(sM17mPKZXFWK*6Dz8-JzGJXGkKn}<6iAH7r(vhcNxxu5M8 zY0LTipaW*l&Nh8%u|b=7Yn137s+J}8%?F5V?S62q9DBR-_VsrP7!KC|p84_vLU|JE z7?%S2PufZg6p8K__j9eg8EEH%Yuk?uXBRXjIn~|&gC4$7K>Mj?Z7S)mF%htOr(M1V zA)S-rYV5sHl&J2}ornlrdjmHvlM4v_<8{aOa+mHXv`jabu}FobnHLfDDTD)`UUvce zoh*?aKO&3;5}j?J18Bt%rX5%DWsiI4NchO!866fm>O%}%c8a$>REt-$>Z3LZiN=4G zT0Oj8I*O2-Fn;5yori$dA|zteYF=Q%6(|!uBS~$Bt6;S_ojaz2D~$2JdDQYs=DZ{X z1a}H1uN5g;CX&|?i6LQ;{cAE_2}S{4G^OF(craxpP}`CC`0I74OqdD09a$UZf-g_P z222+Nf7JA!3Or(c#MZq~)C>hS9^KK-0<{NnC}77?C_X;OuPW5VSXba7(DhC(3qt~o z7)x;=3k$IPUv)@{e*G-Qk1VB3{OK5BBZGU6-42ok5p;pG&1_Q96Is~e(VrN$?%(mZlGN4Cg)nGz~8fl zzuN)4qjwSN{a7O;m@zyo{M$qs3AV|QugswM&9mn14ThCf5AKkFkZ^8dQ_XS^Z7-)z zTz?Ubw|WT%T5;dy5J}zdlvze89i0ONdq@@w9rSgC7$RVsIE3=gXSOCz#a;NLid_=p ztnVX(yX=9pgKWGwW4h=03a$cs1pCk~t}^)B`Ibqbf#rar&C!%05J&iJd9V8SE+m!n z57tR$GWQHUl+}OvY8g+^+4B{r@ZOYRD6;?VA{#x)GV*%U^H=7UWD5v7*AEC7 zP?Hq&O%8l>2n(=Yf=No8W;12Fza$%#E{xp#GL1v5wwLqw;e(Y=K`-Nn{Y=$IBun}> znWu61TbS2Y|Ms0-E$pi`QiDMEXG-!iT9()U_Sdi1c-O3ErNs9>F=gJiGG%!o<9VMK zC{i5r39(`E!<0f-lu*gnWWmt85isy}k^RC8g_olMi+FfP8 z^v88xVc8*6h@`70^HWP7y&nqrB|nJ27Ie8&p%ZX*6{YNeO|QOTba*J;TB;v(uNzc2 zx*X)P-Sg9JgSs15mBj0opmr+@;+1 zXnWNzhVr#dABdj8rwqI0_}*4h^yi$m%Qv#tWie0G#P9XN!IetmT`aK1ELM;_Olm)$ zAY4br%$Jd>w=1rhA4rS2GM-L$n>~+)j|EzzXV$!~x3?0@4;jnd}&*sJ=KUjxQ_wdAU@9m2yx3fIk>> zn75o!qp0_bqDTO}{5fF5Nzf1`2E2mX4>fDwXz9RMmx>rD%)$or_X@ge-dKS}g*>=* zu$h$>;uLWeaD{P`CH~VzLV_i~Mr6lmv{EbJa&&`|uH<|P;97&eg-+gRjY?5VEix{H zIuMKkDv=j&c;R0Vv7yBW3azI^iwO_1JZU1rv+@=w!GSk?mu7sqTfmH;v~r8~jb0#; zN5vQB{hdqU1vEhJ;X$1NY~+hFnok&qJPH_4@nmsTe&4mD7eT<%tzDy`P~Qt(ujVUT zZw`R(RF?>$>t(5cG*CSql_9r7Vl5t~JL<;)#%d8Pzfi%Ljih_aYaGuBhRdg}?I%|yklf}KvEZCP#~ zucbPAlTnS~!!Am5uXKI#tepHfaQ&RjgJ1e73U47(NaUa$GR4DO9q__87prnXbTUN$t zmK1Z(*Q$%%e!R%@t6a9mplPW^QTMhiJy%)Nz`q%-x8pLmf2(Wgv|AOYsPp8M=@`ci z!)#V#btR_8(YCq9GX^B^H;t1&zY({;@H1RXwR=O#X+&jI_8d3nWu}C-&);y?HYUn2 zh56T}e;W^dR?Gvg-7j|uq_51^lIwky?*BF|11(wCVjJPuF}(WC$hNx;{@wma=IQv> zdmQ6dZ-oO|>otP=Gd9Vy+2m{8wtrj!`^lF(ufO?cHO;g)czLck;hL>7~I5W#{SxY~Gj0XhI@{UGICYjsbA%d=Y1#f#h zajwt9kM`=8#J(;E-d48?yR`LwNxr)&O1^GVNIwr1=O_}Jrg|>k4!+!5PcYpl2FXBV zXx*Zyg169s?eI7f7y5B?G{{&nK8wr@`$0zAWJv?D>CYWvUHNxY;~yM7b{Be9aRA(M%>dsvY>(BC;v>0u0Fg$cv+l+W)&{iu(;ezCDN?cRmXh;W4HXYwQd6+Ix z-5N<3Xx9;Luvte%--H7oI2f&a2vD8z9`A6Fd%aZjLAW7--l_4iMABmBGL(WkRN@;B zTYttO5W$q+t^FM?epte^X)^$(mj@M8rUmNfb&Z)FMSg&;bO~S^W!r$@kAMtsMa{L$ zSWtK11Hhi(3Zh1Y`#pVOdjTMsZyunq03IN!ufw+ku&FG3Jd71?E$?RZruG>g02wl9 zBxF~(sSl4O=L^^p1#XcC0WDfdAH&sgX$Tpo_fCQACpNT$UUq|bGGWZRB}=FzcZD)1 z9=w#mG38;%{E_QdGI~!26NTMO3A%+4Ya8N9g zJck_ELfud-^lZk6l%X66>PgL8txJlM^iNa4P|fm;w;R0?5h-`kL3A(o>ync;fO^T3 za|9R_I*w&U^8#SA@@*ZDhJf z(Dv%H$MZ8&jx>-FqF`iv9|*84=xsK_=+3m1#Ft2nCE_KX-(c2o zGOHwS9Z|rAMZCbxZQZdZ5R4vW4JdFon zQvUgI##3LrUL{};!(@F5fEUoIk4WW?j!I?c+qC5auj2g8{pqc({h#aZ7rQ^jZ_a-h zX{Chv`dzc0-gx-XP=p>F6xPJ8`tvm_@dI&WD< z)5UeZs~U|j5c{fxtZiZ@eQrkml{FrByg$j=4ZJx*(A^&j6~w&qa<_}lzV7`(CGJ(> zckk}6Ml62rxe=AkR6&Kx@zo|?`lsZ35UI8+dAHFuHADAJ$JlTG4)S^lwP?Jzl+w?1divN_Z+K1MA6yBeOk*qtG#nXcv*$zAeA)%f|JNT4n%4zADSv61e3Rkle5 z_jR`oouY->lDaj#=PoaDcq=b~e~Exwd4pAgFaZ^S-KY4xIAkr~_jAx4#|`Wj@UFjN4xE{IC7yp^qH#i5c*J(WwdRl0glONi^Vu6m4&AMieE1_`G|F) zVlA}Q;Zv|pr|NfsKmL>jLWQY5#r{Nt=M@Oie=L`Rh6AqVVFkDqt+l;vd^-Q?pV81& zXf|U;nvh8MkA%9w9ts&c5}+!N51o_r(4ob(|44dRwHXlg-4VB|5d;6jsxJIb>sy*OV6}!bd z?8cK7Zl49a?B$wb6`NXD2&F7cRo=YUfK!2l{xkjVmrvKcspW$TiSchHh7~7< z2tnkBFDelD=rg9CP)ivIMxI1T)4-nT|B>u(nUbw9^e)W|F=pNx-O_}!B|u5g>P42N zyvTUKv4$=ak(5M4Jq>-hjIj)PuGkgvwwV%}>p1LnITg(&u*YTAVbW6y@RXwC*z;EN z6LE;~U*7CCZ$FtyIyfMV14d+Xoo%XHsz{ae-^e|zN4x+lt&uu&lvFIZ+-Tir@C(ZI zl#GK-FSo9{IWJ9~Ez;oEH!p1i?U_N>a)vw1#i@pN#g}Bf0ZxQk4Z7oX>Q-EFeZBMdujyglS=Cymz{Ws8~d~7 z#5Z^KYM-!V8s{YLV#)U47f|-uPw~)ioR(@q0yd{b;4jlzj68 zsR3O%+h25uAUsN6GgoJtYg!?h=EKC$XQ0L?FMSzW{4zw$O0V&$;qBg~A)+c0k!()G zwlH6D%8%G9E|4@%JiqlY1ILxXWesimjUTB?jMZBFoPw3ut& z{VNOgkl;s_&`LR{`MzGt?0nnxyq52Zs`K*HYU-2X_n^G=9Mt?I!bkADqeV_%Gb0$g znS@vg3t0VvS={z)8Vy9L6N4fvI`q}v_e>ge2)nvcik>X8iu=uUjjQ=xXZjtlQ6OXe z@0+g=bMC4(e`S(S8~Gz0?>ag!#$`p$j6(djUb*iycU|pyt%Py!DvaHI?nD)Lm8<=| zapN>Fs{Tgn{n{V3boX*5y^3}DWL5~daDRhsdh#-1_=J6Cq!p8I;NL$5gflg(%FFR4-J zl60*0a~yh(n(_SRCXCK7%9(l+&UOE4X>SWPbUPnSWkw~EDWj|ZhN6jf>&Vzh`|H26 z&G#eQ@qg-X#5whE+hS-%vognorwwhddqeO=e6I@@=bIH=tkiVg*_C7zO%%u2UR2n$ zIo(y1u?-8FUt46o@a1bIGS+O0(=IYCrlUW9`l7n74g(edZAu-65{l7HQDa+@5y^&_ zqQP+>wU!=M_K8jVx!2@FI2+zTvr%FMZm}h_mmeNi{R!F0@}@GW=$*|cm5j`JlDd;m z%OD=YfN`bRozLST2}zVAb55$rM(63_*JyOs4WJnO)Si*}Z^P?GIsm;`NAD5CKKhBo z85Hl5ytD_>WfM1KdsE(W?O9pvWyCVYU1qi>r6oANA7=&U#wrAJA8f^|*s2gX^;Cb> z*({X7S3(|^yY}by@Ur2|idWU(*E2#$)8-?w$9R5zQkjUKYu?P=-Bu$kzVbT-;Vz&?avz|u4V!^}%QF8VRZ{T`C0 z(n-iaDQu;Bm)VUjE*U@ovG`#(h82RLOW&~@f_eWCGC)WaNKQQ~+qxv!C*ZY!6V3T+ zswF`zT3^(_a~RvUKf#ac?jq9EXXHAQN7bY1I{{WVS{xLtBYG%;sARzUwAVJAfeB#I zv^XZ<<4z_4a1`yLwM5qMK>VOqdnLf%_l^GE5(7+%7*AKyoh?Hc7KS3dOrSM5kt46m zVyB47zj7_?O+F?s6OeY*GyWW&giqda{;W2l2aP<{B=2<#l<~68zo!fFooCh8B+Oe+ zdPk)nFBbaqay@e;;!=pC7FmBa=kdwkZ!>?r--xV>qNfApcc;2w9OBZ+J>%3hKF_=+ zZ7Sh%<##1|etw%NBv*qx`xskWC_m@={MVU;&v#cXYwB{PlS}9P|WZO z&VM-{y4E?5FI3QPI5l|(i9dBOjOfsKQ3hZ#-~VWx+-Ygiv{$r!R>{-(aqWVLBvZNW zojeGS;JtAQAY6@q@rW`lV?WxnCqS3f;}QFEzrE;`e1}FUL%-l} z#DnWE1K*I}9cd=$xFkyMdEASej8qYAGT-`stB$G+?m+i9@&YUV-36YCc50&-`^F@e@-KIo=ug(Ra%HFvl`7V<9t_I zIGyKWt!6hr?bXrlA$>Z=KK~;w_uP$2?pI0sj*1UH&c%#Vgs_GaW?*AeGL$tFyjl39`NhX!u21ls`B8jj zH)hoMXx2k+6IQSY;Q22Ky~?$>SnLIoyK_~QkEk^20*KI-o$vrQ5S*5JSzLP;vCL$R zFRd~jeT`CYN;&6X^pJ02Hl>HksLO6w+TnX{!5Az)t2P;nA-7nvB1TB`0ECw7i#WWw z%40anh6o{i&O7{GrDnlJ6<*T()1cUVq4lJe-Ig~c zLa$p6@XibNC-F0^^R_-l6++0&Q9}f3MCNo-rwoC^^@2<`{*xO z|DAvvV~H$xS#?XhG$8&;&)ZAV(Kma`V(D_r(LylXn=fVTfw4a+E^1>TD@#)f zIEUz8OkfzXfz3J9sSMn3m2EnCD-P@5W}k1>f7-5nHjiA+l=bqM<`w7Te*mUvrurC* z)i~NTUn%0TKRNv)pS?UbO~pml;I!GEannh+DO6dVZ$Bmt!|DW;ojRLu*-iWpvx@%V$e+B+?HRU++1z{aH*qmv}E;9g`es zR_T|P^zOS&2cNjw7y%oB(?_p+R^NYtv7wEVbL>B>$Dcl3?{5}x-|j+9`A?-BepAyS zn%NPey=n0~7P@CqB7AG!vXwWCkXK$d>L}wzZR(tFn-lN$gwg)~D>M^)Ry<~mcYG2b zD_&YC${l>U|GtyW@I{qBY9Q>q|BLZBJX1_COX4fH*hO6SRhB;;714$Fp`I_Y7F*M> zi&JcV+Kqd!oY+reEIKne;Kv5jb^ekEsPxG1niM^!vv@Q2`!S+lJ?3trTon1l@o>g* zta@sxYB675iJC=aX|C#MbfKVn)MlK*_)poAZiCkShc_L{YAh^1J@FSW_YZv|esCYU z0r{4ze5VM`6XwchTwm%}PtNTnE((o%Z~OYLsK635Qk3L~_;TDfyWaKMWQn_LRJUIz z#I*a<|K;9kTP(vovkqhYfJHKLst0p6{j8n6X%8XMjMR-c8D70xK&>@wb?`1E;xq-o zK}{9z_zGCIk)jDJ$ttLsj=@czVv%$y+!8$gZewI~y!}j_i)|0fdMZBRkF+UARk~aL z?8#wE&46f7e3rvs@BSfdQ;0KLav!}JC6oeisLBCqHn>x9m5PT330fovLWD9{P2S`0Y7{bvKN}gc2RD?p9 zpvruUFu>`9vj3O2h2ZzgSET%dRURxfD@gG;8v?FyA7g#SIHG08c zR#81RJ8v^xkm`2|L_qCxqd>I^9qfWP|3L{|wjB@dkP-$t5u}eNNJOK>R*}*?I|+ua zOeHsKrvF5KcnGj>)usMAi_g1>??(K(i3;&K#!S{{v&6p6!rj!tBw3I$E2-hl^1XS`UUO;oH`4x+^xyA&|B+;@=TRPkkGT_2 zVxd^&xRQ31A`m2e8J9})eT`9aN@we}Cm5N=|uPFobXn@~2dTDGICu{0Sm$-c7Ojl7XhvXzW z+(M&XIrdZ8xnjPps%z+J#~#ruR5IO^V@xSHa~}Wx>=KK`{iE+nx`>Y03Hn}4WX(zA zg`E`|1M#Rx3APp8Gz$2pJLkT;jqQJF^ZsSCAbA6(WbH}?E1kXi`lg);urbI^iQQYo z#CE%1#}XIG(=blgtpYc!38BqHMBf9Uojd1u$Pm4o^NqA`$f-2%^ZtH^>+4t8ZdC-=y+2-!yv~wDi5qr)lt|R;kfOL6;Y;}Ddt17sbMkG=jgy05 zzM;NIj@V~6<$Px8rrGp&v-|u@)E}YFqGG=-Zr3`M>l5P6>L+w<3R-R%u1Nha7xub^ zV@sDxgjD=0PkLkLQqH5tmWr$ISB$qkeq8S2OttG0frX9tn(>VB2>V1%r)JR+N6Gj) z(^Dhsb2^Fx1l_O20?Bu0ot?-ba@5LKeNf3wA9D>2+O@D)`zPA#WN0xg>!KEY1l%=eT&I zfOw|Ci^Cit|Fr_P|L&2go#na}$&#chG*Y_1lT_!RjYdAWdkYE&vGYVNVjTK9m!sLe zZYG+GnEsa+AEXkL20n@;n=+mFFdN0_KC5jN4~&;)tK2C*jYfS7R2-d_d&VGW$K+7 z5vAUiVyoAUqe2LUeFlAPDA|e#2xGa>2C#KT&PdWt!oXhOCII)HRB#QO832xxc*`4M z3zUV1{Rd;^-2E}UAuD5|TkMrA-7f*VSgKIbs6)VdYo+^Nx;+G_aWV|h*`)Z}tF?(9 zgNLu&BhHF~7*xgqG^GWLGEn~i>Fa8NK~l@&gUyGgJ+G!4ku5enfo48jDdv5h*r6co z+@2<(h#w7nV)TYEG@mGAApyBrV}1?~ng1gF|IyC>_Qz>*NMX2m{pXNQgS^~d=r;Ip zAEvV19XfpWHK@k2bA>r^Lxufun)$_<4gp>n4iB0KpyQ*yPRTwfWJV(_HjK{>StUu? zZlvn%B#Y;Hl9V1&XZjvKMVMorEgkF&+C=;|&bcnczt4CP4dE=3Lb@Xj?=_BiU^VZX z8J8cj$->9-HNj_K!EN*EalO-qgsNHuy9sY-Y~ZDPEa~Bf@q+mPz0vkfD*}I$I%I1w zx0~{a7y?mi7aBWs*125ezve$~Mcs-yX0EP>v>wr+E8KEq9~}w$vFcFNU$1KNvP+F@ zh`0Qb>gSz-aNT7?<0)AUZ3vvu+RftNIR`I$e99Tb!dCdCX6wXM{Or=ntNmr^pfWra z)l1w-`(K6>e-8!08$KRcPGx;jj$u|gx-&DY>=g0J?-af-In40nNFAh0^U{x170**B zrkTi}Alm!A6L!9RjxOEd;WX%-hnVW%C*G~nQ#hZS`;JVTd*b$epZ}V}gsv>vk$5Weu6p0+6Zkq44?6x%SG=mkg&Pb9fQ%w&G1<>)hZIBuGQXP!~D(?@-rAcgcQN`Tmpm++xK` z*ZGpatq=WQqe7ny4RbHuvv8I#mfBU!1bI1Y4O5Iw&t&^3UV`O>gI&qg#F$ty_uaI+ zYmq#afJtsAGP=5~f|OGhY9MiW;$KKd{5abuCi&GhvRW+9_#P$s`-Xzw=DCAPPu!TN z=9fwu#H4S^x#MQ#jQi4`Jjcwq(tw1P2yFtU0)xB=&F2IJ$AP2( zor{L%<0Mhly8)45d%i08=o1ad(XlC3gaTT9iv_n)d?R(Jg*1kI@FGJuCb(AAf$&Tc z_|`;o0Bn-v7nJUUEu4*176+hGLt#=s^)=HD&Ix|vmnkCTJMiU51(~_agCUI1%Zs&u zVnqz?t3)vpFvd1QVkAbHPY9fLjHfGxpqAZvS~+ zRoKgeEqZqc*H6EKR))tICP8?s5&8vo|1zWZP!uR(h&%z^{uX^J@^g?tP2o1%B@FlC<#c60;plLA2}~itj(8pab`T?$F|O_*HgWz3U1SUVlFQ) z2i->~mu|p9`zCXX_TuXm|CLMsjl^c>&JjD!-Ju6v@#4QT@;vxoJh`V!??RuftCzE> zy()*qzeUi+luzpnI!=9ld$G5u_vF000mRMNoDGQ8#LL2pX@2)j5bdnkU?Y#uQBXPB z)LKb=rppnLhXLy@^)b7PdN;`akmp`B72(O&VR^y?~GLnndmU*Zow?J|@(s4Nw7QzY-w8QG3^YVV-&Ad+_~K$l41 z`#c7du&PEDz&P!#ZY;=k-PLq?x6Ru-w9^}&Z~EK!cDJ#>SVqA`Hwcq#<4;oDYkwr( z=F#i?&L7hpD!G{^VpK+ML7BIB7SSN_m_*8=aVyCg_8Gs zU4)~QX?>2rmFSQKUMW;#n0;PbQ~V5sq1MM@eyyFKcFa72Ey&UfembExfQjC)5Nh~J znDtkV=!5I*JeAu1ydAG6+zki-Z#RoI zY9#HnLvFbcNK)IDfXZ(`%qSiFUN-Oj$dx!`OWW<(amrR(pjGfGxsty2C58AWswp@r zj^C$`)lCJ^ViDI98CDhIe6JxU1fkrO<11?|{q%x2a~_9yx)8_>*H62r5K`e!V{ug{ zhxDZ7jHxpKm#a5j*IK&ta%TzZwbOw}xi337HZ*e1P zF55EVsG(QRGr#dS*Yv&90~wv6)Ap7E?wiz!S9Zn(67kWZ5aKe&H|Z3Og*+q9&7ThK z3Ja}Xk^Y~pRq6wUobV^~zT6;9f=LlJohgC|1cIV5I|At4pjwZB#eP9H_{>AG0`5d4 zhyh_xR{#Z~??*v3mKuh!;^U``1y-h-{^e{oRye`4O#eyHAU-cm_A>yk1^8sqr>0)5 z7AA@V`W>E$$!~GX&EE4=eRJiu^idi`lOSNymIvmCa;8YOj{;p5?;#`?Sw)yWunTa* zDzH$|KQZ8`TqCU2LnZx*pKIXAm{uB1Z}O^P z*IvX4`*qkIy%g4Rd}#dt+$g{dj{B(ftA7AV%z`8aW`9!Md!y}NyV|lPaZ?ZA{R*H^Te*{lHi`bovKE#!2~;nLHEM0Cllx;oEvRb=nkQ80P}F>d$ry)3L9Km^_ij_g;s*47tzmX{J;)BR{)qSR2+F*`COE8&^X{;x zMLKwofc4~NAcqCzTi91)t`#!2deYCtCtqFRJ-Xp7nsC~2Q;0y}!ZjZ&U&A$NQ>N*@ zXw#-_(j@v>n*osmNeKN<|ZEdCIQKX+5$N%MCxh`vLnvY}QARIY!P@#{NlFrH;<)IS+(-aX$S>R4iM zK$D9Y+derV^m3Y(@clUSt<9UOL!M~+Zs*2zsY2GvY3Oi#W?gjcm6n%G-je((q0Lwj zj^PX;V>Gz(1as|NO^Fts_4LP(##ij=+J8x(`Yq!hM`ZZn1A)l{qu*#%^_aTSK|P}? zLkO})L1R1>Y`rqW_YTA-8UDvlMBL||sZ{>yunX(F7_oY!_G+&p++3<62Ws)y5W7e;d6KV3Q(QRI6@+rd$Wp%g=e~Ec3 zpfNch5)tkK+Xsf}EwyjnHoMunZahMI7qS*?WLsHIH$(be1u+C{y z;Av>9?a^PK=i`V% zBdfQ6-Aros6e^}T{1KCL(`Dqj>)WMN3Ntd;8*vVNCi|1H++bmOke-DANQ`<(CRsTH z6Dr36@PA!9quMN(L8*NP`rPIAElqlp_r(;DP)o*9vzQ zFR;|w2ob2zv%|u`!DU4fMClSjWpEI6n~6lYR!ChEXR^xiNuGT8e}pEfAM0f3y?)0b z7C<6bPWTU5L-Ig}kbr(QG~^--h*tldR=_w9>M+&iwwZ$P(1OPs{>$A6^+SPF9vaWP z_vM`31l>iXTY8G4*feCaT(D18jbvnJq>!2Wtk{wAOQ!ePKL|PKy{?NhU2F3Nzp|#; z2uB^whv)m58J%K9lt+{Rr_L~%+|#{bVIvYM+M?m3LC~^T&}>sLvSttorJ)3Aw@g|v zLJbZG&sa~`^FePZ-lMzO32_l=d5%SSxHZX6e)yq(y9Rq8(x=Jq)1m2cL{HywzqR>_khK9 z+STzrR` zP&dULIpH8C^G8}-7%46;;u)Kr*_|2nfDzoVd{^D{$y^7XnFy*}^H@^bBQ zMh*QqX!GCo_Oe~oMlA*_t5i!CqAC%s7jlny;3m4(P&Kl=&EzII-`t_;q-6UxQfVeR zzk$ZnxbByA?}_Q9?qN+TKUb?eSk@k`%$Ph({58D0-%b^EVu0=iA$I?J7I(LTtybRI zsrt>`ak{zlpO)|GTle?(X@lz>S1cm=1>+`M0=jY=Ek2rUANd}oppDd2+1WU#O}o?3 zKsDMA?G^HH@l_G0{P>9^wMiYt*18qwg2=POW4(GH5SG}mrdj;U&8ib! zF`61niIu6?p!vsvH_FF2H2xu()g6PnB15(jr|@N&n%DE=lM0NCYG<499B9n7rq3RTlwGc60k^=anCvvDF&s+ZcZz-KmK=m zxT}L1-iRcIrjYvD7@Jxg6Rcl#Yu_x;2MGncvU>|xM8$cLT~gLM2ETa9q~t{wApK%d zI`1_%4;TSF`tbAoam3{->L!oq3+*%^uyO|KW(osr%8uy8Ah>8bKP{W310|^#)8p!L znxdqn&uE&Ia)=@~>WQq*jTfOLnlN@oJ6jjhs~?n8b7+k0LYo+{XLAy4PZ_II{u72m zT`N@rUYcnZyH=OKhtgeyfhKK7ZGxFzL6l4g$3qftSp)H57_f)hY4ss(wN!;*&X0`C z8M+mw=`@mxTlcWTIvKjd-ZspBrwd{vRld~T#BjtZ!=ab)BBv!W2~O2{&RAd$WeS%p zkg6{=Yc!JrTNwj!HakuC`?wa{>CA{**u*sPM2psE0YBt2gbE~=nT60nl=mE;fzVD_ z?*jx>OL06iP6Bmc>heWe4?`!x85=}e_fg%YD36qrsibUyb%@YarpL#}a<|eK}TdEJ)&?e2zQ|{@Y?b zXZwH}kzU3ZglHU!X?aJ1o46MHCGhW2o@hrA+qL z2_qfm=d%&Zzv}CDW|ZNk0cRsF1Drw1dRP+Pjseu=8bq5#UFEJ${)atV%MyO77vHPd z5a&YQ_eL8d&*K|!zW&<97Lk?hi};KP{jBr^X(m#cqcR!SmHJI(DYZ@|R>f1nT}`~g z+lrRh62L23-ny&&$;Y;$!43F$bbg8KKFhKKQ$UA{W4PDPI)uIi0WzK^Rzl?ej|E-en9xXc|2tPSzZM_w&H-=QWcR;bPCkhG21LO?}bdoI|oF&Dm+fUAN? zubBXU7I(lNA)^m&h0TrBI>l|oT}+4Hzx4Tz7#$ynB0a^~u`5l!`{Y}-A+7p4deX#i z6Z+rCa}_mXW-m=Cc|0J1d=6zXT%37{q*U}W*NBx~e+k+agP}Ctu&o0coz6f{X5g`_`F3+pbLp^{b zWgim+Wqav*J4MT}^SuU{5L(a5+4pgsZIk9v z0;|?2?&B3d#$YftF%^JTFqw$y7RxbfhG0~@qgcKYJGL!iSDMZ1?2(G@3wjUGqBR~I z$F*yJ5?O<(Ec2xo*;jA#?(vgRya%uzCbR$yHANfMS#JGBHKs)m5ny=_H$8ke7FHB; z_{6OKx`2<($W*yFa5>I?iX&qRO4K`wNAtY=OQ`%1IcS$;9UJ)$WkRXfrGl6LEMbM4? z`O2-bSQJ)#s3K)?ebZ)r`azt>HEDaouRi4_ zS$AH8SREgw*d$n{vf~#W;+uq&1+5E5bqYE-5flIL-S!#DstX=`uiBnu*}(}#Mk0=) z*k|6Zl_6&fM8pMJm5EcXZ#rrhlkhk=t)3$kbUgdwD zc~Wy9_ErDLvr!6&3}Alj&fLZNMDHps#ONj@F5MD_TTSr(9Eb7I@o`X%H7tD7+u;$H ziP7d<&`}6Iag?_Oc}`KmzV(hb%FKukog{Dwm#=ok4XbVxmTD=J?^SHF;d4SODn`jT^^AW&K{6H6q!Zc$T#ptYqFN zci<#gp?k8e(hnh(g@6AKOpK^$qi4Qv8+tbbRm78n5XfS6Lk(J#$!kc;=}%siGg7-p z6GmW{YF|mf6WT~jM+gbtNIqQ~rsV1UH~z}vtx+>T`O??2p}|dFKjEpUAE1+I(?(-g zq9ZW@K@F-$h;i6crO!CZpkIy6x>gYMH^Q*u?Ul8C^O7}4%2`2seAy4XLRp#MqEKW= zPelxO@XOGBbVjT=Vmw9+dOn8%eA7s9|5xX=`|uM`EkK<8oFkE>odIx$ChGoz?z}wx zX%2d`gK-EQKj>qetoT`u{rpVKBvn556Q%HRG@uWIRTM8=c%K-+ zz$b^G(qs(#B;ZFvBvvID^DK_i^Pn#S5`HFyq-(y3b9k>fl*GV%=s}w?`$lP20oT*- zjyOmoh_Uz6@!l9#ZjH3~0m(leso+X>TPX8~4?oBS`$NwPBFJ_G!!=fSb+qNoa%;Hz zXP|VD@X^Dl_=}AfwQaq~PDiz_2Jbfu86g}b+`ZwHloIbL`E*m?X~A>idC7kU`=40% z5bNgRP{ylZf8ty(_&S zZWDHxQ)fs#=AjTf`nNoc^$3A(-n@I_Jb&I8K7WqQ@4j6P3@t6AmHO_ge^mS0U+$SwMFRbjF@d)`B<6@>vebn+ z0F#VW@hv5;%n)Vx^LYJ<%j=NNHu75`%6(HAVp+)>_P^_c^rT?PH->kbF3qeosvNbs z&}R!DU3r`j7Kj&c%GabkS9;6&?6=tfp)>RvL{g8%#L&$Ty?g61&wPrMOo1j2iyc@S zQIPN92TV$w+LQKTBZlS(hocyxT4WZ6vO!sMGyN*uJ04bGg2bSm8L0ZL2gAkto_w3@ zq4{A*OI}u0_{fE8WnM*a?geW%rZV_Ul4^lB3tg*UTn^{r#n^3bqCF>=xhE@TDL`>| zWdcRLrT4-l%+kVbv?i)23k&Y?iZ7|Y>0yYi9_3VEf@La#9ed}XtjF|KA%_oRq3HKu zJpJe8)pJp336IfPpO<~hV^9W-&3tIVCYr=r4O9_m!5sQal2Y&+xQV*E?KC!&q|Eva zC%!z!8K|InG4T1DryTjrdKl3j1D`TTV8Huf00OdOlt?0Fu{z|J-caTbiGQ>gX2<1H zhW8%}27C|$z}_cG@{mO6xvO$ASPj_0PRLn+>$umFo6Y~{nO^<%B;pprB!yk7hjGKT z@=8Q|x}?)D7*B@~aon6U)2-wGm4X8GmRzR7sYQ-kT0dE3|0&amJXziY(KX81g@$n} zdyyDUj|x4kGPs zJjZ~(gizCm@Ja=H^I5foo3gg5x#KXjcOMYK&s}|eV$W~q3AtB0#$%?44`Ve}2E=YU zu@r0g&|o#WpU&iayAJtdLqun4b1!dH%c!U&!nRI8vyQo{j_f0z7N=;_{z!LkaSzF= zdTaE!ldaSGGYNyfY1sDBiB(Vi3$3V|V~$YfY$kC@nw_q9BB|yq;nr(M%ew~huR?7m zNAA+aTom15dNfi6T3XoewA^!5sEL{V;(=libU<@`y>2TmkSBeq=B?wd=EaS%@QW_3 z^WCM&>{smXlRIY*c9BUZjn{)E%BYKNKC%zCS~iiIOMu9k&}0QJN-jWfA-bg7A!6u$ZKl{o1_!hX(>{8@%|#eay&$Sd2qdOQe+reL zbh&iBgz>_8UIyDWk%yM)T?7adFqhB;z$a=+)qcPsOaVA&ztP}#*h`Dh`|zHU z-ePpT;tdTwgCIU==&1oIu9lkXmCz9Tjxo=n>#u%Juc;{1K{wAb`%lVo2UWF{Gi35P zC!)nn7dJwQc$p-O-MV&wN#>`K6sT3S^a;dXM%RVVs`FI%fy2eAg(OuMcg3iauI3{X z?8NO(FsE{*5C3?PBN%iyF<-S2qstjo4of7Rd+Syno27*aZ~eXL-3{J)WOmz{)j5zl zy2sHJNOhytLaWPw$!7HT-O6$OiRZzSaOqwibE#FacWtG`qc?t5l8)CmiV6gLcek{! zyk-7JS6yoD5K|q+3qWp~jCypaJ65Mv##YQ{1pFwIMeNE}vPsNVl*x|T&MtkuM%UPo zwwWV&<_`35G1Je7bjHOuMTgVA4=#5XalMf{Uq}DX_s;j}OQ)bqadxG{)K`2&nH{3- zmD0{anTrPUMd@9grYe<08gXx3Ywn!M z(qwW6#{|EIeHvd+*TO^Edik8}8U^>)emd^27c0kJ|EUmt@?k1#?PB{77u$8~%H8#T zR>aq3MiD_;O}lIKQc-!ljx0u=-&1>&_`h8)7qBPlj{5%4hf zk<6?D=YKHk0Q4X9$e~SFZ`tF|vn52iHH~aCn)c7sJzqj=pV1`c+kW4M&KVq_pqGe`SZfx`0?0lE}ekU0FsGuQ~SBz;Upmv;C9DF&q#eaG=U<*P+bM z^l*b+pD{`SWk7%rL+&_KT`d?CgaBNbD#E})oAn2YW?&< zHoof!jMp#U`r4t6IpQoBn9=DD8CEAb+5x%{MmibydI|v2opsz~!;@h21t$L>8(e$E zK}qO>5e;<-nQ`fuuKigPV0NAABP4tgh9;9vo>X5_F_3E7XR_kTk%2IWJv#rxzd&%n zpi+)~1qUU;^cYC=V<#5Qi@C2x!kEJ(8N-ekjG;*dr&=1Q%43_&C7cN&?g_|V%frSF=vR1k0vA0?a-B#+>uD+kdS0VadK`i>kzRTk!0q`RI zFFoK=A<&w}k`wNdpR)1H(aK-tofsQB;}Hve-L6ylG@b2a=;!D^4b}1)7w%Xvy}M2U zvv*;g0?wZgv($^dS&znjA6#sAo_NbXLEa4RPku3fzDK8AwNuHi;rT`L6WbdJD%DT# z_w-7y3mPi5${docI+#$%5iHcFUmB<-{s`pYjmCyx>E>5gfireg>Dd#v#(dDJBckQ5 zb=}M_w2g0W=em_zdA)w+P6|tFkad4uHnYY0VcO9Aay#4k207>K?Rqq;@9XZV-fZjDS}4Qi?2|gH`$GkcYHS9|V7)gL!02!UwZk9uirTjKL+`SM2zJ z=2c55Ng`}hW02x0?%8|w-%lcLdyNbuQQJ95?p_hwBKOp&?rNtc>iZ9g}6}rN%D`7=!rU=OCODFNZf^8t9NKJNpu|JM|ABV{LyhQn633hdF!=V)m6IrEjH` z1Lkfyd+nTe@zhfNTJ<<479$7>z=i^3ChY$b%J7ziuu32j!1H8Ls}@&h*(Wl- zVykce2zZ@g37C-t!NRM4jG)%Bpqbu``$s@Z4iS$`1i4oIHRrXGy1cziEW6=AguFfq z&cGNNA<*AKszbna(q2_tb>tJTwS5JF0R zAqB;E2|AOjpsi6WdwAJo3=hg?$T7Kn>&72vuhvgux;z2Rp@#9dc^4DUbH~Bj2c6V)hUY-X&z6Dncv{LKi zf)0x_1*13fvPcdu`1(O&r$8%fz>kG5O?#auqR8&xOBn^^H-MMNXKLR57eSsR^vcac zlE`54_b`o!Pp8s1RfCDDat*`$^R?#1-;U8$<8MP6ueD7>jOIVeKglS}pLr+}>$v3q z%vDa(w^TmtSMzojtIdJ@PDZ{ma=T^pdcVct`uvKf%??4nAeg6`lT{<_u39p)VD7$m z=bz|{PIrB(D+t_eR9`lDJ@}2G5kX> zS+^oRrLeo9Fe^zbEa3!AUN-ChNN(2~UCv|l2@Kozu3c54@P2jQjOul|1w!d{k$NvI zxy*Dj?z;(kvu*nJt7hYVQyi%6YEh_?eAS`VMfO*~8Kk0Aj_P=}UytOs6z3Fzki>r9 zn&3$YP)3xD(|`OXK>~2d^70z-zBTWOWV8FvX$$y6U` z%q+mmTxjP4ylhgz5qgP-m@f@@%ZCdV7b*_VIYxG+;LRnBJ?t7UI3OUm)OK57ri~>f z!2$Ek|6oy)N``274gzBUz9b;@IuPE$fE7<&XtnZyL0wj6Zt*lzeK2JbY@z6bB^Xnu zpd`zEcd!gZAQ3wYfEm~ifRPj`fuNVsaU0piBFUJBCXAbAbuzMJKl9hSuNladAj$Xf z^i!8pf1xh+cQTHiaF{;GY!xe%ax#$kqCTnq0~#Pj_RRjO{ZAEyyPpHG8$ z;Em^RnvM1P@uVRM=yg?>yKo1zg*8M;R@U|AI-!?|f#I?BU;aCq1)-H(iKP3O>`X6% z|3}n$M#J4jZGUuv7)0-b5S>JcUSklwm#9IM=%R~mf@mQ`bfb+6k8*#DnqHoGX!c4ECBX}HyJD8fV$a^qKxscbCJ)stKRyyiN2np%Qw8Oliu0wAHY1& zQuq7x!_jY_u|ED*7mK(1^ybBF1G8Jzo>J(?X{mKmz6YE1*MFiK126p@%d)1Y<_k*R zJ3Mo8|M7El6aqVSEOL6hCvWy!QW58K=VpB8g|#O;ln63`gYZ{)%})DfAiH`!&d~G0 z0$@}Dny2&x__YES8-Idb>GQub*L>hnQGgfv`-oQ#l$+M+rTS=SL!!FbCm2+4K@vS! zHl_9pZgEBw&q#^gOQJYfCl=Vf8X(GHP;j?_=HuSNdvlKzsOm6S$#a#=fzbwJq;^_k zu$RL^J?(~J8dIu$-~*3f=_~Q+c}u!rDuYG+2gGIHt109QTPA1wry7#4FLYBDs(AN4 zop!n^blg+s2;pNmx{b=h1Lit>K!U)m`r<@z;Zj9evouI z)R+IFe5M=bwfjnZht9BJj~1ADjywjlz`ls`%K$@XY2x|Ps+(*^8S~|+`;h})b zXsAjB@KRzaaGJSqx(dT`?uSG8!H|m*YCwWxpML-}>FQTTdkV5HBIY>Mbt63s^Vq${H zaJyt^Pu?d*^bsFQta^j=8sZ?qKRJ?kjL&KMKSVzqhynG=)*Kmy;Kbe!Yhjk#)v=Ds z7qxHSZpAcSH1sdwUFP;)Hz)FsxeF=UAJLw)bOlviFmD~OGa>yRqrZ>!3O@;WZ7d#t zy2Q^kA88=+2XndM9qrRZg_qUjg^C#){Gn>JLiRH+?Vvab{_{r6kmbd6KaRihFT;TR zXZBph#);~$C5tTcSZ4Fv1`fu#1`Z}m?=$__=x3hjushoh7j8Q}+^h<8HK@v^H7w?O zVz~K6*B&!8g)fu#*+~1pn#s@i4Yo8QttQV)GIrkT>-jdEy2YOEPv2!~ov)_M_~rA* z0tDH=7Z6j6t>KJ03#~}p)&L8pE(IO5Rlmf}{&ZGr3K zLHvcsp>52}rc|vJ2kRLjW*(13QN`=oS-;1&^fpAFIUkTP%`kjK_Vz#*~zh*v45!uYYR&L)vT>b>%6l(7+SbH)nWn;o_&xKdIF+gU7f7>_~z zYJTgom|cr*=K)H*Rqk7+ha7}Q>w$ON&lx86#+Mu1FPB6mcPYc*7Hqsmij^^Gv!p}& z=8xGRwABTs1q1W#-MvE?yA zUI$EN>@BE8%QDj6cMKXu+D!{TqJD$;vD)%w5@cwZw=)z=Z@Q^|H`8<2(v?h^&akO@D5-MX1TIVXMA%SUs4w*9fx} z1nO~#2>MiA<%um*M*UVA&Zt^brabuK@y(Z7H@jB-+R7N*TUC6W)^OD+rM@^g3A1T5 z2M$FUwf|$U_auZmHe&;W(?n4o*=`nt!3+~v(0zsE)M)xuw;hW~O6xxIq~d83f@`HY zJx=12w)7Me&K3F!F1M&mf7Mp%Nl$;9m-2N-Kd;3k9OOu0onn@oB?okQ8Z1Et`-d&G ziMH#k9UiYPI<9UkccsuMap&-whS^BG$F)5YOK%S7UK4C>aM6lO{QW>8yLA9k>Ag*N z2bPj*IQ^L;-^(z37na7f+0DmMynfiTo&1rBBIdJCpa~}Et=;z_?6(0eI+WCj6RJl0 zV`1=;hQP0#cMT8RwNVaCu7-P;dRvzhzXytMFDA5PL{Ub5Zx>P8+8{JQWTkeW(NF$7 zAw>1unwq6e-|y}3>M^akYHiK=D($E9MM;SZIp`m_9Hm_k9!>KQvJ3;>hv(QrAdR3e~G(MV#B>ZIoam)%6HsTc6((GYm+A1g8< z++`a%z>WpvSD|x6_~4}iDbbkUwai-c2b)}P0kYjo-vApuP5OsWMPS*=&x{(f_<%BY z!i!p~{}pa(QWw}Gtg~t$aH&q)a1^p5W%tQ`Y|}gf5x({W3k($KL4CxZJW(5D5JrCH z^6H+wO_a5qa!D@+3@6j}ROqnaL!Xljeu}A|Vl${6K&63EnoY(0 zbzuQO*P4V898A-cDjdYDANZNUg@n8yme39$Dd`zHEpkC4iCMr>}zBzbKQ-Q!iJ^HL#zlq6|{A~e`@=5R}EaHfRb+6JpBK<*fjOjJ;W4H64Glgg*gR(+I&_Q;;7b-Q1E@!HA&~)51W(O71Y{)DuZA{p0 zAr3b18uaTVSOnYGqPz0GslSmUD1W+muy@%o=g3Azo1btYDY$D_9OL-kVpJDSse3>o z6-iTPMiSzdi{Z1O*J_bX9CT%lHD=d@$r{7D#ZMrk)Pt>tb6-0y`Awu?@l{>lP& zJA^?;7ke0dufS-UDSd_|)hPG9C7)DHX&jHw*q~->&@bKVm`F*;!XXgnE zsJ4DLa7wum$tG%R%~d67!xPr?m>Uhxsi!yN*@w|qOS1XKV{pjv3}^@@2}wJKeb4*KvB%v0UB`%H#4Qp7Gk=gs^W znii^7mm#BtXC?()G5nKh7+%Rtr{{AM!G}R&%1ERS9uUN>?n3gY94I zZr?X@XHB^GjZ~wQ%8n5$h)*|!64-Ue76rOk(jJuL?=t>mPq4QB{N_3`49vFI10UnV z0j)44m7kkAOnsHq6PJnu?8K9j&LFW=V5!a4E^+21{~ZM-rHMMBindX*dD?o->`8o>GRyd`Dcy9<8NpC%Zm?+zmk}4d?1SNUHKE!{Y08sNiFl; zakPXcahZpz&lC{s2_v9qAyJPi(v>2C{W#2SaAz!bnOUC1F8idT<;)Q)#a~uuW!uD) z$h!9l<|b-@l6H&k-l|n!$EJfP38J>vUU#@KH@bqR^$0d^xAoNp6Vg8ScIn~W(YkSb z>D9xYMb@77)1IGzMzgOZegX`gfBL(>%2by0EhWjdv2`DO>!dkHYSj4}^2xr20?@*}IY>W~Uk1Su+WA>59Fq!lQOR}V2$TqJ1>47Kv6OIG>6VL3L@E`@B_Ma7r zZ+Y8^#ZHxLzg;LE)0D~APW)Y@{q|P@!TtT2NIZtqw(je{nocP#h7(nhSMKvztOi$ExLJGpA7x(8EsaBpM8rk3$Znb zlzrT4*_CBpsd{H-$8Vrg_iwqRLU7Mb)dyD2@$Hdby|-sWeecVc(~Ypg9L#EJ&Yy_?lxa%~xlYCUqlR z*c;<@Oi)QUy9{;STE}&}8ddFoH}ddaBc7Ne>1Z{cnX^J@*kIyNL**##=7-PE8n+yenv+N+ zU7vwS1m^3{XkReYWegkq?{oj=dZRa*oE0?k4GE-DN4H~t8`gChrVkX`A7~8Mz^!QO z6_aSDH>(RrzA)bP2zJ<=fdD)06VXlYBC)MeUGvkQx>Bd*xO-60Mxbu9f=toF+qBD$qZ48PI}U z2S08r%Cx#f0^((5>y`7E4GWUqE>?ooL>2fZIPiieMELaw{0wHI#dni0fg{$%X?DHI z;o0_!AH{$??xe}eK|Avs`F`eXhuR?I2Th3Q8t z&XXYjb9(q^eBwSiDTo|BSN+Jkr! zd3*?kH9Np{KfbR?JVuBW1?6Ugz`ym|@KkH?*thr`5a5x2oP8t8p!&jaPpGPDj0iw9 z`eJ;8lnC`c=_o+R!*FSVbRl6Is14r~7?NB6=fDcoxq!g|7>t%B=Y#!)l}}rt z(yWRV`V8xD9xFtB;!}ddf1n1*ma)RVFX`NCB1-TnXTu#(yG;h@;7f!KlgnwG0)X9G z&wZA(&4Xtrg5E7qnpfbk8;sGnID0g`CNvN0+m0>38LTyqI^`QednF#_%0CZw3T+Z6 zjPZ#;nP`<_vsMOjFehJC!X_9%FOM4VeVNIvCK;e_u33))fOf3z7!8Rb-Ol#bSL$E* zlJj?$E6fWhD*`^2%eW=M+mjOy)}!46Q*v|Pg&z0|5Qhp5*%7W}(~QJm%YPlSBa0ch zW|VH=RS&Rv-C+HLrGh)bQ_lK}lPWMvEW@@U(&{7B784@Q4i=~9&%1|aoIKO*%rel;Fg z#GYwjz6~k0`pwZ_d)ah^I`v3lUemZusSZrm3Ahsk#UIlG;Y|TMDttHr=UnbWAoP{PovmX4@*c3siZ;YXA}mQ`GO}q>97_BFXA3^s9cZ)G{`_ zFO-T97BHIx1S&B07Sc1G;}0YTFJYW6YJhvi+D$Mt2*4`W%@zl8T!`dU(JFvMt!7~i z`;Q(8smPCk?h3e^NDs9x=$a9k=0x2LXNWR{GGnLFII4P^-I!!*+4wbz z++C)ophm8UhC0|vknbuyrv2#YCfo7p{B15C>7^o}eB|g>@1nn~k74O7!Z5|nnUJb! z2Hs9XTQZ-w@&t)i0+guohEK>;Ml!pZYIqVX<=S+%}DEvxIkUB8m&0O`m* zn#3&vMsl>DSHA?K9EC@zm88~iEQ7=#8iAhMhlNu%l#Q=7H|El_R`EWiBTT-4{B=C9 zt56F{!Q3@Z!Nrgcn$;u44_{@+@O=Dj+|dzseh<$ZFJyN8wn!0&vSEbYliP}wZikPr zU4@hXUfRZI^)(2MIx6NIZJY;Nxs1Q*62IlQ{ThLgi$CanKSpOY(9Bj+dWrVgJ!mb3 zDBtgp(Gsk&%1^4qQa&qHP)5nlm1imCprprv#o@1l(>FEAY?dG5@k6KMhZHi>Zt=3B z4h^BcyG9|l-;F{7PaQ&D{pS$UBpT_|9cT$d%~Iuis@H-2pu*ad(iE}^}-oY00#ylJ`SxcgD z=ITMpOy0lxi60hIdU`!Uoq9*UQU1XZe{)NKC7)#>q;+V)u1w0 zuSr6Ch+1B;5eyN)S)ei`zFzhz)L%!bEE{bTL3r6cFa&g^$~sSUltJ=%@$(wC|ElV- zK+>b)KdYXOQtD}?fFZ6ZNVIn)OZin@-_bp=2coM60Wv)Th7I`X5KaifZm|_hCIZgA zbU8)>dpI29(t`@6Z@c;nVMp4fycg~8_os%Zu4Py1<^bVBxvAC|w(_0DP~E@o9Z>nc z>4wUL{IKSalIrl4`WyPA{ssji1#+O{ndZwl;4I6+^BCvt`=`LmCJ9`3k`X_+q`%sJ zI26!k)$~sCL`N&TUd7pHbm9g}F{blxF!u-pkSdIT=#~p9uv%aM--LtUcwig6TN+dBW847*XrKS-aYeJs;ya zcG4_`c(^n>H=JyFF>tW5aTwD4xdy#D7r*as!yHLA_N|%b*LPn#g+E@dDt+<0ryKhZ z;3KQDNRcB~f0?Uxu>|t~wNez;1B)HU{{pM+EJ=?jAMxWZ z+TP0POFOJ{bdJWwZ=k_tIjxxT)N89}pH=Z5OY#tz8pB;*6vm@;=v>sP_YG1X6Gu7m z-LF`V-k5o4OEy!#T06;Yye8pybyCvLYVBoWx;H7o=3&WWRIalfMXz=!d&PHdh%S8x z$`&xw5ieWqRxPh9dNeT^MNONx^ld76nLzPITW?{-++r4OUgI2z{YA*Jdz!{z0I9=3b6ZLIoo=2@@Nc7+nP3;IZ_V=2XlxNB;T zUj9GZk3Q9!l1sT^aCX*-sq)1ByS}gPUtdiSqyEi1ny=xJaC65=H0wlwI^z94aJEcSr?A7w)k9VV-2p)&GR+sguEg~TqK;&)&twxc{=dO zrI{gkc^Sa&Jo?qibpGAB+A?B;z##Qha2bbC7?GT-QUgGRsm{h~czmr@IQNX;VH6q1 z3upVAmBzS)Sh5f*0MfUb7HXGDIEAo9>DLJr;n28Zk#_gm2a>W1XPPJoBV6ZY%zWFP za25xB{ts{SLg@Rpr+doyuE*t|4nHv=k-T1XN=WUZ08_qr{N>i(aX1!;jvm+VTMzn| zM`k?I!?Jn<1b3wlrleTPK+gU7U6zVm3ZT&f`6|+ZPFY8Jm+r4h1jvL_z_ovFX=u|F z3nJ?ti}D&NBanWBBMP{L1*L>20;nQ{Q(xl*C#sfICp8>u`+b{jC}byq)ahH4K=I%5o!-Cv*v2?IKquxQk!AewDsx~yP(NWvtnzkkr`s2YLH%g5x5w8d6LUF z_ju#@%e=T{LeQ5yOAbm)#HI9@T^E7fXLWZkh+y#Dq4Q(Yxqc^O*8nZKhT-jcGQ=F? z=p~(Pa}om|#+&k{a@Sj~Q~b5u(fPW|Rf{e3Xepno*29eU@6%RA`D}=%ZYWiB9M~1_YSr3wu#m>YdGmb*be+zJ0P;nY^+p5`cpAErzzq!`J zaH>(%6m!S)7UowZE50HVBJCG1`>snVZ{#<<^?##RRhXw(_$MQn=Rb@>T>qm6{!xB# z$u>XLJ&UJ5EsvlN5$Jk)o@7!?pvpIk^H{^zeP3_kS?!a&2i!ln?(m$xxuOpdsj<38!=-LzT6zI}R>ttTV0NqRk!Ci=_Ew3TB^Q6G#>I!ZKNGD)(853UDx{r`HP9>^|(;XkV32Byes(y!0K zm;45M?<1fLpQ*}i2Ff6`_6)K3gjjrpr8X&n(TcY6Hs9@oKG3k<-)2`t4xhiDdP_)3 zs;f#{@l)1p`ZpvEX6VW@jvpB5%CCplUH=k^@=LgDe#J^V^|L=`TDYRk+34`ic%R3^ zjXRO5QR=2f%8Pt^EXz2CQTz_RttbWnCC?9~Ro*36n`n&tgp@3;$b;jucmhuJMSd@4 zm=g!>{BwDH{BW=JDnF+7nT42yLBArEX_J#rQ!F8C5ABmzID{p>XGe%})&JJFub}ea zuX8e^C4kmLF-=^;0EBE9!YM4xEUp{xGtVrJ!&f zM-fCK@mC}>-}i^>XsB;q{=j1|V?2ulFs=Ksz&i?q5H8H-l&I8>ndqn#F0#?iyn%3m zd{yg0Ud{?$(TX7C*pelnyo4ptSlu{LGs4@v)TynpEj)JBP2`{48s}}5nElQahh%z?@Q!dAF7-NJCn;9DAn~e%X53D8 z+FK=-EB&W+{8;zH*5lJIMq>dz5u!-p~Hg(Dam_HCcpz7WiG`yR&U=2b|E^}jQ4-F+qp)Ok-=o`kgK zTjp`2W(S_Q-8njn04qB5{_!?i&6>SSJ&&7jvW7Z+JHyL($1ZvY&5Os0**rum;I;To z2`qeQ)9*S}jNa#~oXo*ln&}#~Dhx0m+kvLnay)Z#HquZH0x@*T0-qi{5~uVpbIi<|Msu9wAP5$sZT_?xd!%clX30XjXQMUP0Kka_SYo1n@F37DYf{Xmg=g ze&KL;8JJUT^Ma62pXQyMxagH^yGX+D^GKIUpTh(#H5_87|K#SZ9;G^JDtEB}YWpEWom1_&9B?9Xu&2B|sh4MG${XAV3sQnFMwd|1Jd9pIDdV0tYrwG4>b zby{eFBVmV9SjxEEa~E9*1;CYm6BBji-rQ!JPy%rlXB6^-zYQaC5I&+0pUMNBH7@kn zVLe$0AFva599SbTur4}GU`^L)uZ>3bcbh~@$|)(B&;#mVDxgQ$*)2JW94 zW*~Ca`MnhCT`pb}gtmLlBa??OqPqDXRKa&5@;0wfPH%(M-R(J0d@ ztA+}1R-xaLkQhQf0ZNhp7UFRR^v#|+cQag&=D4?FsL1Wls1HGg0-<`^@9`!jAWL}m$QDe#2Ugobq0@rC zzNK^n&NId~C5{J7qF=Om4aGcnYEQ3gw}jsWFA2Q<;b&(|OJ2g^x{gXB`ISa|G0>OEp*HPnqp?=Pfu zspXB-%;x-U#JDOn?sJz-a}m3o9`%@o)LR$r4_3`OdWmi6KQ>2yo8QBj?2CmwdL1uo zHlCkEZM0*jLWL>5^2NMZl)P3~s0)4YS@G7^B>l#4zhqhU?`bzD#%q*ei2rd}QL2`x z`i7wB&eb|^>)c!>9=T*?QOq(KxV0#nnVJ1VTvm&US$+N=MF5aj`y~xJRaxdBx$6#_ zh~^FJccti28=|wkOvnE$JkL*FX>Y?za3w<=ChHqvIrn9LbrHKvo%^vxlSsh zBt+wbXxky==XxL$#0~znhbzK~sW-3}_?JK>n8hHEJKg*22N=5V@d)Q`5O6Dq*6HRz z09mPmQ@C5F^D{Eb+sAy241%3N3r+-qA1=^3$0$#fEfyPyWb!_XZ4Rzc;5duRkE*O@ zyq5+IQpwIr*Bt(pUB)U8Vk(VPAQV$czG(|pjQ5(rH&E3V&Y9)!sl*>~aK>~07USV& z3mY^~k34nw+U=^;%fZjfB|q?spB>C+5$%?;+^%XPW*tud@q?mKfi$4lM@I*+9Xspf zhw0<$QG_}X!c4RZSf1+C=2OWT>i909Tg$FrEAtav2gyLW$|{bXBEcUY0(m}zDag_n zkq#TYLIrD@i%S4ANQ$nzOjVEg>%w{C+XrETebd)Uj%b}xaXT$PXCqgzQlr)+M2Rot zi9R%k!YOuQqCvs_yuva4KW8r|OQCWa5Gbtcp+2j78xQsb{zu2%;H5rmj)POO{nOx`~_!6S$CZpViAc&m+g@lc;gZ~Rq} z8;u}57ixyEHEctO4T6aw2%3F9Fz1vazhrFROrj~b7!J4{+E;nhHH9M* zljVQ36D@e4w0-Y@A?TjTX&}~KnvfE~7*lWBf!d+vcZC$VAL~Xo&8>KR?eeHRMc$V! zgF4u~*qW2og@cvz^@ox#<3sj8;?pMv7%8zFtOZ@j%E++H9$kA_$u@+K&IaZkOgP8O z#fNj=>}L)?etq{WgmF}Gn27b*3*@ILGH!m4APiAbPvQXKFZrg8s=p0iE23lt40DnJ zp>0Q7-90-wl;6FPnYPL7d_8R|x4qTc;P&~=dk8y=;9Q~hy7=ZKSaDBp@I&r*>E#K` zC37?5O|wmJPd;vJwNnPu9ou+WVcXbH`#!B}uTk)|KV$uZz$BEji%*{L&NtPQc=YQ; z?wQ+kbI=f%PQNX=IlCQOd@XO9`f?*+nPDX1NuhGG5U#vGG>V)k8JF-4JBe}}eM+iC zA)IJcK8hm_Iau)J;ogvO@%e}K*0x_Cw~rS;R-P^zz27^2_G~dIL-r&Oozr|f@=n*Z z#7Z2i5`ApDM?=n~l^N7lJM;H=>2|RqYwFdhm0e)lk;?0~kO=IAje*{ggRb}5_NU1hiKkDrL&JQ3ak%YhH z?mz=Dp+pH1hQ!M+4n{{%_Zhja^)E)%ruia`D_3!;{yT{~WWZZTXl=*xMyMhiN`>BZ zu8x~BK|JN4#^c!XVI;-__4KwZh=Iem5Md*I5|M6IePcHrl?W?j%_F1(7KhQ367>axH*t6 zKUwO_JGgy}36vpsyFrTOS-enZduX_k=mwWF+&sldl*-xOF5KudwIdGn3ylcTlPs=? zdsRu!C#R$PErIR_!6AB90O`6|@$CnfrV5s#gk^iklf$Sq_fSsPocDMMw2C0XO~nQK z$$fc_k0sLd&bof0d1Mc{Li?doh507V0HgJrTWWtOcj3bqzY(o*K{{k*Pz zL?C)|$x3aoD3Pv@r{i3nDa1b#uyX7I$Mt$>3mNMPChowwA7$LLn)%h`ipEnf)MV3) z=*Vj6+SpStu1c#m%wAyQipKYkP+qinGWK%`(ZTot!6>>?#qv3{N5iBaGNqup2jCY~fN$XlyCGW!~l6;+>8*85|heibNAT zC4?OD$SUoZKq6}ID!*0W?pC*E^rNbnlOpF%n6LDEqnvj)M{$vu#>eKTE}#aDP8GN| zvB?N+e%=)tykc|x!qk0rkK1KZJFSres`m@U!s{}dl;*n`t09H%<-Cv%j4Zz%xN~=V z;Dl^@fEaVShSz~pYX7&t1=tN^4-YpA1f`b$B5cTz*5VkS|mOv+; zm}8bL(_|`BP4V7;(K1pIvw1|^-!Xd#5jPXy_1b^cfBb28zy3p4q? zx1{fmy9|Eu=+bnOp={996(O(H4rB8{5e*?pBeTZcv?jLha{^O-t3R~LnJ%3Fw5^}I zxD7_K4x{+KV^kn1YS2{S3Ul%|Hn>nWA=Knx@;!dN#$EC{7&zLo70a^I)rM^uvgnx$ z&sBAGlnCWWEPGGYiW29`M8;TK zaJ^zOsbspOi8-yD=EpOeGy?`ed;MJ5MR6;Z{hW&Qk!Uts=o_0pCsSP66tc7R%_^n_ zMF_k5!3Tz^WJ4w@Oj!Y|D;DL`?Jxq+W`Viyf1q5=32FHmXZp@CDw1#uFjVb>bf3_&Gx26lxjmt9W|!hcdI~64E@4VZ{52yv>&S8cbXmisKB3Ni$9uo-@VV!mN413bI<$Uq$u#*l@lx@V{&m}WEm|YTun*oA zYh5#!g2&n7Q2&(uDL(2kv5Fw9-Dtmd?(>kUG@&s_U%spivPG7p1;o?KMlgj`uNHn`()$7Geua~Sak?_T<6FAa{Z{k%EomR2e_$OyHH zv?NLHlw&;}ZTkGF(`{smRWC@vc`yttEJ3PPS`FswDpsQT+1Qirnl$r^@n5TYmb1~P$c68d{tHB_i1nO9;U%Q z2QyjB#1@KdR+2NCDv&cCFJ~D$n8_PHm`S2O-BgVaLYI1(pO&65oNlPopR$9}wfCX% zRt+C&=C-a_TPC#{i;YZ25t*X;&35C zT?bno-431wxhjelFL}#gU=2YdcqXRJk%`wTSjv?SbLU^EGrRltCpya5%UC>p`Nl*) z`OE2Qlyx=={nh|s%d~YOt0+@>d>Mr>vNJ5wvz*X%9@ESzwFTS6v4X@tD0WyBGi>26 zmU63{)zv)&t^5SYZSbU3CWJ)sn0155uqxnxsH3+{Lg3ir76HQU>Af!?xwKryfG%On zN1s6P{uNsqx{7eR-V$WNMeUXx4BiaYCfP6%64?hmlvlLpnNy<y5K3_ zub+-}L_X~ctrD&s-Df>h7CcF54_1jA9iJIeAQV#SUuRm z>+J1ztcjVvQ@xg}6&`=U6bZkQx>%GRI6o5Jpj|VKAqz5`!<-BG_jU1-FZH6{-I@z^ zK6hWoMLf!;)4P5skKEEL1nKJGbJH!a`L!gCE5#JnoZu{5bM+O*`5duc9xK)PJK{GMcty+1?a1-DPUZPQM{_37eaDXt8$@${H043xfx_7huXz>E%)O>Fh>IglUN;m)+`zU!LHVA-D!GFN!O>_9=fmN_9*SA)L7&JJ#2^5Uc+t=JooCO((%Qwj} z(RUPUljv=s-xLQmA_(h>tAsLVW*_|lT?ui~Pj~(aF${hHg?Ac%8gQZ?#=33`Xon$- zEIzhXd}?PC>?TCP58r­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

1X|uL!H^$rr`?L6O(!2hKbkSx#yU=9}Z-j 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%0AG=xgH(usY4M#w6><9>Ru{%9{<(#4>x>a zxZM`KE?tAe<&MJ@x8Uit-ht(dKud$3g`>q zHw7C1GR2-Lk+Cz$*%{PzPO{%o+6?qYz^!cK3kuhZFw;fgCtk2GLJL+cZ>q4q2R}$f z2nv}$HB&}AePnntqXhKC9DCEVt1P}E3Ijx$L?MaXeDo=IY?Fr76ncuC^#ow5{cO)2 z^OTK+ai5jo!TzuWp3n8IPq&6+Q73 ziL*~fJ5_Fj_py+&C-7Uj^W?mm-5Dg_7=p9MtW8k5%7S)(ni=mJ20cx=;7~;WageSMFzT3!WEroxuQK`}iPzU)XI@oKHGlY8#&7pt@EM+JSL6w z6LaRHe|w(?#K0$8;ocBg56iiVFaFV1wGHG+1{D!fIkH!6~HynyH@-|}&-m*Pk zh}$ODiX+k=QR_m=uv!w@aAI)+V~Qg;&o}6TR5N6LB97+8z!JyL_T=nq*)Cm z0{`+0#veqjtg&1N26*Y$amifGJuS}i{ieE?eHyX;2(?$9V%uYW6>i0n%Zx+0h8EL( zwlHfuy(Bl;sV^PQg?&?rgp6EOoF+%lRjycdZ+b^S>cGnpp_cA`8^aCqqBugRN&Z$0 zbIO$t!v|9pGi&ehcaEL&SLxol1|A-${q?ShgLi&XXzQtvJ{{CPiXv>fE-8OI(sFnu zY1Js-uTxyDznNq)zoGwzc8^)MH>d@7#8MO5lRGNC&Ke(Q3c+zDL`8PZt`$f>?$u#)sd@<%j14T58%r$GUMu@_~rbMV(1`7efbss7yXG)ZV5sxVh+6EmQR>4fW*Hf0Q)kQRc?Xq_B6|%e;Axuy zdx<%Z;Z~=SDUqr;i^j6c60-m+>3E5?TGoCS6 zNUK9P6PYQMm{|HorWddeyPP6r)d@CTo1!O|QY{)r7pmQfGx3vzMBftXsbpi5G|I7ni(D&*F8Ull!>UT{Taj?cd_>)LN2a z&7olH%dJb=-rq&GloomCfWi;JD?vvaX4ssbI`^XB#wrs&psR1d2pm@8soKrY_DE7> z;yB^N{RcfDL#_*v3IKm2v{az&1^ zT0Ryl1wNH|J4HU#B$q63Cn&3bm>81M0hRyM43#t?-;Qao#b=cKht0(NB?KN4Q~6;2 zU)xUf4J|U=Utdy0rlf;*cN*Rvgi-6YlFhu%WXZk_-_LDQZToctkr#2Fs~@pAjqmG6 zcuaO6o*sB0n*-`YjC^(F$5&ayQ>cNLwZs$fJLQl-QA0)4i1x!s&!6(1Y`)Wd8&_|E zSiH1mKM#`>L+@cSz0e@_8Op$ zgY9U=-jL)8Qk3@Np41Q7YyFlM|Go6!=dhsk>?{#PZO@~3KVA0FSiF5ee z|9Ykcu(nyIsQ(F*MleLYe|pW?{OijvIkzSj8~uFzz>nMK)z!t*+4FBtI^K2ng1&MT z%+5|%?srq84!p>^akr-4v2!YNLv(B;Bsl1qCcfFUneXU&^5V|7{_t@4<(@lAz>zz( z_r_fCHWq$1hM{l{o9d@5aM^c$LP6@KxuIUE#~|ZrdC>mqaFg7$R5Y>CDhQ`!5oY?d z%U&oQv+1=^xT5fLx_9p!+W=+c#p*C$TX$BVDZA?E8-$o4&`SU}_jjLREhrCF#AA*tND#610D=?SP(RM*z(zcI*KxMd z=~CDq183c{IZA#)72?BGY2JBZ399P4H69lFW69o}yc%~uI8Mef+U8)UVa#pRph$U2 z@FTYD)msf?{xQNz-_|_C8p9J{4Xzl#3Jzv1wP@Df6vZoHADpV+n&r->N^K*55tvzO zKW3pUl1!A{9sK!K4SjukHC54Opi`|zOO+`fTZ#Qxm8Qg|y&BOoYQ;Xba0WYVqWQHW zHKVyWb&cu|20*EO>8rx%j~q`pB1zw=0WD-=U8on~>I6`&e#>#>A?m+a8?`Wx%{s~i zLTHuP$h{GfI199j>-TesB5elPaVtLR=Gmb0;s&Ycd&6`D<|_Ic1G}9+EBpXzDVD|! zeEN7BY)_t2&o&@>

Ceq=tCg$3DXo9BMzci{$)dwR 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

fTJl{vs7Mo8AyixjFhY0LlpT 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�E}d{D_GkE9B8?SU9{Vk`q39`;M0h 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 u)oY5eb=yW9IIs^#j~>O5BZQC>7XN>T@vD4JB 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≦;iysd( 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 z%0D-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,?k_CT=oZ0D~4j>GDo!89x&EpPxK*p0@N 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 z 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 LNkvXXu0mjfjDaxU literal 0 HcmV?d00001 diff --git a/docs/assets/tdesktop-dual-session.png b/docs/assets/tdesktop-dual-session.png new file mode 100644 index 0000000000000000000000000000000000000000..1942c7afac65d7ac54bc32b95cdb77f8540e7521 GIT binary patch literal 1056962 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 jfE!HUpEVHr636%66#>bu_&H3zuNeTS*h9Q6Uf= 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

yn5jUMye>HX>CU9 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 v~? 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 z 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
4c~#B{3o?)D%6#eGw{8L0O|IW)AvMtMCjRU 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
U6yzk<^z~}*}smZTTBW9m%+b<)hb~9ji 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%0A=uvTZD&P_c{VOURvr{ao8p4*m0j+2t2mT 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∨dH64ITfYAT(v?cMY`)8eSefIH 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 TStdC(00000NkvXXu0mjf>I8%l literal 0 HcmV?d00001 diff --git a/docs/channel-module.md b/docs/channel-module.md new file mode 100644 index 00000000..b2f2096e --- /dev/null +++ b/docs/channel-module.md @@ -0,0 +1,471 @@ +# Supergroup / Channel Module Design + +Date: 2026-06-01 + +## Scope + +本模块实现 Telegram Desktop 第一兼容目标下的超级群与频道闭环。项目不保留 legacy 普通群长期形态:`messages.createChat` 在服务端直接创建 `megagroup`,TDesktop 同步创建响应仅暴露带 `migrated_to` 的 legacy `chat` 外观,后续主路径回到 `channel` / `channelFull` 语义,避免服务端维护真正的 `migrateToMegagroup` 升级链路。 + +首批真实实现: + +- `messages.createChat` -> create megagroup +- `channels.createChannel` +- `channels.getFullChannel` +- `channels.getParticipants` +- `channels.getParticipant` +- `channels.checkUsername/updateUsername/getAdminedPublicChannels` +- `channels.toggleSignatures` +- `channels.inviteToChannel` +- `channels.joinChannel` +- `channels.leaveChannel` +- `channels.editAdmin/editBanned/editTitle/deleteChannel` +- `channels.getAdminLog` +- `messages.updatePinnedMessage` +- `messages.editChatDefaultBannedRights` +- `messages.exportChatInvite/checkChatInvite/importChatInvite` +- `messages.getMessageReadParticipants` +- `messages.getMessageEditData` +- `updates.getChannelDifference` +- `messages.sendMessage/getHistory/readHistory/editMessage/deleteMessages/deleteHistory/forwardMessages/setTyping/getReplies/getDiscussionMessage/readDiscussion` 的 channel peer 分支 +- `messages.getDialogs/getPeerDialogs/getPinnedDialogs` 展示 channel / megagroup dialog + +首批兼容 stub: + +- `channels.convertToGigagroup`:当前 megagroup 已是目标形态,返回 ok 或 `CHAT_NOT_MODIFIED` 语义。 +- `messages.migrateChat`:不创建 legacy chat,不执行双写迁移;校验 change_info/creator 权限后把 `chat_id` 映射到既有 megagroup `channel_id`,返回 `updateChannel + tg.Channel`,满足 TDesktop `applyUpdates -> migrateTo()` 路径。 +- `channels.editPhoto`:files/media 头像存储未接入前只允许 `inputChatPhotoEmpty` 删除头像 no-op 兼容;真实上传/已有照片返回 `PHOTO_INVALID`,避免 TDesktop 误以为头像已成功更新。 +- `channels.togglePreHistoryHidden/toggleSlowMode/toggleForum/toggleAntiSpam/updateColor/updateEmojiStatus`:持久化 channel 设置并返回 `updateChannel`;TDesktop 通过 `ChannelFull.hidden_prehistory/slowmode_seconds/antispam`、`Channel.slowmode_enabled`、`Channel.forum/forum_tabs`、`Channel.color/profile_color/emoji_status` 恢复 UI。emoji status 当前支持 empty 与普通 document id,collectible gift 状态等 gift/read model 后续补;forum 当前保存开关/layout 与 admin log,`messages.getForumTopics*` 会在 forum 开启后返回虚拟 `General` topic(id=1) 加持久化 topic store;`messages.create/edit/pin/reorder/deleteTopicHistory` 已接入最小真实 topic mutation;anti-spam 当前只保存开关与 admin log,不接入真实垃圾消息删除管线。 +- `channels.setStickers`:files/sticker store 未接入前只允许 megagroup `inputStickerSetEmpty` 清空 no-op 兼容;非空 sticker set 返回 `STICKERSET_INVALID`,避免 TDesktop 误以为群贴纸集已持久化。`channels.reorderUsernames/toggleUsername/deactivateAllUsernames` 仍先做权限校验型兼容响应;Fragment/多 username 入口不改主 username,主 username 只允许 `channels.updateUsername` 设置或清除。 +- TDesktop 已有调用证据但非首批真实业务的入口统一注册为显式 stub:`channels.reportSpam/editLocation/convertToGigagroup/reportAntiSpamFalsePositive/setBoostsToUnblockRestrictions/setEmojiStickers/checkSearchPostsFlood/setMainProfileTab`。`channels.searchPosts` 已提升为公开频道/超级群帖子真实搜索,`checkSearchPostsFlood` 仍保持免费额度兼容 stub;二者共享 query 长度边界。`setBoostsToUnblockRestrictions` 按 Layer225 限制 0..8。`channels.restrictSponsoredMessages/updatePaidMessagesPrice/toggleAutotranslation` 已提升为最小真实设置持久化:分别回填 `ChannelFull.restricted_sponsored`、`Channel/ChannelFull.send_paid_messages_stars` + broadcast `Channel.broadcast_messages_allowed`、`Channel.autotranslation`,其中 `updatePaidMessagesPrice` 按 TDesktop 默认 app config 限制 stars<=10000,并允许 broadcast direct messages 用 `-1` 表示关闭;真实广告投放、boost/premium 校验、paid messages/monoforum/结算后续补。`channels.exportMessageLink`、`channels.readMessageContents`、`channels.getMessageAuthor`、`channels.deleteParticipantHistory`、`getInactiveChannels`、`getChannelRecommendations`、`toggleJoinToSend`、`toggleJoinRequest`、`toggleParticipantsHidden`、`toggleForum`、`toggleViewForumAsMessages`、`toggleAntiSpam`、`getLeftChannels`、`getGroupsForDiscussion`、`setDiscussionGroup` 已从该列表提升为真实实现。 +- `channels.getGroupsForDiscussion/setDiscussionGroup` 真实维护 broadcast channel 与 megagroup 的双向 `linked_chat_id`。候选列表只返回当前用户可管理的 supergroup,不返回 legacy basic group;设置时校验 access_hash、broadcast/group 类型、管理权限与 hidden prehistory,替换链接会同步清理旧 group/old broadcast,TDesktop 通过 `Channel.has_link` 和 `ChannelFull.linked_chat_id` 刷新讨论组入口。linked broadcast 发新 post 时会在 discussion megagroup 创建一条 forwarded root message,source post 保存 `discussion_channel_id/message_id`,`messages.getDiscussionMessage/getReplies/readDiscussion` 都映射到该 root 和 target channel read state。 +- TDesktop 已有源码调用但当前业务模型尚未维护独立 media/sticker/custom-emoji/saved-message tag assignment/poll/todo/scheduled 的 `messages.reportSpam/report/reportReaction/reportMessagesDelivery/reportReadMetrics/reportMusicListen/reportSponsoredMessage/getSavedReactionTags/updateSavedReactionTag/getDefaultTagReactions/getExtendedMedia/getAttachedStickers/getCustomEmojiDocuments/searchStickerSets/searchStickers/getEmojiKeywords/getEmojiKeywordsDifference/sendVote/getPollResults/getPollVotes/addPollAnswer/deletePollAnswer/getUnreadPollVotes/readPollVotes/appendTodoList/toggleTodoCompleted/getSearchCounters/getSearchResultsCalendar/getSearchResultsPositions/getOnlines/getWebPagePreview/uploadMedia/sendMedia/sendMultiMedia/getScheduledHistory/getScheduledMessages/sendScheduledMessages/deleteScheduledMessages` 均显式注册;统一做参数上限、peer 校验和空/零兼容响应,禁止落到未知 RPC。其中 `messages.readMessageContents` 已提升为 private exact-id real-partial:只校验当前账号可见 message box 并向其它 session 推 content-read update,不维护 media/reaction content-read 状态;channel/supergroup 的 `channels.readMessageContents` 会按可见消息 ID 清理当前作者视角的 unread reaction、重算 `channel_dialogs.unread_reactions_count`,并向当前账号 session 推 `updateMessageReactions` 让 TDesktop 立即去掉 dialog reaction 角标;`messages.getMessagesViews` 已维护 channel-scoped views 去重计数并返回 replies/comment 统计;`messages.getUnreadMentions/readMentions` 已维护 channel-scoped unread mention index,readMentions 返回 channel pts;`messages.sendReaction/getMessagesReactions/getMessageReactionsList/getUnreadReactions/readReactions/getRecentReactions/clearRecentReactions/getTopReactions` 已提升为 private + channel/supergroup emoji reaction 最小真实实现:private 写 `private_message_reactions` 并按双端 owner-visible message box 返回/推送 `updateMessageReactions`,channel 写 `channel_message_reactions` 并按消息作者维护 unread reaction 状态,history/getMessages/search 均回填 `message.reactions`,`add_to_recent` 写账号级最近 channel reaction 列表且 get/clear 支持 hash/notModified,`getTopReactions` 按账号使用次数排序并优先用真实 `available_reactions` catalog 兜底;`messages.getSavedReactionTags/updateSavedReactionTag` 已持久化账号级 emoji saved reaction tag 标题并向其它 session 推 `updateSavedReactionTags`,但 peer 维度 count 与 saved-message tag assignment 仍返回空;`messages.getForumTopics/getForumTopicsByID/createForumTopic/editForumTopic/updatePinnedForumTopic/reorderPinnedForumTopics/deleteTopicHistory` 已从纯空响应提升为真实最小 topic store:虚拟 General + root service message topic + bounded delete;`messages.getCommonChats` 已提升为真实共同超级群查询并回填 `users.getFullUser.common_chats_count`;`messages.report` 保持 TDesktop 分步举报 UI 所需的 choose option/add comment/reported 形态但暂不落库;report/metrics/music/sponsored 这类 telemetry 入口只返回 BoolTrue/reported,不写业务状态;TDesktop 资料页 shared media count 会用 `messages.search(limit=0, filter=photo/video/document/url/gif/music/roundVoice/poll)` 取 `messages.channelMessages.count`,当前显式返回空页/count=0,避免纯文本 channel history 污染 photos/videos/files 等计数;search calendar 空结果会回填请求 offset date/id,避免空月份重复拉取;default tag、sticker/custom emoji 与 extended media 均只返回空/notModified 或明确错误,不伪造 paid media 或 tag 绑定状态;poll/todo mutating 入口在缺少媒体 store 时返回可解释错误,不伪造 `updateMessagePoll` 或 todo service message;web preview 返回 `messageMediaEmpty` 且不会抓外网,`sendMedia(inputMediaWebPage)` 降级为纯文本发送,真实 photo/document/poll/album、todo、scheduled store 留待后续模型。 +- legacy chat 管理入口 `messages.getChats/getFullChat/addChatUser/deleteChatUser/editChatTitle/editChatPhoto/editChatAdmin/editChatAbout/editChatDefaultBannedRights/editChatParticipantRank` 统一映射到 megagroup/channel 语义;其中 `about` 与 default banned rights 真实持久化,default banned rights 参与普通成员发送和邀请权限校验。`editChatCreator` 已显式注册并校验 peer/user,但账号 2FA/SRP 与所有权转移事务未接入前返回可解释的密码错误,不进入 fallback。 + +## Reference Audit + +### TDesktop + +TDesktop 创建群入口仍会调用 `MTPmessages_CreateChat`,创建频道/超级群调用 `MTPchannels_CreateChannel` 并根据 UI 类型设置 `f_megagroup` 或 `f_broadcast`。`messages.createChat` 的 `ChatCreateDone` 会从 `messages.InvitedUsers.updates.chats` 取第一条 `chat`,不接受 `channel`;因此 TDesktop ctx 下同步响应必须把 `legacy chat(migrated_to=inputChannel)` 放在第一项,同时继续附带真实 `channel.id/access_hash/title/broadcast/megagroup/participants_count/date` 供 `updateNewChannelMessage` 和迁移后的 supergroup 历史使用。服务端不持久化 legacy chat,后续 `InputPeerChat` 主路径统一映射回同 id channel。 + +频道更新走 `ChannelData` 的独立 `PtsWaiter`。`updateNewChannelMessage` 若找不到频道资料,会延迟触发 `getDifference`;找到频道且不在处理 channel difference 时,会调用 `channel->ptsUpdateAndApply(pts, pts_count, update)`。`PtsWaiter` 依赖 `pts_count` 累加判断连续性,缺口会缓存乱序 update,1 秒后拉 `updates.getChannelDifference`。因此服务端必须保证 channel pts 单调、`pts_count` 准确、每个已分配 pts 都能从 channel durable log 补到。 + +管理入口会直接触发 `MTPchannels_EditAdmin`、`MTPchannels_EditBanned`、`MTPchannels_EditTitle`、`MTPchannels_DeleteChannel`;pin 使用 `MTPmessages_UpdatePinnedMessage`,邀请链接使用 `MTPmessages_ExportChatInvite/CheckChatInvite/ImportChatInvite`。TDesktop 对这些 RPC 返回的 `Updates` 会立即 apply,因此响应必须至少带 `updateChannel`、成员变更时带 `updateChannelParticipant`,pin 时带 `updatePinnedChannelMessages`,并把相关 `chats/users` 填齐。 + +TDesktop 源码还会在频道/群 UI、导出和搜索路径触发 `MTPmessages_GetMessagesViews`、`ReadMessageContents`、`GetUnreadMentions/ReadMentions`、`GetSearchCounters`、`GetReplies`、`GetForumTopics/GetForumTopicsByID` 和 legacy basic group wrappers。参考实现 对 `messages.getOnlines` 返回固定 1,参考实现 handler 仍未实现;telesrv 比 参考实现 多走一步:使用在线 session 快照与 active channel member 有界交集返回实时在线数,缺少在线 provider 时才退回兼容 1。basic group 管理入口再下沉到 megagroup/channel 语义;参考实现 对 default banned rights 要求 `ban_users` 权限并发布频道设置命令。telesrv 借鉴这个边界:views/mentions 已有真实最小实现,forum topic create/read/edit/pin/delete/reorder 已有持久化最小实现,复杂媒体仍返回显式 stub,legacy basic group 管理统一落到 megagroup/channel 语义,default banned rights 真实落库并影响普通成员 send/invite。 + +管理员日志页调用 `MTPchannels_GetAdminLog(channel, q, events_filter, admins, max_id, min_id, limit)`,TDesktop 既会用 `max_id` 向旧事件翻页,也会用 `min_id`/polling 拉新事件;响应必须是 `channels.adminLogResults{events,chats,users}`,并让 action 里的 message/participant 能被客户端渲染。服务端必须 cap `limit/admins/query`,按 `(channel_id,id)` seek,不能按客户端传入的大 id 构造数组。 + +已读详情浮层在 outgoing 且本地已读、成员数小于 `chat_read_mark_size_threshold`、消息未超过 `chat_read_mark_expire_period` 时调用 `MTPmessages_GetMessageReadParticipants(peer,msg_id)`。TDesktop 只接收 `Vector`,不附带 users/chats;因此服务端必须只返回当前客户端已经能解析的 user id,并保证接口失败不会变成持续 `NOT_IMPLEMENTED` 噪声。 + +reply/forward 渲染依赖 `MessageReplyHeader` 与 `MessageFwdHeader` 内引用的 peer 已可解析。TDesktop 的 `ReplyFieldsFromMTP` 会优先用 `reply_to_top_id`,没有时退回 `reply_to_msg_id`;`api_updates.cpp` 的 `ForwardedInfoDataIsLoaded/ReplyDataIsLoaded` 会在 forward/reply header 引用的 peer 未加载时暂缓 apply update。因此服务端发送 channel reply 时必须用已存在、当前成员可见的 channel message 反算 `reply_to_top_id`,不能信任客户端传入;forward/reply header 如果引用 user/channel peer,当前 RPC 响应、账号级 `updates.getDifference`、在线 outbox 和 `updates.getChannelDifference` 都要补齐对应 users/chats 上下文,不能只修实时响应路径。 + +会话内搜索也走 channel peer:TDesktop 的 `api_messages_search.cpp` 与 `dialogs_widget.cpp` 在群/频道里直接发 `messages.search(peer=inputPeerChannel, q, offset_id, limit)`,并把 `offset_id` 当 seek cursor 继续翻页。参考实现 对 channel peer 把 `OwnerPeerId` 切到 channel id,从单份 message read model 查询,并补齐 sender/forward/reply 相关 users/channels。telesrv 因此不把 channel 搜索转成私聊 `message_boxes`,而是在 `channel_messages(channel_id,id)` 上做有界文本搜索;PG 使用 `channel_messages_body_trgm_idx` 辅助 `ILIKE` 命中,仍按 `id DESC LIMIT` 分页。 + +公开 username 管理入口会调用 `MTPchannels_CheckUsername`、`MTPchannels_UpdateUsername` 与 `MTPchannels_GetAdminedPublicChannels`;settings/profile 管理区还会触发 signatures、prehistory、slowmode、stickers、color、emoji status、Fragment usernames 等 RPC。主 username 是客户端可见资料,必须真实落库;Fragment/多 username、颜色、贴纸、emoji status 可先权限校验型 stub,但不能误修改主 username 或静默吞掉未知 RPC。 + +TDesktop 左侧搜索框通过 `MTPcontacts_Search` 查 peer,返回的 channel/supergroup 命中必须同时出现在 `results/my_results` 的 `peerChannel` 和 `chats` 向量中,否则 UI 无法 materialize peer;用户名跳转通过 `MTPcontacts_ResolveUsername`,公开 channel/supergroup 同样返回 `peerChannel + chats`。telesrv 只暴露带主 username 且未删除的公开频道/超级群,PG 使用 `channels_public_username_trgm_idx` / `channels_public_title_trgm_idx` 避免无索引模糊扫。非成员从公开搜索结果打开频道时,`channels.getFullChannel`、`messages.getPeerDialogs(InputPeerChannel)` 与 `messages.getHistory(InputPeerChannel)` 使用只读公开预览视图返回 full/dialog/history;私有频道、ban/kick/view_messages 禁止仍拒绝,发送、差分、管理等写路径仍要求 active member。 + +源码检索还显示导出/管理/搜索路径会触发 `MTPchannels_ExportMessageLink`、`ReadMessageContents`、`DeleteParticipantHistory`、`GetGroupsForDiscussion`、`SetDiscussionGroup`、`ToggleForum`、`ToggleJoin*`、`SearchPosts`、`CheckSearchPostsFlood`、`GetChannelRecommendations`、`GetMessageAuthor` 等 Layer 225 方法。`ToggleForum` 保存 `enabled/tabs`,仅 creator 可改,返回 `updateChannel`;`GetChannelRecommendations` 先按公开 username broadcast channel 做最小真实推荐,真实相似度/订阅画像/Premium 扩容后续补;其它未接入完整业务的入口必须显式注册、加参数上限、返回可解释 stub,并写入 compatibility matrix。 + +讨论组设置入口在 `edit_peer_info_box.cpp` 中:broadcast 侧无当前链接时会调用 `channels.getGroupsForDiscussion` 拉候选;保存时若选中的 megagroup 开启了 hidden prehistory,TDesktop 会先调用 `channels.togglePreHistoryHidden(group,false)` 再重试 `channels.setDiscussionGroup`。保存成功后客户端本地调用 `ChannelData::setDiscussionLink`,同时 `ChannelFull.linked_chat_id` 会在后续 full channel 刷新时恢复状态。因此 server 必须返回候选 chats,设置/解绑成功后更新双方 `linked_chat_id` 并推 `updateChannel`,不能只返回 BoolTrue。 + +TDesktop 导出路径的 `channels.getLeftChannels` 使用 count-offset 分页:客户端每次把 offset 增加已返回 chats 数量,`messages.chats` 立即结束,`messages.chatsSlice` 只有空 chats 才结束。telesrv 因此按 `channel_members(user_id,status='left')` 查询用户已离开的频道/超级群,pageSize 固定 100,offset 上限 10000;最终非空页返回 `messages.chats`,offset 已越过总数时返回空 `messages.chatsSlice{count}`,避免导出流程多拉或循环。 + +输入框与高级会话路径还会触发 `MTPmessages_GetWebPagePreview`、`UploadMedia`、`SendMedia`、`SendMultiMedia`,定时消息入口会触发 `GetScheduledMessages/SendScheduledMessages/DeleteScheduledMessages`,forum UI 会触发 topic create/edit/pin/reorder/delete。参考实现 对 web preview 空文本报错、无预览返回 `messageMediaEmpty`,media 和 scheduled 都有独立持久化;参考实现 的 web preview 是空响应,media 走 helper,scheduled/forum 多数仍未实现。telesrv 当前不伪造这些缺失模型:web preview 返回空,webpage media 可降级纯文本;真实 media/scheduled/forum 只做有界校验与可解释错误或 cleanup update。 + +投票和 todo 是 channel/supergroup 消息媒体的后续模型,不应在缺少 store 时假成功。TDesktop 的 `api_polls.cpp` 会在 `sendVote/addPollAnswer/deletePollAnswer/getPollResults` 成功后 apply updates,投票人列表通过 `messages.getPollVotes` 的 `next_offset` 翻页;`api_unread_things.cpp` 和 `menu_send.cpp` 还会拉取/清除未读投票,todo 则在 `api_todo_lists.cpp` 中 append/toggle 后 apply updates。参考实现 只实现旧层 poll 三件套,先按 peer+msg_id 取消息并提取 poll_id,`getPollVotes` 把 limit 压到 50;参考实现 对 poll 已有 domain event 与 sender/self update 分离,todo append 依赖 `MessageMediaToDo`,toggle/read-unread/add/delete 多数仍未实现。telesrv 当前做显式有界兼容:read-only poll 入口返回空,投票/新增/删除答案和 todo 变更返回 `MESSAGE_ID_INVALID`,并保留 `OPTIONS_TOO_MUCH/OPTION_INVALID/TODO_NOT_MODIFIED/BROADCAST_FORBIDDEN` 等客户端可理解错误,等 media store 接入后再生成真实 `updateMessagePoll`、todo service message 与 channel pts。 + +资料页、贴纸、custom emoji 与 reaction tag 也会在群/频道页面后台触发,不应落到 fallback。TDesktop `getCommonChats` 用 `max_id/limit` 分页共同群,且资料页按钮依赖 `UserFull.common_chats_count`;参考实现 都只把共同 channel 中的 megagroup 作为共同群。telesrv 只查双方 active membership 的 megagroup/supergroup,排除 broadcast/left/kicked/deleted,PG 走 `user_channel_member_index(user_id, channel_id) WHERE active megagroup` 交集和 channel id seek 分页,避免 `users.getFullUser` 私聊打开路径反向规划 `channel_members` 的 64 个 channel 分区。`getExtendedMedia` 成功后只 apply updates,paid media 未接入时空 updates 是安全结果;attached stickers、custom emoji documents、sticker search、emoji keyword difference 都接受空 vector/空 found results;reaction top/recent/saved tag titles 用 hash 缓存,rename tag 会本地先改名再调用 `updateSavedReactionTag`。sticker/custom emoji 不查外部索引,saved-message tag assignment/count/per-peer 暂不持久化,避免为了兼容 UI 预取而引入无界查询或半截状态。 + +`updates.getChannelDifference` 有三种客户端路径: + +- 无新增:`updates.channelDifferenceEmpty{final=true, pts, timeout}`。 +- 正常补差:`updates.channelDifference{final, pts, new_messages, other_updates, chats, users}`。 +- 差量过长:`updates.channelDifferenceTooLong{dialog, messages, chats, users}`,客户端会用返回 dialog 的 `pts` 重置 channel 本地状态,并拉历史范围校验。 + +### gotd / TL Layer 225 + +gotd 已提供所有需要的 Layer 225 类型和 dispatcher: + +- `MessagesCreateChatRequest` 返回 `messages.InvitedUsers`。 +- `ChannelsCreateChannelRequest` 返回 `UpdatesClass`,包含 `Broadcast/Megagroup/Forum/TTLPeriod`。 +- `UpdatesGetChannelDifferenceRequest` 返回 `UpdatesChannelDifferenceClass`,请求 limit 对普通用户建议 10-100,服务端必须 cap。 +- `UpdateNewChannelMessage` 携带 `message/pts/pts_count`。 +- `UpdateEditChannelMessage`、`UpdateDeleteChannelMessages`、`UpdatePinnedChannelMessages` 都走 channel pts;delete 的 `pts_count` 必须等于本次删除 id 数,pin/edit 为 1。 +- `UpdateChannelParticipant` 携带 actor、prev/new participant,适合 editAdmin/editBanned 的在线更新。 +- `ChatInviteExported`、`ChatInvite`、`ChatInviteAlready` 覆盖邀请链接导出、预览和已加入状态。 +- `Channel` 和 `ChannelFull` 有 TDesktop 最小必需字段:`AccessHash`、`Broadcast`、`Megagroup`、`HasLink`、`ParticipantsCount`、`AdminRights`、`BannedRights`、`DefaultBannedRights`、`LinkedChatID`、`ReadInboxMaxID`、`ReadOutboxMaxID`、`UnreadCount`、`NotifySettings`、`ExportedInvite`、`Pts`。 +- `channels.getGroupsForDiscussion#f5dad378` 无入参,返回 `messages.Chats`;`channels.setDiscussionGroup#40582bb2` 接 `broadcast:InputChannel group:InputChannel`,返回 Bool,并显式定义 `LINK_NOT_MODIFIED/BROADCAST_ID_INVALID/MEGAGROUP_ID_INVALID/MEGAGROUP_PREHISTORY_HIDDEN` 等错误。`channelAdminLogEventActionChangeLinkedChat` 可记录管理日志。 +- `messages.getReplies#22ddd30c` / `messages.getDiscussionMessage#446972fd` / `messages.readDiscussion#f731a9f4` 使用 channel peer + message id 定位 thread;`messageReplies` 的 `comments/channel_id/replies_pts/max_id/read_max_id` 是 TDesktop 展示 comment button、recent replies 与 unread state 的直接输入。 + +实现时 `tg.*` 仍只能出现在 `internal/rpc`,domain/app/store 使用自有模型。 + +### 参考实现 A + +参考实现 有一个关键语义可借鉴:`MessageSubType.AutoCreateChannelFromChat`。普通群创建最终进入 channel 创建 saga,先创建 channel creator/member/invite,再发送 `messageActionChannelCreate` 服务消息。它在 `SendMessageSaga` 中按 owner peer 分配 message id 与 pts;当 `ToPeer == Channel` 时只创建 channel owner 的 outbox message,不为每个成员创建 inbox message,随后 `SetChannelPts` 更新 channel read model 的 `Pts/TopMessageId/LastSenderPeerId/LastSendDate`。 + +成员加入/邀请/退出会创建或更新成员 dialog,并且仅 megagroup 生成 `ChatAddUser/ChatDeleteUser/Join` 等服务消息;broadcast channel 不向普通成员写这类群服务消息。它还把 channel update 保存和在线推送分开:channel durable update 供 `getChannelDifference`,在线推送只给活跃成员/管理员/被 mention 用户。 + +ack/globalSeqNo 的设计也值得保留为边界:`msgs_ack` 只确认某个 server msg_id/RPC response 已送达,ack cache 再把它映射回 pts/globalSeqNo 更新设备已确认水位。业务 pts 仍由 owner/channel 事件流决定,不能让 MTProto ack 直接产生业务事件。 + +管理语义方面,参考实现 的 editAdmin/editBanned 都先做权限校验,再构造 participant update;title 变更会产生可见服务消息;pin 走 `messages.updatePinnedMessage` 并只推进 channel pts;invite link 支持导出、检查和导入,join-approval/request_needed 是单独路径,不能误当作已经入群。TDesktop 对 `messages.importChatInvite` 明确分支处理 `INVITE_REQUEST_SENT` 与 `USERS_TOO_MUCH`,因此 request-needed 的首版兼容 stub 必须返回 `INVITE_REQUEST_SENT`,usage limit 满必须返回 `USERS_TOO_MUCH`,不能退化成坏链接/不可访问错误。 + +username 与管理项方面,参考实现 的 `channels.checkUsername` 只校验格式、access_hash 和全局 username 占用;`channels.updateUsername` 要求 channel owner,大小写不敏感相同值返回 `USERNAME_NOT_MODIFIED`;`getAdminedPublicChannels` 返回当前用户管理的公开 channel 列表。`toggleSignatures/togglePreHistoryHidden/toggleSlowMode/updateColor` 都先校验权限后发布命令;`updateEmojiStatus` handler 仍是空 updates,但 read model/converter 已有 `EmojiStatus` 字段。telesrv 采用这个边界:主 username/signatures/prehistory/slowmode/color/profile_color/普通 emoji status 真实持久化,Fragment usernames、贴纸、collectible emoji status 等依赖额外模型的入口继续显式 stub 或错误。 + +参考实现 的 `messages.toggleNoForwards` 已实现 channel 路径:先校验 access hash,再发布 `ToggleChannelNoForwardsCommand`,通过 channel 聚合返回 updates;`messages.setChatAvailableReactions` 和 `messages.setChatTheme` handler 仍未实现,但 `ChannelFullReadModel/ChannelFullMapper` 已有 `ReactionType/AvailableReactions/ReactionsLimit` 到 `chatReactions*` 的映射。telesrv 因此不把 reactions 当纯兼容噪声,而是持久化到 channel setting,并在 full channel 中恢复给 TDesktop。 + +参考实现 对 TDesktop 长尾入口多采用空/BoolTrue 响应:`readMessageContents/reportSpam/setEmojiStickers/getInactiveChannels/getChannelRecommendations/checkSearchPostsFlood/setMainProfileTab` 等直接返回兼容值;`channels.getMessageAuthor` handler 存在但未实现。参考实现 的 `messages.readMessageContents` / `channels.readMessageContents` 会先查当前用户可见消息、过滤 mention/media_unread/reaction 内容状态,再向 not-me 推 `updateReadMessagesContents` 或 `updateChannelReadMessagesContents`;未找到 `channels.getMessageAuthor` 对应实现。telesrv 当前没有 media/reaction content-read 持久表,因此 private 与 channel 都只用现有 exact-id message store 校验可见消息,并把存在的 id 推给当前用户其它在线 session;缺失 id 仍按参考项目返回当前 pts/BoolTrue。`getMessageAuthor` 则按 Layer225/TDesktop monoforum 右键菜单的最小可用语义,只查可见 channel message 的 `SenderUserID` 并返回 user,完整 monoforum 管理员权限留后续模型。`deleteParticipantHistory` 做管理员权限后按固定 page size 分批删除;`searchPosts` 按 参考实现 的 PublicPosts 语义落到本项目公开 username channel_messages 查询,只返回公开频道/超级群文本消息,满页用 `next_rate + offset_peer + offset_id` seek 翻页,付费 flood/限额继续由 `checkSearchPostsFlood` 免费 stub 承接;`getInactiveChannels` 按 TDesktop Premium 限额弹窗消费方式返回当前用户 active 频道/超级群,`dates[i]` 与 `chats[i]` 对齐并按最久未活跃排序;`getChannelRecommendations` 则从空响应提升为公开 broadcast channel 推荐,指定来源时排除 source,无来源时排除当前账号已加入频道;`getGroupsForDiscussion/setDiscussionGroup` 是讨论组链路。telesrv 借鉴其删除边界:按 sender 分页取一批 message id,生成一条 channel delete update,通过 `offset` 提示客户端续删,禁止一次性展开超大历史。 + +参考实现 的 `channels.getAdminLog` 当前返回空结果,但保留了 read model/query 模型:按 `channel_id`、action types、skip/limit 拉取 admin log event。telesrv 采用“有真实 event store、但只实现 TDesktop 首批可见 action”的路线,避免管理入口只能打开空页。 + +参考实现 的 `messages.getMessageReadParticipants` 先按 owner peer 校验 message 存在和 7 天过期窗口,再查询 `ReadingHistoryReadModel(TargetPeerId, MessageId >= msg_id, ReaderPeerId != self)` 并返回 `TReadParticipantDate{user_id,date}`。telesrv 采用同一语义边界,但落到 channel 单份消息模型:成员 `read_inbox_max_id >= msg_id`、`available_min_id < msg_id` 且有显式 `read_inbox_date` 才算已读者。 + +参考实现 的 `messages.getMessageEditData` 先校验 access_hash/message,再按编辑时间窗口返回 caption 可编辑标记;参考实现 对私聊/旧群先校验作者或管理员编辑权限,再返回 `messages.messageEditData{caption=false}`,channel 分支在旧项目被商业版挡住。telesrv 当前没有媒体 caption 编辑,因此采用更窄且可验证的语义:对私聊和 channel peer 都先校验消息存在、可见和作者/`edit_messages` 权限,再返回 `caption=false`,避免 TDesktop 编辑入口触发未知 RPC 或绕过权限。 + +参考实现 的 `channels.readHistory` 会读取 `max_id` 对应消息的 sender,触发 `UpdateReadChannelOutbox` saga,并向 sender 推送 `TUpdateReadChannelOutbox{channel_id,max_id}`。`channels.getFullChannel` 还会把 dialog read model 的 `ReadInboxMaxId/ReadOutboxMaxId` 写回 `ChannelFull`,dialog mapper 也持久返回 `ReadOutboxMaxId`。TDesktop 收到 `updateReadChannelOutbox` 后只调用 `History::outboxRead(max_id)`;如果在线 update 丢失或设备离线,TDesktop 会在 `messages.getDialogs/messages.getPeerDialogs` 的 dialog 字段、`channels.getFullChannel` 的 channel full 字段中重新应用 `read_outbox_max_id`。telesrv 采用同一客户端语义,但在 store 内 bounded 扫描最近 read delta,可一次推进多个相关发送者,同时保留 fanout/scan 硬上限。 + +参考实现 的 `EditPeerFoldersSaga` 明确把 `TInputPeerChannel` 转成 `PeerType.Channel` 并发布 `UpdateDialogFolderCommand`,最终回 `updateFolderPeers`;dialog unread/pinned/read-outbox 都在同一个 dialog read model 上表达。TDesktop 侧 `updateDialogPinned/updatePinnedDialogs/updateDialogUnreadMark/updateFolderPeers` 也按 `DialogPeer/Peer` 泛化消费,因此 telesrv 不能只更新私聊 `dialogs` 表,channel peer 必须更新 `channel_dialogs` 并仍走账号级 user pts/update。 + +参考实现 的 send/forward 请求转换器保留 Layer225 `InputReplyTo`,message mapper 再把业务 `ReplyTo/InputReplyTo` 转回 `MessageReplyHeader`;它还用 `MessageForwardedEvent/MessageReplyUpdatedEvent` 维护原消息回复统计。参考实现 的 message/dialog app service 会把 `FwdHeader.FromId/SavedFromPeer/SavedFromId` 放进额外 peer 集合,确保客户端 apply update 前能解析 forward 来源。telesrv 保留 `reply_to_msg_id/top_id/quote` 与 forward header 的客户端可见语义,在响应、durable difference、outbox 投递里补齐可解析的 user/channel peer 上下文,并用 `reply_to_top_id` + linked discussion root 维护首版 channel replies 统计/已读。 + +参考实现 的 admin/ban 流程先校验 `add_admins/ban_users`,再通过 saga 改成员 read model 并把 `updateChannelParticipant` 放入可恢复的 updates 流;TDesktop 对 `updateChannelParticipant` 本身不走 channel pts 检查,但会在 `updateChannelTooLong(pts)` 后主动拉 `updates.getChannelDifference`。telesrv 因此把 editAdmin/editBanned 写入 `channel_update_events(channel_participant)`,payload 保存 prev/new participant,在线响应额外携带 `updateChannelTooLong(pts)` 触发客户端补齐 channel pts。 + +### 参考实现 B + +参考实现 的 channel 模型包含 `broadcast`、`megagroup`、`top_message`、`pts`、`participants_count`、`default_banned_rights`、`hidden_prehistory`、`slowmode`、`forum`、`noforwards` 等业务字段。它用 Redis key `channel_pts` 缓存 channel pts,并用 `channel_pts_updates` 持久化 `channel_id/pts/pts_count/update_type/update_data/date`,`updates.getChannelDifferenceV2` 按 `channel_id AND pts > ? ORDER BY pts ASC` 返回差量。 + +参考实现 对 `messages.toggleNoForwards` 先把 peer 限定为 chat/channel,再在 chat service 校验创建者权限,更新 `noforwards` 与版本后返回 chat update;`chat.setChatAvailableReactions` 会校验成员/管理员权限,把 reaction type 与 reaction list 持久化。telesrv 借鉴“设置是 channel state,不是消息事件”的边界,但权限放宽到 change_info 管理员,与当前 channel 管理设置统一。 + +参考实现 的不足需要避免:部分差量恢复时靠反解 message 的 `from_id` 临时判断 `out`,代码里有 sender TODO。telesrv 的 `channel_update_events` 必须显式保存 `sender_user_id`、`affected_user_ids`、`message_ids` 等负载,不能把客户端展示所需信息藏在 TL JSON 里。 + +参考实现 的 admin log 通过 `channel_admin_logs` 保存 actor、event、JSON action、query、date,并在 BFF 先校验 creator/admin 后调用 service。它的不足是只按最近 24h/channel 扫描且过滤 TODO;telesrv 改为 channel hash 分区、`id` 单调分页、actor/type 索引和 request cap,保留语义但不继承实现。 + +参考实现 的 `messages.getMessageReadParticipants` 在 channel 版本中通过 dialog service 拉取已读成员 id,旧 chat 版本则按参与者 dialog 的 `read_inbox_max_id` 判断是否覆盖目标消息;日期字段仍返回 0。telesrv 保留“按成员读水位判断”的核心语义,并新增 `read_inbox_date`,避免 UI 已读详情只能显示无时间。 + +参考实现 的 `messages.getMessageEditData` 私聊路径会按 owner message id 取 message box,并拒绝非作者;旧群允许具备编辑权限的管理员,最终仍返回 `caption=false`。telesrv 的 channel/supergroup 路径沿用这个权限边界,但查询单份 `channel_messages` 与当前 viewer 的 member 状态,不落 legacy chat。 + +参考实现 频道 participant/dialog 模型都保存 `read_outbox_max_id`,BFF `channels.getFullChannel` 会用当前 dialog 的 `ReadOutboxMaxId` 覆盖 `ChannelFull.ReadOutboxMaxId`,另有 `message_read_outbox` 记录读回执排查维度。telesrv 当前先把发送者 `channel_members/channel_dialogs.read_outbox_max_id` 与在线 `updateReadChannelOutbox`、离线 dialog/full channel 恢复路径打通;单条读者时间仍由 `read_inbox_date` 支撑。 + +参考实现 的 channel outbox 在写入 reply 消息前会按 `channel_id + reply_to_msg_id` 读取被回复消息:如果目标消息已有 `reply_to_top_id` 就继承,否则把当前 `reply_to_msg_id` 作为 top,最终把 `ReplyTo/ReplyToTopId` 一起落库;forum topic 发送后会更新 topic 的 top message。TDesktop 在 topic 输入框里会发送 `reply_to_msg_id=0 + top_msg_id=topicRootId`,参考实现 的 `InputReplyToMessage.TopMsgId` 也按 topic/thread 维度保存并在 header 上打 `forum_topic`。telesrv 借鉴该语义,但改成事务内校验目标或 topic root 未删除且对当前成员可见,非法目标返回 `REPLY_MESSAGE_ID_INVALID`,topic 内普通消息返回 `messageReplyHeader{forum_topic, reply_to_top_id}` 并更新 topic top message。 + +参考实现 的 `updates.getChannelDifference` 还会在当前 participant 的 `AvailableMinPts > req.pts` 时把请求 pts 抬到 `AvailableMinPts`,再去读 `channel_pts_updates`。这是避免新成员用 `pts=0` 拉到入群前 participant/admin/settings 事件的关键边界;telesrv 因此在 `channel_members` 中同时保存 `available_min_pts`,加入/导入/受邀/重新加入时设为加入前 `channels.pts`,消息历史可见性仍由 `available_min_id` 独立控制。 + +参考实现 的 `channels.deleteHistory` 本地清空路径不写 channel pts,而是返回并同步 `updateChannelAvailableMessages{channel_id, available_min_id}`;TDesktop 在 `api_updates.cpp` 收到后设置 channel `available_min_id` 并对已加载 history 执行 `clearUpTill`,`ChannelData::setAvailableMinId` 本身不会做 max-clamp。telesrv 采用同一客户端语义:本地清空只更新当前账号成员/dialog 水位,同时写账号级 durable update 供其它设备在线推送或 `updates.getDifference` 离线恢复;返回和推送的 `available_min_id` 必须是实际应用后的单调水位 `max(old_available_min_id, requested_max_id)`,避免多设备乱序或 stale 请求把 TDesktop 本地可见下界回退。 + +## Domain Model + +新增 domain model: + +- `Channel`:`ID`、`AccessHash`、`Title`、`About`、`Username`、`CreatorUserID`、`Broadcast`、`Megagroup`、`Forum`、`ForumTabs`、`Date`、`ParticipantsCount`、`AdminsCount`、`KickedCount`、`BannedCount`、`DefaultBannedRights`、`TopMessageID`、`PinnedMessageID`、`Pts`、`Deleted`、`NoForwards`、`TTLPeriod`。 +- `Channel` 还保存设置字段:`PreHistoryHidden`、`ParticipantsHidden`、`AntiSpam`、`SlowmodeSeconds`、`Signatures`、`ReactionPolicy`、`Color`、`ProfileColor`、`EmojiStatus`。`ParticipantsHidden` 会让 `ChannelFull.participants_hidden` 可见,并让非管理员成员列表/已读详情按隐藏成员语义收敛;`AntiSpam` 会让 `ChannelFull.antispam` 可见,供 TDesktop 管理员页恢复开关;`SlowmodeSeconds>0` 会让 `tg.Channel.slowmode_enabled` 和 `tg.ChannelFull.slowmode_seconds` 可见;`Color/ProfileColor/EmojiStatus` 转成 `tg.Channel.color/profile_color/emoji_status`;`ReactionPolicy` 转成 `ChannelFull.available_reactions/reactions_limit/paid_reactions_available`。 +- `ChannelInvite`:`ChannelID`、`InviteID`、`Hash`、`AdminUserID`、`Title`、`Permanent`、`Revoked`、`RequestNeeded`、`ExpireDate`、`UsageLimit`、`UsageCount`、`Date`。 + - `importInvite` 必须在同一事务内锁定对应 invite row 后检查并递增 `usage_count`,避免多个客户端同时导入一次性链接时突破 `usage_limit`。 +- `ChannelMember`:`ChannelID`、`UserID`、`InviterUserID`、`Role`、`Status`、`JoinedAt`、`LeftAt`、`AdminRights`、`BannedRights`、`Rank`、`AvailableMinID`、`AvailableMinPts`、`ReadInboxMaxID`、`ReadInboxDate`、`ReadOutboxMaxID`、`UnreadMark`。 +- `ChannelMember.AvailableMinID`:当前成员可见历史下界;开启 prehistory hidden 后,新加入/导入/受邀成员初始化为加入前 `channel.top_message_id`,只看后续消息和自己的加入服务消息。 +- `ChannelMember.AvailableMinPts`:当前成员可恢复 channel difference 的 pts 下界;新加入/导入/受邀/重新加入成员初始化为加入前 `channels.pts`,`updates.getChannelDifference(pts=0)` 也会先抬到该值,避免入群前 participant/admin/settings durable event 泄漏。 +- `ChannelMember.ReadInboxDate`:当前成员最后一次推进 `read_inbox_max_id` 的时间,用于 `messages.getMessageReadParticipants` 返回 `readParticipantDate.date`;不参与 channel pts。 +- `ChannelMember.SlowmodeLastSendDate`:普通成员最近一次成功发言时间,用于服务端按 channel 维度返回 `SLOWMODE_WAIT_X`;creator/admin 不受首批 slowmode 限制。 +- `ChannelMessage`:`ChannelID`、`ID`、`RandomID`、`SenderUserID`、`From`、`SendAs`、`Date`、`EditDate`、`Post`、`Silent`、`NoForwards`、`Body`、`Entities`、`ReplyTo`、`Forward`、`Action`、`Pts`、`Deleted`。 +- `ChannelDialog`:当前 user 对 channel 的会话摘要,保存 `FolderID`、`Pinned`、`PinnedOrder`、`TopMessageID`、`ReadInboxMaxID`、`ReadOutboxMaxID`、`UnreadCount`、`UnreadMentions`、`UnreadMark`、`ViewForumAsMessages`、`NotifySettings`。channel message id 是 channel-scoped,跨频道 dialog 排序不能只看 `top_message_id`;统一按 `pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC` 排序,并用 `offset_date + offset_id + offset_peer(channel_id)` 做 seek cursor。folder include/exclude/read/archive/type 条件必须在 SQL `LIMIT` 前下推,避免 TDesktop dialogs 翻页重复顶部频道或自定义分组漏掉旧频道。 +- `ChannelUnreadMention`:按 `(user_id, channel_id, message_id)` 保存未读提及,`top_message_id` 支持 thread/topic 级清除;它是 owner 视角状态,不写入 `channel_update_events`,`messages.readMentions` 返回当前 channel pts 供客户端清本地 badge。 +- `ChannelUpdateEvent`:`ChannelID`、`Pts`、`PtsCount`、`Type`、`Date`、`MessageID`、`MessageIDs`、`SenderUserID`、`UserIDs`、`Payload`。 +- `ChannelAdminLogEvent`:`ChannelID`、`ID`、`UserID`、`Date`、`Type`、前后字符串/布尔/整数、前后 participant、相关 message、`Query`;用于 `channels.getAdminLog`,不参与 channel pts。 + +`domain.PeerType` 扩展为: + +- `user` +- `channel` + +首批不持久化 `chat` peer;RPC 层只在 `messages.createChat` 的 TDesktop 同步响应暴露 migrated legacy `chat` 外观,主消息/media/history/read 等入口遇到 `InputPeerChat/InputChat` 均映射到同 id megagroup/channel。 + +## Storage + +大表从第一版开始分区: + +| table | partition key | purpose | +|---|---|---| +| `channels` | hash(`id`) | channel/supergroup 主体 | +| `channel_members` | hash(`channel_id`) | 成员、权限、读水位、可见历史边界 | +| `user_channel_member_index` | primary key(`user_id`, `channel_id`) | user 维度成员索引;供共同群、`users.getFullUser.common_chats_count` 等 user→channel 热路径使用 | +| `channel_messages` | hash(`channel_id`) | 单份 channel message;TDesktop 看到的 message id 即 `(channel_id, id)` 中的 `id` | +| `channel_message_viewers` | hash(`channel_id`) | `(channel_id,message_id,viewer_user_id)` 去重视图,支持 `messages.getMessagesViews(increment=true)` 幂等递增 | +| `channel_message_reactions` | hash(`channel_id`) | `(channel_id,message_id,reacted_user_id,reaction)` 当前 reaction 状态,支持 `sendReaction/getMessagesReactions/getMessageReactionsList` | +| `private_message_reactions` | message id fk | `(private_message_id,user_id,reaction)` 当前私聊 reaction 状态,双端 message box 共享聚合,支持 private `sendReaction/getMessagesReactions/getMessageReactionsList` | +| `user_saved_reaction_tags` | user_id prefix | 账号级 saved-message reaction tag 标题;支持 `messages.getSavedReactionTags/updateSavedReactionTag`,不承载 message assignment/count | +| `channel_unread_mentions` | hash(`user_id`) | owner 视角未读提及索引,支持 `messages.getUnreadMentions/readMentions`,不复制 channel message body | +| `channel_update_events` | hash(`channel_id`) | channel pts durable log,供 `updates.getChannelDifference` | +| `channel_dialogs` | hash(`user_id`) | 当前账号对 channel 的 dialog/read/folder/pin/mute 摘要 | +| `dialog_drafts` | hash(`user_id`) | user/channel peer 云草稿;支持 forum `top_msg_id`,不复制 channel message | +| `channel_invites` | hash(`channel_id`) | 默认 invite link、管理员导出的邀请链接、usage/requested 计数 | +| `channel_invite_importers` | hash(`channel_id`) | invite importer/read model 与 pending join request;每个 `(channel_id,user_id)` 只保留当前状态 | +| `channel_admin_log_events` | hash(`channel_id`) | 管理日志;按 channel 内单调 `id` seek pagination,不复制 channel 消息正文 | + +关键索引: + +- `channel_usernames(username_lower)` 主键保存公开 channel username 占用;`channels.username` 保存展示值,避免 PG 分区表唯一索引必须包含分区键的问题。 +- `channel_members(channel_id, user_id)` 主键;父表上的 `(user_id, channel_id)` 索引不能裁剪 `channel_id` HASH 分区,只可作为单分区内辅助索引,禁止用它承载 user→channel 热路径。 +- `user_channel_member_index(user_id, channel_id) WHERE status='active' AND megagroup AND NOT broadcast AND NOT deleted` 支持共同群 count/list;由 channel member upsert、leave 和 delete channel 事务同步维护。后续凡是从 user 入口列 joined/admined/left channel,都应扩展这张 user 维度 read model 或两步取 bounded channel_id 后再按 `channel_id/id = ANY($1)` 访问分区表。 +- `channel_members(channel_id, read_inbox_max_id, user_id) WHERE status='active'` 支持小群已读详情按读水位有界查询;同时用 `available_min_id < msg_id` 排除加入前不可见历史。 +- `channel_messages(channel_id, id DESC) WHERE deleted=false` 历史 seek pagination。 +- `channel_messages(channel_id, sender_user_id, id DESC) WHERE deleted=false` 支持 `channels.deleteParticipantHistory` 按发送者有界取最近一页消息,避免成员历史删除全表扫。 +- `channel_messages(channel_id, sender_user_id, random_id)` 唯一,保障 channel send 幂等。 +- `channel_messages(channel_id, reply_to_top_id, id DESC) WHERE reply_to_top_id > 0 AND NOT deleted` 支持 `messages.getReplies` thread/comment seek pagination。 +- `channel_messages(discussion_channel_id, discussion_message_id) WHERE discussion_channel_id <> 0 AND discussion_message_id <> 0 AND NOT deleted` 支持 broadcast post 到 discussion root 的反查与排查。 +- `channel_message_viewers(channel_id, message_id, viewer_user_id)` 主键去重;`messages.getMessagesViews` 先按最多 100 个 id 过滤可见 `channel_messages`,只对首次插入的 viewer 更新 `channel_messages.views_count`,不按 viewer 做 count 聚合扫描。 +- `channel_message_reactions(channel_id, message_id, reaction_date DESC, reacted_user_id DESC, reaction_value ASC)` 支持最近 reaction 回填和列表 seek;`(channel_id,message_id,reaction_type,reaction_value,reaction_date DESC,reacted_user_id DESC)` 支持按单个 emoji 过滤,不使用 SQL OFFSET。 +- `channel_unread_mentions(user_id, channel_id, top_message_id, message_id DESC)` 支持当前账号 unread mentions seek;发送时只为解析出的 active/可见/未读成员插入,单条消息最多 100 个候选,清除时单批最多 1000 条。 +- `channel_update_events(channel_id, pts)` 主键;`(channel_id, pts)` 升序扫描差量。 +- `channel_dialogs(user_id, folder_id, pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC)` 支持 dialogs seek;`default_send_as_peer_type/default_send_as_peer_id` 只保存当前 owner 的默认发送身份,不参与列表排序。 +- `dialog_drafts(user_id, date DESC, peer_type, peer_id, top_message_id)` 支持 `messages.getAllDrafts/clearAllDrafts` 有界扫描;draft 内容用 domain JSON 保存,业务层不持有 `tg.*`。 +- `channel_invites(channel_id, admin_user_id, revoked, created_at DESC, hash DESC)` 支持 TDesktop invite links 管理页按 admin/revoked/offset seek 翻页;`channel_invite_hashes(hash)` 用于导入链接按 hash 快速定位,撤销 invite 不删除 hash 映射,避免管理页 detail 失效。 +- `channel_invite_importers(channel_id, invite_id, requested, date DESC, user_id DESC)` 与 `(channel_id, requested, date DESC, user_id DESC)` 支持 `messages.getChatInviteImporters` 的 link/requested/filter 查询;`q` 过滤走有界 candidate 后关联 user,不允许全表搜索。 +- `channel_admin_log_events(channel_id, id DESC)` 支持 admin log 翻页;`(channel_id, actor_user_id, id DESC)` 支持 admins 过滤;`(channel_id, event_type, id DESC)` 支持 events_filter。 + +Redis 可恢复计数: + +- `counter:channel_id`:channel id,值域应避开 user id。 +- `counter:channel_msg_id:{channel_id}`:channel message id。 +- `counter:channel_pts:{channel_id}`:channel pts。 + +管理日志 `id` 使用 `channels.admin_log_seq` 在同一事务内 `UPDATE ... RETURNING` 分配,原因是它只服务管理页 seek,不参与高频消息 pts;后续如果 admin log 写入成为瓶颈,再迁移到可恢复 Redis counter + noop 占位策略。 + +Redis miss 恢复来源: + +- channel id 从 `MAX(channels.id)` 恢复。 +- message id 从 `MAX(channel_messages.id WHERE channel_id=?)` 恢复。 +- channel pts 从 `MAX(channel_update_events.pts WHERE channel_id=?)` 恢复。 + +分配必须使用 Redis Lua 的初始化+递增原子脚本;批量 delete/clear 这类 `pts_count>1` 的操作必须一次性分配连续 range,PG fallback 也要实现 `NextChannelPtsN(current+count)`,不能用多次读取 `MAX(pts)+1` 模拟。事务失败但 pts 已分配时必须写 `noop` channel update 占位,避免 TDesktop channel PtsWaiter 永久 gap。 + +## Create Flow + +`messages.createChat`: + +1. RPC 层解析 users/title/ttl,校验至少 1 个非自己用户,限制单次邀请数量。 +2. 调用 `ChannelService.CreateMegagroupFromCreateChat`,内部强制 `Broadcast=false, Megagroup=true`。 +3. 创建 channel、creator member、受邀成员、默认 invite link。 +4. 分配 channel message id + channel pts,写 `messageActionChannelCreate` 或 `messageActionChatCreate` 兼容服务消息。 +5. 为 creator 与受邀成员创建 `channel_dialogs`,但不复制 message body。 +6. 写 `channel_update_events(updateNewChannelMessage)`。 +7. 给 creator 当前 RPC 返回 `messages.InvitedUsers{updates}`;TDesktop ctx 下该同步响应的 `chats` 第一项是带 `migrated_to` 的 legacy `chat` 外观,第二项保留真实 `channel`。其它在线成员通过 outbox/active channel push 收到真实 channel updates,不推 legacy 外观。 + +`channels.createChannel`: + +- `broadcast=true` 创建频道,`megagroup=true` 创建超级群。 +- 如果两个 flag 都没传,TDesktop 路径按 broadcast 处理;服务端可返回 `CHANNEL_INVALID` 防错误客户端。 +- `for_import` 依赖 `messages.initHistoryImport` 与导入 session,本阶段没有历史导入模型,返回 `CHAT_INVALID`;`geo_point/address` 依赖 geogroup/location 模型,本阶段返回 `ADDRESS_INVALID`。这两类高级 flags 不能落 `NOT_IMPLEMENTED`,也不能伪造成普通群创建成功。 +- `ttl_period` 只接受非负值,负数返回 `TTL_PERIOD_INVALID`;真实 TTL 自动删除管线后续接入。 +- broadcast 的普通成员不能发普通消息;creator/admin 可发 `post` 消息。 +- megagroup 的 slowmode 只限制普通成员发普通消息;服务端检查 `slowmode_last_send_date + slowmode_seconds`,重复 `random_id` 命中幂等结果时不重新触发限速。 +- 如果 `pre_history_hidden=true`,invite/join/import invite 写成员时用加入前的 `top_message_id` 初始化 `available_min_id`;无论是否隐藏历史,都用加入前 `channels.pts` 初始化 `available_min_pts`。为了避免旧历史在惰性 unread 计算中变成未读,invite/join/import invite 都会把 `read_inbox_max_id` 初始化到加入前 top;主动 join/rejoin/import invite 生成的自服务消息会继续把当前成员 read 水位推进到该服务消息,邀请服务消息则保留为被邀请者的一条未读。 + +## Send / History Flow + +频道/超级群消息只写一份 `channel_messages`: + +1. 校验成员状态、ban/default banned rights、broadcast 发送权限、普通成员 invite/send 默认限制、slowmode、noforwards。 +2. 如带 `reply_to`,按当前 channel + 当前成员 `available_min_id` 校验目标消息存在、未删除、可见,并反算 `reply_to_top_id`:目标已有 top 则继承,否则 top=目标 msg_id;quote 文本/entities/offset 原样保留,但 `quote_text` 上限 1024、`quote_offset` 按原消息文本 offset 收口到 4096,不能按 message id 放行。 +3. 通过 Redis 分配 `channel_msg_id` 与 `channel_pts`。 +4. 写 `channel_messages` 与 `channel_update_events(updateNewChannelMessage, pts_count=1)`。 + - `random_id` 幂等重试必须返回原始 `updateNewChannelMessage` 的 durable snapshot;即使该消息之后被编辑或删除,也不能把重试响应污染成 edit/delete 事件或当前消息状态。 +5. 更新 `channels.top_message_id/pts`。 +6. 更新发送者 `channel_members/channel_dialogs.read_inbox_max_id/read_outbox_max_id/top_message`,自己的新消息立即视为已读/已发。 +7. 不写成员 message box;Postgres 用单条 set-based SQL 推进 active/可见成员的 `channel_dialogs.top_message_id/unread_count` 缓存,dialog 查询仍以 `channels.top_message_id` + 当前 member 的 `available_min_id/read_inbox_max_id` 可重算可见 top 与 unread,避免缓存陈旧影响 TDesktop 离线恢复。 +8. 在线推送只 fanout 给当前活跃 channel 成员 session,且每个接收者按自己的 viewer user id 重新生成 `tg.Message.out`,不能复用发送者视角的 update;离线设备走 `updates.getChannelDifference`。 + +`messages.getHistory(InputPeerChannel)` 直接查 `channel_messages(channel_id, id)`,按 `offset_id/max_id/min_id` 做 seek pagination,`offset_id=0` 时支持 `offset_date` date cursor,禁止 SQL 大 OFFSET。`channels.getMessages` 必须按 `id = ANY(...)` 精确批量读取,不允许把稀疏 ID 转成 min/max 范围后再 `LIMIT`,否则 TDesktop 拉 pinned/reply/跳转消息会漏旧消息。`channels.getChannels/channels.getMessages` 这类精确 ID vector 入口统一 cap=100,不能按客户端传入数量无界查库或构造响应。返回消息时按请求 user 动态设置 `out`、`post`、`from_id/send_as` 与 reply/forward 信息。 + +## Dialog / Read / Unread + +`channel_dialogs` 是 owner 视角: + +- `read_inbox_max_id`:当前用户已读到的最大 channel message id。 +- 被邀请加入已有 megagroup/channel 时,成员的初始 `read_inbox_max_id` 固定到邀请发生前的 `channels.top_message_id`;如果 megagroup 产生 invite service message,则只让这条新服务消息成为未读,不能把入群前历史全部计入 unread。主动 join/rejoin/import invite 也先把 read 水位固定到加入前 top,再把自己的 join service message 标为已读,避免 TDesktop 打开会话时把旧消息批量推进成读回执。`pre_history_hidden=true` 时 `available_min_id` 也落到同一个旧 top,既隐藏旧历史又避免未读数膨胀。成员的 `available_min_pts` 固定到加入前 `channels.pts`,避免离线补偿返回入群前权限/成员事件。 +- `joinChannel/importInvite` 必须尊重既有 member 状态:`active` 重复加入返回 `USER_ALREADY_PARTICIPANT`,`kicked`、`banned` 或 `view_messages` ban 不允许直接重新加入;`left` 用户重新加入时恢复 `participants_count`。`inviteToChannel` 对单个已加入用户返回 `USER_ALREADY_PARTICIPANT`,对被踢/被禁看消息用户只有 creator 或具备 `ban_users` 的 admin 才能通过邀请恢复,普通成员单人邀请返回 `USER_KICKED`,多人邀请跳过无权恢复的目标。`leaveChannel` 的在线 recipients 必须包含离开的 user 本人,用于同账号其它设备同步离群状态。 +- `importInvite` 的 invite link 错误码需要保持 TDesktop 可解释:`request_needed` 当前不落真实申请流,但返回 `INVITE_REQUEST_SENT`;`usage_limit` 用尽返回 `USERS_TOO_MUCH`;被踢/禁看仍返回 `INVITE_HASH_INVALID`,避免泄漏私有成员状态。 +- `read_outbox_max_id`:当前用户发送的 channel/supergroup 消息被其它成员读到的最大值。`readHistory` 只扫描 bounded recent delta,推进涉及发送者的 member/dialog outbox watermark,并通过 `updateReadChannelOutbox` 在线通知发送方;不做全员 O(n) fanout。该水位必须同时从 `messages.getDialogs/messages.getPeerDialogs` 的 dialog 和 `channels.getFullChannel` 的 `ChannelFull` 返回,作为离线设备或丢失实时 update 后的恢复路径。 +- `unread_count`:发送事务推进 active/可见成员的 `channel_dialogs` 缓存,计算方式仍是 `max(0, channels.top_message_id - read_inbox_max_id)`;删除/隐藏历史会用 `available_min_id` 修正,读请求必须可重算,不能只信缓存。 +- `top_message_id/top_message_date`:`channel_dialogs` 中的值是当前 owner 状态缓存,发送时会随最新消息推进;本地清空历史后可能落后,PG 查询仍必须优先用 `channels.top_message_id > channel_members.available_min_id` 判断当前可见 top,并 join 当前 top `channel_messages` 取真实 `message_date` 排序。 +- `folder/pin/manual unread/notify settings/default send-as/view-forum-as-messages` 仍按当前 user 保存,并通过 user_update_events 通知多设备;频道消息本身通过 channel_update_events 补。`messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks` 和 `folders.editPeerFolders` 必须把 `InputPeerChannel` 分流到 `channel_dialogs`,与私聊 `dialogs` 合并返回;这些是账号级 dialog 状态,不推进 channel pts。Layer 225 `markDialogUnread/getDialogUnreadMarks` 的 `parent_peer` 只用于 monoforum/SavedSublist;当前没有 monoforum subdialog store,因此先校验 parent channel 与 sublist peer 后 BoolTrue/空列表 no-op,避免 TDesktop 后台 `NOT_IMPLEMENTED`,真实 monoforum unread marks 留后续模型。`messages.saveDefaultSendAs` 写 `channel_dialogs.default_send_as_peer_*`,`channels.toggleViewForumAsMessages` 写 `channel_dialogs.view_forum_as_messages` 并发 `updateChannelViewForumAsMessages`,`channels.getFullChannel` 输出 `channelFull.default_send_as/view_forum_as_messages` 供 TDesktop 恢复本地选择。`folder_id=0/1` 是物理主列表/归档状态,必须在 SQL `LIMIT` 前下推;`folder_id>=2` 是 TDesktop 自定义 filter 规则,不能拿它和 `channel_dialogs.folder_id` 直接相等比较。 + +`messages.readHistory(InputPeerChannel)`: + +1. 锁当前 user 的 `channel_dialogs`。 +2. 将 `read_inbox_max_id` 推进到 `min(req.max_id, channel.top_message_id)`。 +3. 若水位前进,写 `channel_members.read_inbox_date=req.date`;再更新 `unread_count/manual unread`。 +4. 对当前 user 产生账号级 `updateReadChannelInbox` 或兼容的 dialog/user update;该事件用于多设备 dialog 未读状态同步。 + - 当前实现记录一条 `user_update_events(read_history_inbox, peer_type='channel')`,在线/离线设备转换为 TL `updateReadChannelInbox`;实时 fallback 使用 channel pts 填 `updateReadChannelInbox.pts`,可靠 outbox/difference 使用账号 pts 作为 durable cursor,TDesktop 在 pts 不匹配时仍会推进 `max_id` 并通过 dialog entry 取回精确 unread。 +5. 对读水位前进区间做 bounded sender scan:最多查看最近 `MaxChannelReadOutboxScanMessages=1000` 条、最多通知 `MaxChannelReadOutboxFanout=128` 个发送者;每个发送者只推进自身 `read_outbox_max_id` 并在线推 `updateReadChannelOutbox(channel_id,max_id)`,避免客户端传超大 `max_id` 时生成无界 update。 + +`messages.getMessageReadParticipants(InputPeerChannel)`: + +- 只对 megagroup 返回真实小群读者;broadcast channel 或成员数超过 `chat_read_mark_size_threshold` 返回空列表,避免大频道 O(n) 放大。 +- 若 `channels.participants_hidden=true`,已读详情直接返回空;TDesktop 在隐藏成员模式下也会关闭 read participants UI,服务端不通过该接口泄漏成员身份。 +- 先校验当前用户可见 channel 与目标 message,删除消息或不可见历史返回 `MESSAGE_ID_INVALID`。 +- 消息超过 `chat_read_mark_expire_period` 返回空列表;客户端正常不会发起过期请求。 +- 查询 `channel_members` 时只取 `status='active'`、未禁看消息、`available_min_id < msg_id`、`read_inbox_max_id >= msg_id` 且 `read_inbox_date > 0` 的成员,并排除请求者自己;单次最多 50 个。初始 read 水位只用于 unread 基线,不应让新加入成员凭默认水位出现在旧消息的已读列表中。 +- 返回的 date 来自 `read_inbox_date`,旧数据未记录日期时允许为 0,TDesktop 会按无具体时间展示。 + +## Channel Difference + +`updates.getChannelDifference(channel, pts, limit)`: + +- 校验 access_hash、成员/可见权限、ban。 +- `limit` cap:普通用户 `1..100`,超过按 100;内部硬上限 1000,拒绝负数和超大值。 +- 若 `pts < 0` 或 `pts > current_channel_pts` 返回 `PERSISTENT_TIMESTAMP_INVALID`,避免客户端用未来水位跳过 durable log。 +- 从 `channel_update_events` 读取 `pts > req.pts ORDER BY pts ASC LIMIT cap+1`。 +- `channel_update_events.payload` 对 new/edit/pin/admin participant 等事件保存 domain 快照;`updates.getChannelDifference` 必须优先使用事件时刻的 message/participant snapshot,不能回读当前 `channel_messages` 覆盖旧事件,否则连续编辑、删除后的离线补偿会丢失中间状态。 +- 没有事件返回 `channelDifferenceEmpty{final=true, pts=current_channel_pts, timeout=30}`。 +- 事件数 `<= cap` 返回 `channelDifference{final=true, pts=max_pts, new_messages, other_updates, chats, users}`。 +- 如果当前 member 的 `available_min_pts > req.pts`,先把请求 pts 抬到 `available_min_pts`,从源头跳过入群/重新加入前的 participant/admin/settings 事件。 +- 对 `available_min_id` 之后才可见的成员,普通差量仍扫描 durable log 并推进返回 `pts`,但会过滤 `new/edit/delete/pin` 中 `message_id <= available_min_id` 的消息内容和 id;若本页全被过滤,返回 `channelDifferenceEmpty{pts=max_scanned_pts}`,避免隐藏历史或本地清空后的旧消息通过差量恢复泄露。部分可见的 delete/pin 事件只裁剪 `messages` 向量,保留原始 `pts_count`;TDesktop 在线 update 用 `pts_count` 推进 channel PTS,差量响应最终用 `channelDifference.pts` 初始化,不要求 `len(messages)==pts_count`。 +- 若 `current_channel_pts - req.pts > cap`,返回 `channelDifferenceTooLong`,包含带当前 channel pts 的 dialog、最新一页有界消息、channel、相关 users,避免大频道旧 pts 客户端循环拉取大量差量页。 +- 否则事件数 `<= cap` 返回 `channelDifference{final=true, pts=max_pts, new_messages, other_updates, chats, users}`;事件数达到 cap 但仍未追上当前 pts 时返回 `final=false`,客户端会继续拉下一页。 +- `new_messages/other_updates` 中 message 的 `from/send_as/fwd_from/reply_to/action` 涉及的 user/channel 必须随本页 `users/chats` 返回;账号级 `user_update_events` 同样持久带出 fwd/reply 的 users/channels,供 `updates.getDifference` 和 `dispatch_outbox` 复用,避免在线响应正常但离线恢复缺 peer。 + +## Input Hash And Visibility + +- 外部 `InputChannel` / `InputPeerChannel` 带非零 `access_hash` 时,RPC 层必须先和当前 domain channel 的 `access_hash` 比对;不匹配返回 `CHANNEL_PRIVATE` 或在 vector 查询中跳过该项,禁止只靠 channel_id + member 关系兜底。 +- 这个校验不仅覆盖 `channels.*` 和 `messages.send/edit/delete/read/history/search` 主路径,也覆盖 `messages.getPeerSettings`、`messages.saveDraft`、`messages.getDialogs` offset peer、dialog pin/unread/folder、`messages.updateDialogFilter`、`folders.editPeerFolders`、reply header 的 `reply_to_peer_id` 等旁路入口。 +- `InputChannelFromMessage` / `InputPeerChannelFromMessage` 属于 min channel 解析路径,没有 access_hash 字段;服务端只接受正 channel_id,并继续执行成员/可见性校验。 +- legacy basic group wrapper 由服务端内部映射到 megagroup,内部构造的 `InputChannel{access_hash=0}` 允许跳过 hash 比对;真实客户端传来的非零 hash 一律校验。 + +硬约束: + +- `updateNewChannelMessage.PtsCount=1`。 +- `updateEditChannelMessage.PtsCount=1`。 +- `updateDeleteChannelMessages.PtsCount=len(message_ids)`,单次 cap 1000。 +- 全清历史不能生成十几万条 update,也不能一个超大 vector;`messages.deleteHistory(InputPeerChannel)` 返回 `affectedHistory.offset` 供客户端/管理端续删,`channels.deleteHistory` 的 TL 返回 `Updates` 且 TDesktop 不读取 offset,所以该入口只执行一个有界 page 或本地 `available_min_id` 清空,不能在同步 RPC 内循环展开全量历史。 +- 所有 history/search 入口的 `add_offset` 必须在 RPC/store 层 clamp 到小窗口;即使 channel 单份历史当前按 seek 查询忽略大 offset,也不能让私聊或后续 channel around-load 分支把客户端传入的极端值变成无界 slice/SQL OFFSET。 +- `channels.deleteParticipantHistory` 同样只删除一个有界 page,按 `(channel_id, sender_user_id, id DESC)` seek 最近消息,`offset=1` 表示后面仍可能有更多该成员历史需要客户端/管理端继续请求。 + +## Admin Log + +`channels.getAdminLog` 是管理页的只读审计视图,不推进 channel pts,也不写入普通用户 update。当前真实记录这些 TDesktop 首批能展示的事件: + +- 元信息/设置:title、username、signatures、prehistory、slowmode、anti-spam。 +- 成员权限:invite、join、leave、promote/demote、ban/unban、kick/unkick。 +- 消息管理:pin/unpin、channel post/send、edit、delete。 + +请求边界: + +- `limit` cap 100;`admins` cap 100;`q` cap 128。 +- `max_id`/`min_id` 只作为 seek 条件:`id < max_id`、`id > min_id`,禁止展开区间数组。 +- `q` 只做有界页面搜索,覆盖元信息字符串与消息类事件 body;后续若管理日志量级增大,再接 PG full-text/trigram 索引。 +- `events_filter` 空表示不过滤;非空只映射当前支持的 action types,未来 forum/group_call/subscription 等先保持无结果而不是伪造。 +- `admins` 过滤 actor_user_id,不按受影响成员过滤;TDesktop 侧该参数语义就是管理员 actor 列表。 + +返回 `channels.adminLogResults` 时,RPC 层按事件收集 actor、participant、message sender,填充 `users`;`chats` 返回当前 channel。domain/store 层只保存自有模型,TL action 转换集中在 `internal/rpc`。 + +当前实现状态: + +- 已实现 `messages.createChat -> megagroup`、`channels.createChannel`、成员 invite/join/leave、channel 单份文本发送、history、dialogs、read、typing 与 `updates.getChannelDifference`;`channels.createChannel` 的 history import/geogroup 高级 flags 暂不建模,已映射为显式 TL 错误而非 `NOT_IMPLEMENTED`。 +- 已实现 channel 文本 edit:只更新 `channel_messages` 单份消息,写 `channel_update_events(edit_channel_message)`,在线推 `updateEditChannelMessage`;TDesktop 文本编辑携带的 `inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media/reply_markup/quick replies 仍返回显式 TL 错误等待后续模型。 +- 已实现 channel delete:`channels.deleteMessages` 与 `channels.deleteHistory(for_everyone)` 软删单份消息,单批最多 1000 个 id,写一条 `delete_channel_messages` 事件,`pts_count=len(ids)`;需要多页续删的管理路径应使用 `messages.deleteHistory(InputPeerChannel)` / `channels.deleteParticipantHistory` 这类带 `offset` 的响应继续推进,避免复现 参考实现 按超大范围构造 id vector 的 OOM 风险。 +- 已实现 `channels.deleteParticipantHistory`:管理员按 participant sender 删除一页消息,PG 走 sender history 部分索引,单批最多 1000 个 id,返回 `affectedHistory.offset` 供续删,在线推 `updateDeleteChannelMessages`。 +- 已实现当前用户本地清空 channel history:只推进该用户 `available_min_id/read_inbox` 与 `channel_dialogs`,不生成 channel pts,也不对成员写扩散;同时写账号级 `channel_available_messages` durable update,并给同账号其它 session 推 `updateChannelAvailableMessages`;重复或 stale 清空请求返回实际单调水位,不能把客户端 `available_min_id` 回退。 +- 已实现 channel reply:发送时校验被回复消息是同 channel 内当前成员可见消息,继承/计算 `reply_to_top_id`,保留 quote metadata,并在 TL 层返回 `messageReplyHeader`;非法、本地清历史后不可见目标或超界 `quote_offset` 返回 `REPLY_MESSAGE_ID_INVALID`。 +- 阶段外高级发送/转发 flags 不再返回 `NOT_IMPLEMENTED`:quick reply、effect、paid、suggested post、monoforum/todo/poll/story reply 分别映射到 `SHORTCUT_INVALID`、`EFFECT_ID_INVALID`、`PAYMENT_UNSUPPORTED`/`STARS_AMOUNT_INVALID`、`SUGGESTED_POST_PEER_INVALID`、`REPLY_TO_MONOFORUM_PEER_INVALID`、`REPLY_MESSAGE_ID_INVALID`、`POLL_OPTION_INVALID`、`STORY_ID_INVALID`;后续接入对应模型前不伪造成功 update。 +- 已实现 linked discussion/comment 基础闭环:broadcast post 写入单份 source message 时同步在 linked megagroup 写一条 forwarded root message;source message 保存 discussion ref 并在 `messageReplies` 填 `comments/channel_id/replies_pts/max_id/read_max_id`;`messages.getDiscussionMessage` 返回 root、`messages.getReplies` 读 linked group thread,`messages.readDiscussion` 推进 linked group read watermark。 +- 已实现文本转发的 channel 路径:channel→channel、channel→user、user→channel;目的 channel 仍按单份消息写入并生成 channel pts;请求携带的目标会话 `reply_to` 会正常校验和持久化,TDesktop 转发到 forum topic 时发送的 `top_msg_id` 会映射为 topic-only reply 并更新 topic top message,但源消息自身 reply 不继承;RPC 响应、账号级 difference/outbox 与 channel difference 都会为 `fwd_from/reply_to` 中可解析的 user/channel peer 补齐 users/chats,避免 TDesktop 因 header peer 未加载而延迟 apply。 +- 已实现 `messages.search(InputPeerChannel)`:支持 channel/supergroup 单份消息文本搜索、`from_id` 用户过滤、`min_date/max_date`、`offset_id/max_id/min_id` 与 limit cap,结果附带当前 channel 与消息 sender/forward/reply 所需 users/chats。 +- 已实现 `messages.searchGlobal` 的 channel/supergroup 分支:TDesktop 主搜索的 Channels/Groups tab 会按当前账号 active membership 搜索单份 channel message,支持 `broadcasts_only/groups_only/users_only`、`offset_rate+offset_peer+offset_id` seek、folder_id=0/1 下推与 limit cap=50;未加入/left/kicked/view_messages banned 的频道不暴露,media filter 在 media store 接入前返回空结果。 +- 已实现 `messages.getMessageReadParticipants(InputPeerChannel)`:基于小 megagroup 成员读水位返回 `readParticipantDate`,含 50 人阈值、7 天过期窗口、`available_min_id` 可见性过滤与 PG 索引。 +- 已实现 `messages.getMessageEditData`:私聊与 channel peer 都做 message/peer/作者或管理员编辑权限校验;当前文本-only 编辑没有媒体 caption 状态,返回 `caption=false`。 +- 已实现 `messages.getMessagesViews(InputPeerChannel)`:TDesktop 每秒最多 100 条批量增量,服务端按 `(channel_id,message_id,viewer_user_id)` 持久去重并维护 `channel_messages.views_count` 聚合列;本地清历史前不可见、已删除或不存在的 id 不递增且返回空 view,replies/comment 信息继续从 discussion/thread model 回填。 +- 已实现 `channels.exportMessageLink`:复制频道/超级群消息链接前会校验 channel message 对当前成员真实存在且未被删除/本地清历史隐藏;公开 username 走 `t.me/{username}/{msg_id}`,私有 channel 走 `t.me/c/{channel_id}/{msg_id}`,普通 reply/thread 链接支持 `?thread={root_id}`。`grouped/html` 与 linked discussion 的 `?comment=` 细分链接留后续。 +- 已实现管理面最小真实能力:`channels.editAdmin/editBanned/editTitle/deleteChannel`、`channels.getParticipants` 的 admins/kicked/banned/search 等过滤、`messages.updatePinnedMessage/unpinAllMessages`、`messages.exportChatInvite/checkChatInvite/importChatInvite`。`channels.deleteChannel` 按 TDesktop/参考实现预期返回并推送 `updateChannel + channelForbidden`,同时 dialog 列表过滤 deleted channel。`channels.editPhoto/messages.editChatPhoto` 在头像 media store 接入前只接受 `inputChatPhotoEmpty` no-op 删除,uploaded/existing photo 明确返回 `PHOTO_INVALID`,不伪造 `messageActionChatEditPhoto`。 +- `channels.editAdmin/editBanned` 会写 channel-scoped `channel_participant` durable event,保存 prev/new participant;在线响应/推送包含 `updateChannelParticipant + updateChannel + updateChannelTooLong(pts)`,离线或 pts gap 通过 `updates.getChannelDifference` 补偿。 +- 已实现公开 username 管理:`channels.checkUsername/updateUsername/getAdminedPublicChannels`,PG 用 `channel_usernames(username_lower)` 与 users username 查询避免跨 peer 占用;主 username 的清除只走 `channels.updateUsername("")`。 +- 已实现 `channels.toggleSignatures`:权限校验后持久化 `channels.signatures`,返回/在线推 `updateChannel`。 +- 已实现 `channels.updateColor/updateEmojiStatus`:颜色分别持久化 `color/profile_color` 与 background emoji id,并保留 color flag 显式 0;普通 emoji status 保存 document id/until,`emojiStatusEmpty` 清空,collectible gift 状态因缺少 gift/read model 先返回 `EMOJI_STATUS_INVALID`。响应、在线推送、`channels.getChannels` 都回填 `Channel.color/profile_color/emoji_status`。 +- 已实现 `channels.toggleViewForumAsMessages`:这是当前账号本地论坛展示模式,不修改 topic 列表;服务端写 `channel_dialogs.view_forum_as_messages`,返回/可靠投递 `updateChannelViewForumAsMessages` 给同账号其它 session,并在 `messages.getDialogs` 与 `channels.getFullChannel` 回填 `Dialog/ChannelFull.view_forum_as_messages`。 +- 已实现 forum topic 最小真实闭环:`channels.toggleForum` 打开 forum 后,`messages.getForumTopics/getForumTopicsByID` 返回虚拟 General + `channel_forum_topics` 分区表里的 topic;`messages.createForumTopic` 写 `messageActionTopicCreate` root service message,topic_id 绑定该 message id,并用 channel pts 通过 `updateNewChannelMessage` 在线推送。TDesktop 的 topic 输入框 `reply_to_msg_id=0/top_msg_id=topicRootId` 已支持:服务端校验 topic 存在且未关闭/隐藏,消息只写一份 `channel_messages`,保存 `reply_to_top_id` 和 `forum_topic` 标志,`messages.getReplies(topicRoot)` 返回 `messages.channelMessages{pts,topics}` 且 topic 用 short constructor;发送 topic 消息会更新 topic top message,`messages.forwardMessages.top_msg_id` 也走同一 topic-only reply 语义,避免转发到主题落到主会话。`messages.editForumTopic` 写 `messageActionTopicEdit` service message 且 reply_to_top_id 指向 topic root;`messages.updatePinnedForumTopic/reorderPinnedForumTopics` 更新 topic pinned/order 并推送对应 TL update;`messages.deleteTopicHistory` 每页最多删 `MaxDeleteHistoryBatch` 条 root/thread message,通过 `affectedHistory.offset` 续删,最后一页才隐藏 topic。topic page 走 limit cap=100 与 seek 条件,不按客户端超大 offset/id 展开数组。 +- 已实现 `channels.toggleAntiSpam`:服务端写 `channels.antispam`,返回/推送 `updateChannel`,在 `channels.getFullChannel` 回填 `ChannelFull.antispam`,并记录 `channelAdminLogEventActionToggleAntiSpam`;真实 native anti-spam bot 和自动删垃圾消息管线后续单独接入。 +- 已实现 `channels.getSendAs` + `messages.saveDefaultSendAs` 的保守 current-channel 链路,并让 `messages.sendMessage/forwardMessages(InputPeerChannel)` 接受同一套 `send_as` 校验结果:列表始终包含 self,只有 creator、broadcast post admin、megagroup anonymous admin 才追加 current channel,且响应必须带 current channel chat + self user 供 TDesktop 解析;不暴露 public channel 扩展候选。`channels.getSendAs` handler 在一次 `GetChannel` 中完成 access_hash 校验、成员读取与候选构造,避免 TDesktop 打开输入框时重复查同一 channel。保存 current channel 会写入 `channel_dialogs` 并在 `channelFull.default_send_as` 恢复;保存 self 清空默认身份。发送/转发未显式带 `send_as` 时会读取已保存默认身份并重新校验,默认值因权限变化失效时自动降级 self,避免陈旧状态阻塞发送。 +- 已实现 `messages.toggleNoForwards`:legacy chat/channel 内容保护入口持久化 `channels.noforwards`,返回/在线推 `updateChannel`;后续 channel/supergroup 单份消息写入时自动继承 noforwards,避免转发受保护内容。 +- 已注册 `messages.setChatTheme`,并实现 `messages.setChatAvailableReactions`:前者对 private peer 返回空 updates、对 legacy chat/channel 返回 channel context;后者参考实现 的 `available_reactions` 存储语义,用 domain policy + PG JSONB 持久化,`channels.getFullChannel` 返回 `available_reactions/reactions_limit/paid_reactions_available`,并对 reaction vector/reactions_limit 做 cap。 +- 已实现 private + channel/supergroup emoji message reaction 最小闭环:`messages.getAvailableReactions` 优先返回 seed 自真实导出的 reaction documents;`messages.sendReaction` 对 private peer 写 `private_message_reactions`,按共享 `private_message_id` 聚合但以各 owner 的 peer/msg_id 构造 `updateMessageReactions`,并写账号级 `message_reactions` durable event,离线设备经 `updates.getDifference` 收到带最新 `message.reactions` 的 message + `updateMessageReactions`;对 channel/supergroup 写 `channel_message_reactions`,替换或清除当前用户对单条 channel message 的 emoji reaction,返回并在线推 update,离线/重开由 `messages.getHistory` / `updates.getChannelDifference` / `messages.getMessagesReactions` 补偿;channel `sendReaction` 每次还累计账号级 `user_top_reactions`,`getTopReactions` 按使用次数排序并优先用真实 available reaction catalog 有界补齐;`add_to_recent` 会写账号级 `user_recent_reactions`,`messages.getRecentReactions/clearRecentReactions` 按 hash 有界返回/清空最近 emoji reaction;`messages.updateSavedReactionTag` 会写账号级 `user_saved_reaction_tags` 并推 `updateSavedReactionTags` 给同账号其它 session,`getSavedReactionTags` 全局请求按 hash 返回标题列表;channel 反应者不是消息作者时标记 unread 并重算 `channel_dialogs.unread_reactions_count`,`messages.getUnreadReactions/readReactions` 按消息作者视角拉取/清理未读 reaction;TDesktop 打开会话时的 `channels.readMessageContents` 会按 visible message ids 清理这些 unread reaction 并推 `updateMessageReactions`,避免 dialog reaction icon 重启后重新出现;`messages.getMessagesReactions` 按请求 id 返回 reaction 聚合清理/刷新 UI,`messages.getMessageReactionsList` 返回具体用户列表(channel 用 keyset offset,private 当前受 recent 嵌入上限约束)。custom emoji、paid reaction、saved-message tag assignment/count/per-peer ranking 留后续模型。 +- 已实现 `channels.togglePreHistoryHidden/toggleSlowMode`:prehistory 仅 creator 可改,并通过 `available_min_id/read_inbox_max_id` 裁剪新成员历史,同时用 `available_min_pts` 裁剪新成员 difference 起点;slowmode 允许 change_info 管理员改;PG 持久化 `pre_history_hidden/slowmode_seconds/slowmode_last_send_date`,TDesktop 可从 channel/full channel 看到状态,普通成员过快发言返回 `SLOWMODE_WAIT_X`。 +- 已实现 `channels.toggleParticipantsHidden`:creator/具备 `ban_users` 的管理员可切换 `channels.participants_hidden`,返回/推送 `updateChannel`;TDesktop 通过 `ChannelFull.participants_hidden` 恢复隐藏成员 UI,普通成员的 participants 列表只保留 aggregate count,read participants 返回空,避免用已读详情绕过隐藏成员设置。 +- 已实现 `channels.getAdminLog`:持久化 channel-scoped admin log,支持 actor/filter/query/max_id/min_id/limit 的有界查询,并返回真实 TL admin log actions;当前覆盖 metadata、成员权限、pin、send/edit/delete,forum/group_call/subscription 等长尾 action 留待对应业务模型。 +- 已注册 `stats.*` 统计域兼容入口:TDesktop 统计页会调用 `stats.getBroadcastStats/getMegagroupStats/getMessageStats/getMessagePublicForwards/loadAsyncGraph`,当前先做 access_hash、admin、类型、msg_id、limit/offset 边界校验并返回可解析空图表或空 public forwards;真实聚合表、预计算窗口和异步 graph token 后续单独设计,避免在首版频道模块里引入无界统计扫描。 +- 已注册 `premium.*` boost 域兼容入口:TDesktop 频道统计页、颜色/权限入口和 boost 弹窗会调用 `premium.getBoostsStatus/getBoostsList/getMyBoosts/applyBoost/getUserBoosts`;当前仅做 channel/access_hash/admin/user/limit/offset/slots 边界校验并返回零状态或空列表,不落库、不生成虚假 boost 关系。真实 boost slots、giveaway、premium audience 聚合后续独立建模。 +- 已实现 invite management:TDesktop 管理 invite links、admins with invites、importers、join requests 时会调用 `messages.getExportedChatInvites/getExportedChatInvite/editExportedChatInvite/deleteExportedChatInvite/deleteRevokedExportedChatInvites/getAdminsWithInvites/getChatInviteImporters/hideChatJoinRequest/hideAllChatJoinRequests`;当前已持久化 invite 列表、detail/edit/revoke/delete、按 admin 统计、importer/read model、`request_needed` pending join request、单个 approve/dismiss 与 bounded `hideAll`。`getExportedChatInvites` 使用 `offset_date + offset_link` seek,`getChatInviteImporters` limit cap=100,`hideAll` 单批最多 1000,避免按客户端超大参数生成无界更新;subscription/chatlist/paid invite 与 join-request service notification 仍留后续。 +- 已实现 public join settings:`channels.toggleJoinToSend`/`channels.toggleJoinRequest` 持久化 `channels.join_to_send/join_request` 并返回带 flags 28/29 的 `tg.Channel`;`join_request` 仅 public megagroup 可开启,非成员 `channels.joinChannel` 会写入 `channel_invite_importers(invite_id=0, requested=true)` 并返回 `INVITE_REQUEST_SENT`,之后可通过 `messages.getChatInviteImporters(requested=true)` 查询和 `messages.hideChatJoinRequest` approve/dismiss。admin 侧 `channels.getFullChannel` 会回填 `requests_pending/recent_requesters`,request-needed import/public join 以及 approve/dismiss 会向有界管理员集合推 `updatePendingJoinRequests`;该状态不写入无界 durable update log,离线管理员重新打开 full channel 时补偿。 +- 已修正当前普通成员的 `channels.getParticipant(inputPeerSelf)` 与 participants 列表 TL constructor:TDesktop `requestSelf` 期望普通本人是 `channelParticipantSelf`,creator/admin 仍分别返回 creator/admin self 语义;避免客户端记录 `Got self regular participant`,且不改变 domain/store 成员模型。 +- PG channel pts 已补失败保护:现有 channel 的 send/edit/deleteHistory/deleteMessages/pin 和成员/标题服务消息在事务失败或权限失败后写 `noop` 占位,避免 Redis 分配过的 channel pts 形成 TDesktop `PtsWaiter` 永久 gap。 +- 2026-06-01 双 TDesktop 在线/离线实测已覆盖超级群发送、reply、forward、edit 与离线恢复:Bob 对 Alice 消息 reply,Bob 将回复 forward 回同一超级群,随后编辑该频道消息;Bob 关闭期间 Alice 发送 channel 消息,Bob 重启后 dialog 未读数=1,打开群后看到离线消息;Alice/Bob 双窗口均实时显示,server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic。删除、清历史、踢/禁言仍需用户行动时确认后做 UI 实测。 +- 2026-06-02 Computer Use 双 TDesktop 复测已覆盖当前非破坏性频道/超级群 UI 路径:Alice/Bob 在 `E2E Super 0307` 中双向发送 `cu-round-alice-*` / `cu-round-bob-*` 并实时互见,成员栏显示 2 members/online,Alice 全局搜索 Bob 的新消息返回 `Found 1 message`;Alice 在 `CU Public Search 44238` 频道发布 `cu-channel-round-*` 后频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,客户端本轮无新增 `Bad participant` / `Got self regular participant`;清空搜索框产生的 `SEARCH_QUERY_EMPTY` 保持可解释。 +- 2026-06-02 09:55 Computer Use reaction/sticker 启动复测:Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`,互发 `cu-stubfix-alice-*` / `cu-stubfix-bob-*` 后双方消息列表和左侧 preview 均可见;打开 emoji 面板触发 `messages.getAvailableReactions` / `messages.getStickerSet` / `messages.getAvailableEffects`,server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前 `log.txt` 无新增 `Unexpected messages.stickerSetNotModified` / participant 告警。右键消息菜单可打开但本轮未显示 reaction 快捷项,真实 reaction sticker animations/custom UI 仍留后续。 +- 2026-06-02 10:10 Computer Use 主干路径复测:Debug/Alice 与 DebugBob/Bob 在 `E2E Super 0307` 中双向在线发送 `cu-continue-*`,消息流与左侧 preview 双端可见,未读通过 `channels.readHistory` 清除;Alice 在 `CU Public Search 44238` 发布 `cu-channel-continue-*`,频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error;DebugBob 主日志仅有 08:05 前旧 sticker/participant 噪声。 +- 2026-06-02 10:49 Computer Use unread reaction 回归冒烟:本轮 server 启动后自动应用 migration 0051,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Alice 发送 `cu-unreadrx-alice-1780368577183` 后 Bob 左侧 preview 出现新消息/未读 badge,Bob 发送 `cu-super-bob-1780356886095` 后 Alice 消息流实时显示。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新 `DebugLogs/log_10_45.txt` 无新增 API error/Unexpected;当前 TDesktop reaction 快捷入口仍未稳定显示,未读 reaction 语义由 `internal/rpc` router 测试覆盖。 +- 2026-06-02 11:20 Computer Use recent reactions 相关冒烟:本轮 server 启动后自动应用 migration 0052,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-recent-bob-1780370400982` 后双方消息流和左侧 preview 可见,Bob 打开 emoji 面板后 Emoji/Stickers/GIFs 面板正常渲染。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新日志无新增 API error/Unexpected;TDesktop 本轮未重新请求 `messages.getRecentReactions`,recent get/clear 的 hash/notModified/clear 后空列表语义由 `internal/rpc` router 测试覆盖。 +- 2026-06-02 11:36 Computer Use top reactions 回归冒烟:本轮 server 启动后自动应用 migration 0053,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-toprx-bob-1780371301984` 后双方消息流和左侧 preview 可见,Bob/Alice emoji 面板正常渲染,Bob 右键最新消息菜单可打开。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error,DebugBob 仅有 08:05 前旧 sticker/participant 噪声;本轮 TDesktop 没重新请求 `messages.getTopReactions`,top 排序、静态 catalog 兜底、hash/notModified 语义由 `internal/rpc` router 测试覆盖。 +- 2026-06-02 12:23 Computer Use saved reaction tag title 回归冒烟:本轮 server 启动后自动应用 migration 0054,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-savedtags-final-bob-1780374193558` 后另一客户端消息流可见并触发 channel difference。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic/error;本轮 TDesktop 未直接触发 `messages.getSavedReactionTags/updateSavedReactionTag`,账号级标题持久化、hash/notModified 与 `updateSavedReactionTags` 推送语义由 `internal/rpc` router 测试覆盖。 +- 2026-06-03 unread reaction / 打开历史卡顿回归:Alice Debug 中 `AAAA` 的 unread reaction 由 Bob 对 Alice 自己消息的 reaction 产生;修复后 `channels.readMessageContents` 按 visible message ids 清理该作者视角 unread reaction 并回推 `updateMessageReactions`。Computer Use 验证 Alice 点开 `AAAA` 后 PG `channel_dialogs.unread_reactions_count=0`、`channel_message_reactions.unread=false`;重启 Alice 后 `AAAA` 列表无红色 unread reaction 图标;Bob DebugBob 同时启动并可打开 `AAAA`。同轮修复 `channels.getSendAs` 内重复 `GetChannel`,打开历史时 `messages.getHistory` 为十毫秒级,日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg`。 +- `channels.editPhoto/setStickers/setEmojiStickers/reorderUsernames/toggleUsername/deactivateAllUsernames` 仍为兼容 stub:头像删除只做 no-op channel state 响应,真实上传/已有照片返回 `PHOTO_INVALID`;群贴纸/custom emoji 只允许 megagroup `inputStickerSetEmpty` no-op 清空,非空 sticker set 返回 `STICKERSET_INVALID`;Fragment/多 username 入口不修改主 username,避免数据破坏。 +- Layer 225 `OnChannels*` dispatcher 与 legacy chat/channel 入口已全部显式注册;未进入首批真实业务的入口均为参数有界、权限校验或空结果 stub,避免 TDesktop 实测出现 `NOT_IMPLEMENTED`/unknown RPC。 + +## Permissions + +首批权限模型: + +- creator:全权限。 +- admin:按 `AdminRights` 判断 invite/delete/edit/bans/change_info/post_messages。 +- member:megagroup 可发消息,broadcast 默认不可发。 +- left/kicked/banned:不可读写;banned rights 可限制 `send_messages/view_messages/invite_users/pin_messages/change_info`。 +- default banned rights:channel 级默认禁言,member 无显式权限时继承。 + +TDesktop 最小入口: + +- `channels.getFullChannel` 必须返回当前用户 admin/banned/default rights、participants/read/unread/notify/invite link/pts。 +- `channels.getParticipants` 支持 recent/admins/search/kicked/banned/bots/contacts/mentions 的最小分页,limit cap 200;banned/kicked 只向 admin 暴露。 +- `channels.editAdmin/editBanned/editTitle/deleteChannel/updateUsername/toggleSignatures/togglePreHistoryHidden/toggleParticipantsHidden/toggleForum/toggleAntiSpam/toggleSlowMode/updateColor/updateEmojiStatus/toggleViewForumAsMessages/getAdminLog/exportMessageLink` 已有真实权限/可见性校验;`channels.editPhoto/setStickers/setEmojiStickers/reorderUsernames/toggleUsername/deactivateAllUsernames` 暂为兼容 stub,后续接 files/Fragment username/stickers/custom-emoji/boost gating 等模型。 + +## Online Push + +在线推送分两类: + +- user 维度:dialog/folder/pin/notify/read state 等 owner 状态,继续写 `user_update_events + dispatch_outbox`。 +- channel 维度:消息/edit/delete/pin/member service message,写 `channel_update_events`,在线 fanout 给 active channel sessions。 + +为避免大频道 O(n) 离线写扩散: + +- 不把每条 channel message 写入所有成员的 `dispatch_outbox`。 +- 参考实现 的 status online sessions / `MutableChannelOnlinePush` 边界,当前实现先从 `SessionManager.OnlineUserIDs` 取有界在线 user 快照,再用 `channel_members(channel_id,user_id)` 过滤 active member,最后合并操作显式 recipient(例如 leave/kick 后的本人)。这样避免“大频道取前 500 个成员却漏掉真实在线用户”,也避免每条消息按全体成员写扩散;后续可升级为 `channel_id -> active sessions` 订阅索引。 +- `messages.setTyping(InputPeerChannel)` 是纯瞬时在线推送:TDesktop topic 输入框带来的 `top_msg_id` 只在 `updateChannelUserTyping` 中透传给在线成员,先做 `0..MaxMessageBoxID` 边界校验,非法返回 `MSG_ID_INVALID`,避免异常 topic id 扩散到其它 session。 +- 离线成员不接 outbox;重新打开会话或 gap 时通过 `updates.getChannelDifference` 补齐。 + +## Safety / Performance + +- 所有 list/id/vector 参数必须 cap:participants limit 200、offset 最大 10000、participants/admin-log 搜索 q 最大 128 字符、history/search/searchPosts q 最大 256 字符、searchPosts limit 50、paid message stars 10000、boost unrestrict 0..8、history 100、get ids 100、delete ids 1000、forward ids 100、channel difference 100。 +- 所有实时 fanout 必须有硬上限:首版 `MaxChannelRealtimeFanout=500`,不能因为一个大频道消息或 typing 动作把全体成员读入内存。 +- channel history/search 只返回有界页;PG 用 `limit+1` 判断是否还有下一页,不为 UI 计数执行 `count(*) over()` 或全量 count。 +- 禁止按客户端传入 `int64 max/max_id` 构造数组;deleteHistory 必须批量 seek。 +- `channel_messages` 和 `channel_update_events` 查询必须带 `channel_id` 分区键。 +- `channel_dialogs` 查询必须带 `user_id` 分区键。 +- 从 user 入口访问 channel membership 时不得直接扫 `channel_members WHERE user_id=...`;该表按 `channel_id` 分区,实测会展开 64 个分区。必须走 `user_channel_member_index`,或先取 bounded channel_id 列表再用 `channel_id/id = ANY($1)` 二段查询。 +- 对 `channels` / `channel_members` 这类分区表,SQL 内动态 join key 不能保证分区裁剪;`index -> partitioned table` 单条 join 实测仍会展开全部分区。 +- 单条消息文本沿用 4096 code point 限制,entities cap 256。 +- 大频道 unread 不能在应用层逐成员循环;当前兼容实现只允许数据库 set-based 缓存推进并保留 read watermark 可重算路径,后续高规模频道需拆后台 fanout 或更强惰性缓存。 +- 所有 channel pts 分配失败/事务失败要有 noop gap 补偿与指标。 +- 压测目标:单机 1000 msg/s megagroup send p99 < 150ms;`getChannelDifference` p99 < 100ms;10 万成员频道单条 broadcast 不产生 O(n) 数据库写。 + +## Implementation Plan + +1. Domain/store:补 `PeerTypeChannel`、channel/member/message/dialog/update DTO 与 store interfaces。 +2. Migration:新增分区表、索引、Redis allocator source。 +3. App:新增 `internal/app/channels`,封装创建、成员、发送、history、read、difference。 +4. RPC:注册 `channels.*`、`messages.createChat`、`updates.getChannelDifference`,给 messages 现有 RPC 增加 channel peer 分支。 +5. TL 转换:只在 `internal/rpc` 实现 domain ↔ `tg.Channel/ChannelFull/Message/UpdateNewChannelMessage`。 +6. Online push:新增 channel active fanout,user dialog 状态继续用 reliable outbox。 +7. Tests:domain/store/app/rpc 单元测试,PG/Redis 集成测试覆盖 pts 连续、差量、deleteHistory 分批、权限。 +8. TDesktop:双端/多端验证创建超级群、频道、邀请、退出、在线/离线消息、history、read、edit/delete、forward/reply、typing、dialog/filter/归档展示。 + +## 媒体与频道头像(2026-06-02) + +- `channel_messages` 增 `media` JSONB 快照列(与私聊同构),`SendChannelMessage` 透传 `req.Media`,讨论组联动消息一并带 media;`scanChannel*`/`channelMessageColumns` 统一加 `media::text`,所有 history/getMessages/replies/difference 读取路径自动带出。`tgChannelMessage` 在 media 非空时 `SetMedia`。放宽 `channel_messages` content CHECK 为 `body<>'' OR action<>'{}' OR media<>'{}'`。 +- 频道头像:`channels` 表反范式 `photo_id/photo_dc_id/photo_stripped`(migration `0059`),`channelColumns` + 全部 5 处 channel scanner 同步;`channels.editPhoto`/`messages.editChatPhoto` 经 `resolveInputChatPhoto` 上传或引用照片,admin(change_info)校验后落列并返回 `updateChannel` + 推 channel state;`tgChannel.Photo`(ChatPhoto)/`tgChannelFull.ChatPhoto` 渲染真实头像(`getFile` 按 `photo::` 解析忽略 access_hash,合成 a/c 尺寸即可下载)。 +- 2026-06-03 接手审计修正:`SendChannelMessage` 的空内容校验已把 `req.Media` 纳入,允许超级群/频道发送无 caption 的 photo/document/sticker;新增 PG 集成测试覆盖 channel media 经 `ListChannelDifference` 恢复,防止离线 TDesktop 拉差分丢媒体。 +- 范围外:in-history `MessageActionChatEditPhoto` service 消息留 todo。 \ No newline at end of file diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md new file mode 100644 index 00000000..91c4f1cd --- /dev/null +++ b/docs/compatibility-matrix.md @@ -0,0 +1,419 @@ +# Telegram Desktop Compatibility Matrix + +Client: Telegram Desktop dev 9caf32dffc (describe v6.8.4-15-g9caf32dffc, pinned baseline) +Server: telesrv +gotd/td: v0.144.0 +Layer: 225 +Date: 2026-06-02 + +status 取值:done(真实实现) / stub(兼容响应) / todo(已发现未实现) / blocked(需先补协议或数据结构) + +> 连接层(mtprotoedge)M0–M5 已完成。**登录注册闭环已打通**: +> auth.sendCode/signIn/signUp/logOut + users.getUsers + updates.getState 真实实现, +> telegram.Client 端到端验证(sendCode→signIn→signUp→getUsers→启动后空账号 RPC,TestLoginRegisterFlow)。 +> 启动必经的空账号 RPC 已从硬编码 stub 推进到 PG-backed 业务查询: +> app_configs / countries / account_passwords / temp_auth_key_bindings / +> update_states / contacts / dialogs / langpack,语言包 seed 来自导出的 TDesktop 数据。 +> TDesktop 进入空账号主界面后的异步预取 RPC 已补第一阶段空响应,避免后台重试噪声。 +> PG 集成测试覆盖 AuthKeyStore 与业务 store 往返(TestBusinessStoresRoundTrip)。 +> active session 已缓存 raw auth_key_id、业务 auth_key_id、session_id、user_id;router 入口只做一次 temp→perm 与 auth_key→user_id 解析,业务服务按 user_id 查询;普通注册 user_id 从 1780243200 起递增。 +> 二阶段私聊文本消息链路已落地:`messages.sendMessage` 事务内写 `private_messages` / 双端 `message_boxes` / `dialogs` / `user_update_events` / `dispatch_outbox`,`pts` 与 owner 视角 `box_id` 由 Redis 原子计数并可从 PG durable log 恢复;outbox worker 批量推送在线 session,链路指标覆盖发送、限流、outbox claim/deliver/fail。 +> 联系人与 dialog owner 视角已补齐:`contacts.addContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses/search`、dialog pinned/manual unread/peer settings、`messages.*DialogFilter*` 与 `folders.editPeerFolders` 均真实落库;`contacts` 保存每个 owner 对同一 user 的独立姓名/电话/备注,`dialogs` 保存置顶顺序、manual unread、action bar 隐藏态与 archive folder_id,自定义 filters 独立存 `dialog_filters`。 +> username 生命周期已补齐:注册仍按 Telegram 语义只创建手机号/姓名账号,后续通过 `account.checkUsername/updateUsername` 设置或清除主 username;PG 以 `lower(username)` partial unique index 防并发占用,`contacts.resolveUsername/resolvePhone` 可用于 TDesktop 搜索/链接解析。 +> 注册资料闭环已补齐:TDesktop 手机号验证码后进入 sign-up 页,`auth.signUp` 携带 first_name/last_name 完成建号;主界面 Settings/Profile 的姓名与 bio 变更走 `account.updateProfile`,写入 `users.first_name/last_name/about` 并向其它设备可靠推送 `updateUserName`。 +> 联系人 reset、dialog pinned/order/manual unread/peer settings、dialog filters/order/reload、folder peers 已写入账号级 durable update log,并同事务写入 `dispatch_outbox`;离线设备可通过 `updates.getDifference` 恢复这些状态变化,在线其它端由 outbox worker 可靠投递且排除当前 session。 +> PG 集成测试覆盖“两个 owner 给同一联系人不同备注、改备注 hash 变化、互相关系同步、删除反向 mutual 清理、dialog 列表按当前 owner 备注展示、置顶重排、manual unread、隐藏 peer settings bar、归档 folder 与自定义 filter”。 +> 2026-05-31 TDesktop Debug 客户端已用独立 workdir 启动并连到当前 server,日志证明 key exchange、`auth.bindTempAuthKey`、启动配置 RPC、搜索用户、打开私聊、发送消息、`messages.getDialogFilters` 与自定义 filter 左侧分组展示均可走通;本轮 Settings/Folders 搜索页额外触发的 account/contacts/messages 预取 RPC 已补第一阶段兼容 stub。 +> 2026-05-31 双 TDesktop 在线/离线复测已覆盖私聊 read/edit/delete:在线 read 触发 sender `Seen`,在线 edit/delete 两端同步;Bob 离线时 Alice send+edit,Bob 重启拿到最终文本并可回读,Bob 离线期间 Alice revoke delete,Bob 重启后会话和消息均清除。联调中补齐 `messages.getOutboxReadDate`,并修正 MTProto content-related service request 口径(`ping*` / `get_future_salts` / `msgs_state_req` / `msg_resend_req` / `destroy_*` 需要 odd seq_no 与 msgs_ack),23:51 后复跑日志无新增 bad_msg / NOT_IMPLEMENTED。 +> 2026-06-01 双 TDesktop 在线/离线复测已覆盖超级群普通发送、目标会话 reply、forward 到同一超级群、频道消息编辑与离线恢复:Alice/Bob 双窗口均能实时看到对应 `UpdateNewChannelMessage` / `UpdateEditChannelMessage` 结果;Bob 关闭期间 Alice 发送 channel 消息,Bob 重启后 dialog 未读数=1,打开群后通过恢复路径看到离线消息;server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic。删除、清历史、踢/禁言属于破坏性 UI 操作,需行动时确认后再点客户端确认。 +> 2026-06-02 Computer Use 双 TDesktop 复测已覆盖超级群双向在线发送、成员栏显示、全局搜索命中 channel/supergroup 消息与公开频道广播发帖:Alice/Bob 在 `E2E Super 0307` 中互发 `cu-round-alice-*` / `cu-round-bob-*` 并实时显示,Alice 搜索 Bob 消息返回 `Found 1 message`,`CU Public Search 44238` 频道发 `cu-channel-round-*` 后消息流与左侧预览同步更新;server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,客户端本轮无新增 participant 告警。清空搜索框触发的 `SEARCH_QUERY_EMPTY` 保持可解释。 +> 2026-06-02 09:55 Computer Use 双 TDesktop 复测补充覆盖 reaction/sticker 启动依赖:Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`,互发 `cu-stubfix-alice-*` / `cu-stubfix-bob-*` 并在双方消息列表与左侧 preview 可见;打开 emoji 面板触发 `messages.getAvailableReactions` / `messages.getStickerSet` / `messages.getAvailableEffects`,server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前 `log.txt` 无新增 `Unexpected messages.stickerSetNotModified` / participant 告警。右键消息菜单可打开但未显示 reaction 快捷项,真实 reaction sticker animations/custom UI 留后续。 +> 2026-06-02 10:10 Computer Use 双 TDesktop 复测再次覆盖当前主干频道/超级群路径:Debug/Alice 与 DebugBob/Bob 在 `E2E Super 0307` 中双向在线发送 `cu-continue-*`,消息流与左侧 preview 双端可见,`channels.readHistory` 清除未读;Alice 在 `CU Public Search 44238` 发布 `cu-channel-continue-*`,频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error;DebugBob 主日志仅有 08:05 前旧 sticker/participant 噪声。 +> 2026-06-02 11:20 Computer Use 双 TDesktop recent reactions 相关冒烟:新 server 应用 migration 0052 后,Debug/Alice 与 DebugBob/Bob 同时连接 `E2E Super 0307`;Bob 发送 `cu-recent-bob-1780370400982`,双方消息流与左侧 preview 可见;Bob 打开 emoji 面板,Emoji/Stickers/GIFs 面板正常渲染。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新日志无新增 API error/Unexpected;本轮 TDesktop 受客户端缓存影响未观察到重新发送 `messages.getRecentReactions`,get/clear 的 hash/notModified/clear 后空列表语义由 `internal/rpc` router 测试覆盖。 +> 2026-06-02 11:36 Computer Use 双 TDesktop top reactions 回归:新 server 应用 migration 0053 后,Debug/Alice 与 DebugBob/Bob 同时连接 `E2E Super 0307`;Bob 发送 `cu-toprx-bob-1780371301984` 后双方消息流与左侧 preview 可见,emoji 面板保持 Emoji/Stickers/GIFs 正常渲染,右键消息菜单可打开。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error,DebugBob 仅有 08:05 前旧 sticker/participant 噪声;TDesktop 本轮命中本地 reaction 缓存未重新请求 `messages.getTopReactions`,账号 top 排序、catalog 兜底与 hash/notModified 语义由 `internal/rpc` router 测试覆盖。 +> 2026-06-02 媒体闭环已落地:migration `0057_media`(upload_parts/file_blobs/documents/photos/sticker_sets/available_reactions/profile_photos + 消息表 media JSONB 列)/`0058`(appConfig reactions_default)/`0059`(channel photo 列);新增 `upload.saveFilePart/saveBigFilePart/getFile/getFileHashes`、`photos.uploadProfilePhoto/updateProfilePhoto/getUserPhotos/deletePhotos`,本地磁盘 blob backend;`getAvailableReactions/getStickerSet/getAllStickers/getEmojiStickers/getCustomEmojiDocuments` 返回真实 document(启动从 `TELESRV_STICKER_SEED_DIR` 幂等 seed 74 reactions/24 sets/~1.5k docs/~3k blob 索引);`messages.uploadMedia/sendMedia/sendMultiMedia` 支持 photo/document/sticker 主路径(私聊 + channel,复用 pts/box/outbox/在线推送/离线 difference);User/Channel/Full 渲染真实头像。**自动化验证**:`go test ./...`(含 PG 集成测试 167s 全过,覆盖媒体列 + 频道 photo 列扫描)、server 启动 migrate+seed 无错误、`internal/rpc` 新增贴纸/图片 sendMedia 端到端单测全过。**待人工**:双 TDesktop 测私聊/超级群/频道的图片/文件/贴纸消息、reaction 面板真实动画、头像、在线 fanout 与离线 difference,确认日志无新增 `NOT_IMPLEMENTED`/`Unhandled RPC`/`bad_msg`/panic/API error。范围外 poll/todo/scheduled/paid/CDN/sticker 管理(install/uninstall/reorder)/grouped_id 相册聚合继续显式 stub/todo。 +> 2026-06-03 媒体接手审计补充:全量 `go test ./...`(PG/Redis env-gated,169s)通过,`TestSeedMediaFromRealExport -count=1` 确认 `TELESRV_STICKER_SEED_DIR` seed 为 74 reactions / 11 sets(maxRegularSets=2)/ 1355 docs / 2682 blobs。参考 TDesktop `image_location_factory.cpp` 发现 `StickerSet.thumbs` 的可下载 `PhotoSize` 会触发 `inputStickerSetThumb`,而当前导出目录仅有 set_cover `PhotoPathSize` SVG、无可服务 raster blob;已在 seed 与 TL 转换层过滤 sticker set cover 的 downloadable thumb,只保留非下载占位,避免 TDesktop 生成不可满足的 cover 下载请求。另修复 channel/supergroup 仅媒体无 caption 被 `SendChannelMessage` 误判空消息的问题,并新增 PG 集成测试覆盖 channel media `updates.getChannelDifference` 恢复。**仍待人工**:双 TDesktop 按本轮目标完整验证图片/文件/贴纸、头像、reaction 动画下载与在线/离线同步日志。 +> 2026-06-03 头像/资料路径审计补充:Alice 上传用户头像时 TDesktop 额外触发 `account.getDefaultGroupPhotoEmojis`、`account.getConnectedBots`、`stories.getAlbums`;群/频道头像编辑页继续触发 `messages.getEmojiProfilePhotoGroups`。参考 TDesktop 消费点、gotd Layer225 返回类型、参考实现 空集合 handler、参考实现 emoji-categories DAO/connected-bots/custom-emoji 空集合实现后,当前按有界兼容 stub 返回空列表,避免资料页/头像编辑后台 `NOT_IMPLEMENTED` 与 business/stories/custom-emoji 模型扩张。自动化验证:`go test ./internal/compat/tdesktop ./internal/rpc -run "Test(EmojiProfilePhotoGroups|TDesktopStartupRPCsEncode)" -count=1`、`go test ./...`、`TestSeedMediaFromRealExport -count=1` 均通过,migration 状态 `59|f`;双 TDesktop 群/频道头像 UI 回归仍待 Computer Use 恢复后补证据。 +> 2026-06-03 reaction 全路径审计与双 TDesktop 回归:参考 TDesktop `api_reactions_notify_settings.cpp`、`data_message_reactions.cpp`、`api_updates.cpp`,参考实现 reaction 设置/聚合/更新语义,以及 gotd Layer225 `messages.sendReaction` / `updateMessageReactions` / paid/privacy/participant/stories 入口后,补齐私聊 reaction durable event 与账号设置/paid privacy/participant 删除/stories 兼容入口。自动化验证:`go test ./internal/app/updates ./internal/rpc -run "Test(GetState|GetDifference|Record|UpdatesGetState|UpdatesDifferenceIncludesReaction|MessagesSendReactionPrivate)" -count=1`、`go test ./... -count=1` 通过。Computer Use 双端实测仅使用 `patched Telegram.exe -workdir .tdata-alice`(Alice)与 `patched Telegram.exe -workdir .tdata-bob`(Bob):私聊在线 reaction Alice 实时同步;Alice 离线后 Bob 改 reaction,Alice 重启经 `updates.getDifference` 恢复;超级群 `AAAA` 右键 reaction 快捷栏不再空白,在线同步正常;Alice 离线后 Bob 改群消息 reaction,Alice 重启打开群后经 `messages.getHistory` / `updates.getChannelDifference` / `messages.getMessagesReactions` 恢复。最终 server `bin\\telesrv.exe` 监听 2398,日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic / internal error。 +> 2026-06-03 unread reaction / 打开历史卡顿修复回归:参考 TDesktop `UnreadThings::requestReactions`、`History::clearUnreadReactionsFor`、`SendAsPeers::request`,参考实现 `readMessageContents/readReactions` 重算 unread reaction 语义,参考实现 send-as/reaction read model 与 gotd Layer225 `channels.readMessageContents` / `messages.getUnreadReactions` / `channels.getSendAs` 后,修复 channel/supergroup 打开会话只读 visible messages、不清作者视角 unread reaction 的问题;并去掉 `channels.getSendAs` handler 内 access_hash 校验 + 后续取 channel 的重复 `GetChannel`。验证:Alice Debug 打开 `AAAA` 后 `channel_dialogs.unread_reactions_count` 从 1 变 0,`channel_message_reactions.unread` 从 true 变 false;重启 Alice 后 `AAAA` 会话列表不再出现红色 unread reaction 图标;DebugBob/Bob 同时启动并打开 `AAAA`,history 正常展开。最新 server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg`,Alice 打开 `AAAA` 的 `messages.getHistory` 为 10.8ms/5.3ms,Bob 为 34.0ms/15.9ms,`channels.getSendAs` 去重后主路径约 10-27ms(Alice 一条 72ms 属独立预取调用,未伴随 history 慢查询)。 +> 2026-06-03 sticker/reaction 启动资源检查修复:参考 TDesktop `api_hash.cpp` / `data_stickers.cpp` 后,`messages.getAllStickers` / `messages.getEmojiStickers` 的 catalog hash 改为客户端同公式(按 set 顺序仅 `HashUpdate(set.hash)`,跳过 archived/default set),避免 Debug/DebugBob 日志反复出现 `received stickers hash ... while counted hash ...` 并导致重启后 full catalog 失效重拉;`messages.getAvailableReactions` 继续用服务端稳定 hash,但统一到同一 hash helper,命中时返回 `messages.availableReactionsNotModified`。自动化验证:`go test ./internal/rpc -run "TestSticker|TestMessagesGetAllStickers|TestMessagesGetAvailableReactions" -count=1`、`go test ./internal/rpc -count=1`、`go test ./internal/app/files -count=1`、`go test ./... -count=1` 均通过;双 TDesktop Debug/Alice + DebugBob/Bob 连新 server 重启两轮,server 日志出现 `messages.getAvailableReactions/getStickerSet/getStickers` 与一次成功 `upload.getFile`,无 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID`,客户端新日志无 `received stickers hash ... counted ...` / `Unexpected messages.stickerSetNotModified`。 +> 2026-06-03 sticker/reaction 历史首开资源冷路径修复:参考 TDesktop `ApiWrap::requestStickerSets`、`Data::Stickers::somethingReceived`、`DocumentMedia::checkStickerSmall` 后确认,打开历史时若 sticker set 处于 `NotLoaded` 或文档缩略图/动画首次解码,客户端会拉完整 `messages.getStickerSet(hash=0)` 并依赖 128px document thumb 先显示。Files service 新增 `object_key→小 blob bytes` LRU(单项 ≤256KB,总 64MB)和完整 sticker set cache,server 启动 `WarmCaches` 从已 seed 的 sticker/reaction 元数据预热 set、document 与可下载缩略图。验证:`go test ./internal/app/files -count=1`、`go test ./internal/rpc -count=1`、`go test ./... -count=1` 均通过;`bin\\telesrv.exe` 启动日志预热 `sticker_sets=24 documents=1554 blobs=2809`;Computer Use 启动 Debug/Alice 与 DebugBob/Bob 并点击含 sticker 历史会话,`messages.getStickerSet` 从冷路径约 18-24ms 降到 0-1.7ms,点击历史 `messages.getHistory` 为 5-16ms,未再触发 `upload.getFile` 冷下载;server/client 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID` / `Unexpected messages.stickerSetNotModified`。 +> 2026-06-03 sticker installed-cache 语义修复:继续对照 TDesktop `Storage::Account::writeInstalledStickers` / `Data::ParseStickersSetFlags` 后确认,`installed_date` 会让 set 进入 Installed;普通 installed stickers 集若仍处于 `NotLoaded`,客户端会中止本地 installed stickers 写入。telesrv seed 过去把 `InputStickerSetAnimatedEmoji` / dice / generic animations 等 system set 也标成 installed,导致系统资源混入普通 installed stickers 缓存路径。现 migration `0066_system_sticker_sets_not_installed` 修复既有库,并改 seed:`set_kind=system` 仅作为按 input 系统 key 解析的内置资源,不再声明为 installed。 +> 2026-06-03 sticker 静态缩略图元数据修复:继续读 TDesktop `history_view_sticker.cpp` / `data_document_media.cpp` / `data_cloud_file.cpp` 后确认,历史页若看到 document `PhotoPathSize` 会把它当成 vector placeholder,`dataMediaCreated()` 因 `thumbnailPath()` 非空跳过 `thumbnailWanted()`,从而挡住同时存在的真实 raster thumb。seed 现将 ≤32KB 可下载 document thumb 转为 `PhotoCachedSize` 并写入 image cache 所需 bytes;当 document 已有 raster/default/cached/progressive thumb 时丢弃 `PhotoPathSize`,仅没有 raster 的 set cover 继续保留 path 占位;thumb blob MIME 由字节魔数写入,`upload.getFile` 也优先按魔数返回 `storage.fileWebp`,兼容旧库误标 `image/jpeg`。验证:`go test ./internal/app/files -run "TestSeedMediaFromRealExport|TestSeedPreferRasterDocumentThumbsDropsPathWhenRasterExists|TestDocumentsNeedInlineCachedThumbsDetectsPathWithRaster|TestDocumentsNeedInlineCachedThumbsDetectsStaleMime" -count=1`、`go test ./internal/rpc -run "TestStorageFileType" -count=1`、`go test ./... -count=1` 通过;新 server 启动 repair 后 PG `documents.thumbs` kind 分布为 `cached=2313`,两条样例 `5415908822013185960/5381935901284774004` 均仅 `cached/m`,`file_blobs` document thumb MIME 为 `image/webp=2313`;服务进程 PID 53892 监听 2398。 +> 2026-06-03 sticker 文档身份修复:继续对比 TDesktop `data_session.cpp` / `data_document.cpp` / `history_view_sticker.cpp` 后确认,TDesktop 以 `document_id` 复用 `DocumentData`,且 `DocumentData::updateThumbnails()` 不会清掉旧的 inline/path thumbnail 状态;一旦服务端把外部导出 document id 当成本服资源主键,旧 Debug tdata 中同 id 的污染对象会持续影响历史页渲染。telesrv 现把导出 JSON/文件名中的 document id 只作为 seed source id,在 `internal/app/files` 导入阶段归一为 telesrv-owned storage id;RPC、`InputDocument`、`inputDocumentFileLocation`、`getCustomEmojiDocuments`、channel custom emoji status/reaction/color 均直接使用同一个服务端 document id,不再做边界映射。migration `0067_seed_document_id_namespace` 同步修复既有开发库中的 documents/file_blobs/sticker_sets/available_reactions/message media/channel appearance 引用。document thumb 在 store 中可保留 cached bytes,但 RPC 对 document 统一暴露 downloadable `photoSize m`,避免 `PhotoCachedSize` 与本地旧 cache 组合出不可替换状态。验证:`go test ./internal/app/files -run TestSeedMediaFromRealExport -count=1 -v`、`go test ./... -count=1` 通过;server `bin\\telesrv.exe` 监听 2398,migration 状态 `67|false`,PG `documents/file_blobs/message media/available_reactions/sticker_sets` 均无 `>4e18` 外部 document id;Computer Use 重启 Debug/Alice 与 DebugBob/Bob 后分别打开 Bob B/Alice A,250ms 截图已显示 sticker,server 与两端客户端日志无 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID` / `API Error` / sticker hash 异常。 + +## Transport / MTProto 服务消息 + +| 消息 | status | behavior | note | +|---|---|---|---| +| transport codec 协商 | done | real | intermediate/abridged/full 自动探测 | +| TCP obfuscation | done | real | TDesktop tcpo_only:先解 64 字节 obfuscated 前缀,再探测 abridged codec | +| TDesktop reconnect fake req_pq | done | real | 既有 auth key 重连时先 fake req_pq 再发加密帧;server 回 resPQ 后 replay 后续加密帧,不断开 session | +| 密钥交换 (req_pq…dh_gen_ok) | done | real | exchange.Server,auth key 落 AuthKeyStore | +| 加密消息收发 | done | real | crypto.ServerCipher + container/gzip | +| new_session_created | done | real | 连接首个加密消息后发送 | +| msgs_ack | done | real | content-related 消息统一确认;口径对齐 TDesktop `SerializedRequest::needAck()`,`ping*` / `get_future_salts` / `msgs_state_req` / `msg_resend_req` / `destroy_*` 都按 content-related 处理 | +| msgs_state_req | done | real-minimal | 连接层按当前连接已见 client msg_id 返回 msgs_state_info,并作为 content-related request 回 ack,避免 TDesktop 重连状态探测落到 RPC fallback;完整 out queue 状态留后续 session 状态机扩展 | +| msg_resend_req | done | real-minimal | 连接层按当前连接已见 client msg_id 兜底回复 msgs_state_info,并作为 content-related request 回 ack;后续完整 outgoing queue 后再补真实重发 | +| rpc_drop_answer | done | real-minimal | 连接层以 rpc_result 包装 rpc_answer_unknown,避免清理请求落到业务 RPC fallback | +| destroy_session | done | real-minimal | 返回 destroy_session_ok/none;命中非当前活跃 session 时清理运行态索引与 SessionStore 记录 | +| http_wait | done | real-minimal | TCP/container 场景下解码后吞掉,不加入 msgs_ack;HTTP long-poll 语义第一阶段不启用 | +| destroy_auth_key | done | real-minimal | TDesktop 清理旧 auth key 时连接层直接返回 destroy_auth_key_ok,避免落到业务 RPC fallback;物理删除 auth key 留后续安全清理任务 | +| active session update gate | done | real | 在线 session 先注册但不接收业务 updates;`updates.getState/getDifference` 后才放开,期间主动推送暂存,语义对齐 参考实现 session 的 canSync | +| ping / ping_delay_disconnect | done | real | 回 pong,并作为 content-related request 回 msgs_ack;TDesktop 保活 odd seq_no 不再触发 bad_msg code 34 | +| get_future_salts | done | real | 返回当前 auth key 的权威 server_salt 有效窗口,并作为 content-related request 回 ack | +| bad_msg_notification | done | real | msg_id==0 等非法情形 | +| bad_server_salt | done | real | 客户端带错 salt 时返回 error_code=48 与当前权威 salt | + +## Wrapper + +| 方法 | status | behavior | note | +|---|---|---|---| +| invokeWithLayer | done | unwrap | 注入 layer 到 RPC ctx | +| initConnection | done | unwrap | 注入设备/应用信息到 RPC ctx | +| invokeWithoutUpdates | done | unwrap | 透传内层 query | + +## Boot / Config + +| method | status | behavior | note | +|---|---|---|---| +| help.getConfig | done | real | 返回含自建 DC 的 DCOptions | +| help.getNearestDc | done | real | 返回自建 DC | +| help.getAppConfig | done | real | PG-backed app_configs;默认 seed 包含 TDesktop read mark config:`chat_read_mark_size_threshold=50`、`chat_read_mark_expire_period=604800`、quote reply config:`quote_length_max=1024`,以及 anti-spam UI config:`telegram_antispam_group_size_min=200`、`telegram_antispam_user_id=5434988373` | +| help.getCountriesList | done | real | PG-backed countries/country_codes;默认 seed US/CN | +| help.getTimezonesList | stub | small-list | TDesktop Business hours 预加载入口;返回非空常用时区列表和 hash/notModified,避免客户端周期性 `NOT_IMPLEMENTED` | +| help.getPeerColors | stub | notModified | TDesktop 主界面加载 peer 色板;第一阶段无服务端色板 | +| help.getPeerProfileColors | stub | notModified | TDesktop 主界面加载 profile 色板;第一阶段无服务端色板 | +| help.getPromoData | stub | empty | 无 PSA/MTProxy 推广,返回空并设置短期 expires | +| help.getTermsOfServiceUpdate | stub | empty | 第一阶段无 TOS 更新 | +| help.getPremiumPromo | stub | empty | 第一阶段不做 Premium 展示数据 | + +## Auth + +| method | status | behavior | note | +|---|---|---|---| +| auth.bindTempAuthKey | done | real-validated | 校验 encrypted bind_auth_key_inner(nonce/temp key/perm key/session/expires_at)后记录 temp_auth_key_bindings;后续 temp auth_key RPC 会解析为 perm auth_key 身份,并刷新 active session 的业务 auth_key_id / user_id 缓存 | +| auth.exportLoginToken | stub | tdesktop-qr-placeholder | TDesktop QR 登录页轮询;第一阶段只支持手机号登录,返回短期 auth.loginToken 避免未实现噪声 | +| auth.sendCode | done | dev-code | 开发固定验证码,phone_code_hash 存 Redis CodeStore;手机号统一规范为纯数字 | +| auth.signIn | done | real | 校验码;兼容 TDesktop sendCode 后 signIn 使用 DigitsOnly(phone);登录成功绑定在线 session 的 user_id,写入并延迟推送 777000 官方登录消息 updateNewMessage,同时向其它在线 session 推送新登录 updateServiceNotification | +| auth.signUp | done | real | 建 user + 绑定 authorization;登录成功绑定在线 session 的 user_id,写入并延迟推送 777000 官方登录消息 updateNewMessage | +| auth.logOut | done | real | 解绑当前 auth_key 的授权,并清理同业务 auth_key 的活跃连接 user_id 缓存与 auth_key+user update state;账号级 user_update_events 不随设备退出删除,退出后同设备换号不会继承旧账号差分 | +| account.checkUsername | done | real | username 规则为 ASCII 字母开头、后续字母/数字/下划线、5-32 字符;大小写不敏感检查占用,已被本人占用视为可用 | +| account.updateUsername | done | real | 设置或清除当前账号主 username;大小写不敏感唯一约束由 PG partial unique index 兜底,并向其它在线 session 推送 updateUserName | +| account.updateProfile | done | real | 更新当前账号 first_name/last_name/about;first_name 必填且姓名 64 字符内,about 70 字符内;资料变化后向其它在线 session 推送 updateUserName,`users.getFullUser` 返回 about | +| account.getPassword | done | real | PG-backed account_passwords;第一阶段默认无 2FA | +| account.getAccountTTL | stub | default-365d | TDesktop Settings/self-destruct 预取;当前不做账号自动销毁持久配置,返回正数默认 TTL 避免后台 NOT_IMPLEMENTED | +| account.getNotifySettings | stub | default | TDesktop 主界面读取通知设置;显式返回 show_previews=true、silent=false、mute_until=0 与 default sound,避免空 settings 被客户端按静默展示 | +| account.updateNotifySettings | stub | ok | 第一阶段不持久化通知偏好,但接受 TDesktop 设置写入,避免本地通知状态 RPC 报错 | +| account.getPrivacy | stub | default | Settings/Folders 预取隐私项;参考实现 默认规则:手机号默认 disallowAll、生日 allowContacts、其它 allowAll | +| account.getAuthorizations | stub | empty | Settings 设备列表预取;第一阶段不展示授权设备管理,返回空 authorizations | +| account.getDefaultEmojiStatuses | stub | notModified | 第一阶段不提供默认 emoji status 列表 | +| account.getCollectibleEmojiStatuses | stub | empty | TDesktop 启动/emoji status 面板会刷新 collectible gift emoji statuses;当前无 gift/status 模型,返回空 `account.emojiStatuses` | +| account.getDefaultGroupPhotoEmojis | stub | empty | TDesktop 头像/群头像编辑页默认 emoji 图片入口;当前无 custom-emoji photo 候选,返回空 `emojiList` | +| account.getConnectedBots | stub | empty | TDesktop business chatbot 资料预取;当前无 business bot 模型,返回空 `account.connectedBots`,避免客户端 API error | +| account.getReactionsNotifySettings / account.setReactionsNotifySettings | done | real-account-settings | 持久化账号 reaction 通知范围(none/contacts/all)与 show_previews,返回默认提示音;无记录时默认 contacts + previews | +| account.getContactSignUpNotification | stub | false | 第一阶段不推送联系人注册提醒 | +| account.getThemes | stub | notModified | Settings/theme 预取,第一阶段不提供云主题 | +| account.getContentSettings | stub | default | Settings 内容敏感项预取,第一阶段不启用 NSFW 内容配置 | +| account.getGlobalPrivacySettings | stub | default | Settings 全局隐私项预取,返回空默认设置 | +| account.getPasskeys | stub | empty | Settings 安全项预取,第一阶段不提供 passkeys | +| account.getSavedMusicIds | stub | empty | TDesktop saved/profile music 预取;当前没有 profile music media store,返回空 id vector | +| account.updateStatus | done | persisted-presence | 记录 `auth_key/session/user` 维度运行时在线状态并持久化 `users.last_seen_at`;`offline=false` 与重连后 session 身份恢复都会写入 last_seen 并推送 `updateUserStatus(userStatusOnline expires=now+5m)`,`offline=true` 写入精确 `userStatusOffline.was_online`;最后一个 MTProto session 断开/destroy_session 也会写 last_seen 并推送 offline。状态推送给当前用户其它在线 session、在线联系人、以及已有私聊 dialog 的在线对端;session 恢复时还会向当前 session 补发在线联系人/私聊对端状态,避免 TDesktop 等用户动作后才刷新。当前未接 account privacy,已知 last_seen 默认精确可见 | + +## Updates + +| method | status | behavior | note | +|---|---|---|---| +| updates.getState | done | real | auth_key+user 维度持久化 update_states;账号级当前 pts 报告**最大连续已提交 pts**(MaxContiguousPts,非 allocator 最大已分配值),避免同设备换号/多账号串差分,也避免越过在途空洞 | +| updates.getDifference | done | real | 按 user_id 从 user_update_events 拉取 pts 后增量;只返回从客户端 pts 起**连续**的事件(遇在途空洞即截断),超 100 条置 differenceSlice;支持 new_message、read_history_inbox/read_history_outbox(私聊与 channel peer,channel read 映射 updateReadChannelInbox)、edit_message、message_reactions(输出带最新 `message.reactions` 的 affected message + `updateMessageReactions`,并按 viewer 重取最新聚合,避免 TDesktop 离线恢复时本地 message cache 不刷新)、delete_messages、contacts_reset、dialog pinned/order/manual unread、peer_settings、dialog filters/folder peers 与 noop gap;消息事件携带 fwd/reply 所需 users/chats,payload 来自 durable log。**账号级绝不返回 `differenceTooLong`**:已核对 TDesktop 基线 `api_updates.cpp:516`——对账号级 differenceTooLong 只打一行日志、不读 pts 且漏 `setRequesting(false)`,会永久锁死 update 引擎(`:689` 早退,重连/新 session 不可恢复);落后客户端改用 `differenceSlice` 续传。因此 `user_update_events` **永久保留、不做 retention 裁剪**(对齐 参考实现),详见 docs/performance-audit.md 附录 C | +| updates.getChannelDifference | done | real-channel | 超级群/频道使用 channel 维度 durable log:`channel_update_events(channel_id, pts, pts_count, ...)`;按 channel pts 返回 `channelDifferenceEmpty/channelDifference/channelDifferenceTooLong`,limit cap=100;`pts < 0` 或 `pts > current_channel_pts` 返回 `PERSISTENT_TIMESTAMP_INVALID`;当前 member 的 `available_min_pts` 会抬高请求 pts,避免新加入/重新加入成员拉到入群前 participant/admin/settings 事件;公共 username 频道允许非成员以只读预览身份拉可见差分并返回 synthetic read dialog,私有频道和禁看用户仍返回权限错误;当 `current_channel_pts-pts > cap` 时返回带当前 dialog pts 和最新有界消息快照的 `channelDifferenceTooLong`,避免大频道旧 pts 客户端循环拉大量页;普通 difference 优先使用事件 payload 中的 message/participant 快照,连续编辑/删除后不会被当前消息状态污染;本页消息的 sender/send_as/fwd_from/reply_to/action peers 会随 users/chats 返回,禁止复用 user_update_events | + +### 兼容硬约束:pts / pts_count(跨所有产生 update 的 RPC) + +> 已核对 TDesktop 基线源码(`data_pts_waiter.cpp` / `.h`、`api_updates.cpp`)。详细推导见 docs/message-module.md「客户端 pts 重排依赖 pts_count 准确」。 + +outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱序到达客户端**(pts=6 先于 pts=5)。客户端 `PtsWaiter` 靠 `_count += pts_count` 累加判连续:乱序的先缓存、等空缺补齐再按序应用,1 秒补不齐才 `getDifference`。因此**任何产生 update 的 RPC 都必须遵守**: + +1. **每条 update 的 `pts_count` 必须准确等于它推进的 pts 步数**。私聊文本、已读 inbox/outbox、文本编辑均为 1;批量删除为本次删除的 owner 视角 message_id 数量。 +2. **每个分配出去的 pts 最终都要能被 getDifference 拿到**——事务回滚也要写 `noop` 占位(`recordPtsGaps`),否则连续水位永久卡死、客户端永久 gap。 + +当前私聊文本、转发、已读回执、文本编辑与删除消息满足两条(new_message/forward/read/edit pts_count=1,delete_messages pts_count=len(message_ids),noop 补洞,连续水位兜底)。**新增任何其它 `pts_count ≠ 1` 的场景(批量 service action、频道 editMessage 等)时,必须回到此约束重新核对**,否则客户端 `_count` 永久错位。 + +## Dialogs / Messages + +| method | status | behavior | note | +|---|---|---|---| +| messages.getDialogFilters | done | real | 返回 dialogFilterDefault + 当前账号持久化自定义 filters,并带 tags_enabled;离线设备启动可重拉完整分组状态 | +| messages.getSuggestedDialogFilters | stub | empty | 第一阶段不提供推荐文件夹 | +| messages.updateDialogFilter | done | real | 持久化/删除当前账号自定义 filter,校验 filter_id/title/peer 数量;channel peer 必须校验非零 access_hash;写 durable updateDialogFilter + dispatch_outbox | +| messages.updateDialogFiltersOrder | done | real | 持久化自定义 filter 顺序,去重并忽略默认/归档保留 ID,写 durable updateDialogFilterOrder + dispatch_outbox | +| messages.toggleDialogFilterTags | done | real | 持久化 folder tags 开关,写 durable updateDialogFilters reload + dispatch_outbox | +| folders.editPeerFolders | done | real | 支持 folder_id=0/1 的归档/还原;user peer 更新 dialogs.folder_id,channel peer 更新 channel_dialogs.folder_id;返回/记录 updateFolderPeers(pts,pts_count=1),可靠投递其它 session | +| messages.getDialogs | done | real | 从按 user_id HASH 分区的 dialogs/message_boxes/users 查询当前账号私聊会话,并合并 channel_dialogs/channels/channel_messages;支持 exclude_pinned、folder_id(0 主列表/1 归档/2+自定义 filter)、offset_date/offset_id/offset_peer seek pagination、limit、hash notModified;channel offset_peer 校验非零 access_hash;channel dialog 返回持久 `read_outbox_max_id`,可恢复离线/丢失的 `updateReadChannelOutbox`;不会把返回的 channel 宽泛标记为 active viewer,避免 typing/reaction 等瞬时事件误推给全体 dialog 列表;登录后可见 777000 官方系统会话 | +| messages.getPinnedDialogs | done | real | 从 dialogs + channel_dialogs 合并查询 pinned 会话,返回 top messages/users/channels,并附持久化 update state | +| messages.getPeerDialogs | done | real | 按 InputDialogPeer 精确查询当前账号会话,返回 dialog/top message/users/update state;channel peer 同样返回持久 `read_outbox_max_id`;公开 username channel/supergroup 对非成员返回只读 preview dialog/top message,private/ban/kick/view_messages 不暴露;未建会话的 user peer 返回空 dialog 占位;peer vector cap=100 | +| messages.getPeerSettings | done | real | 当前 owner 视角 peerSettings:非联系人显示 add/block,联系人显示 shareContact;若该 owner 已 hide peer settings bar 则返回空 action bar;附带当前 owner 视角用户资料 | +| messages.getMessages | done | real-private | TDesktop reply/webpage/media lazy load 入口;当前按 owner 视角 `InputMessageID` 精确拉取私聊 message_box,ID 不存在返回 messageEmpty,单次最多 100 个 | +| messages.getHistory | done | real | 从按 owner_user_id HASH 分区的 message_boxes 按当前账号+peer 查询历史;支持 offset_id、offset_date、add_offset、limit、max_id、min_id、hash,查询条件走分区索引与 seek pagination;`add_offset` clamp 到 `[-100,100]`,避免异常客户端触发超大跳过扫描或内存分配 | +| messages.readHistory | done | real-partial | 标记 reader dialog 的 read_inbox_max_id/unread_count 并清 manual unread;真正发生已读时给 reader 生成 updateReadHistoryInbox,同时推进 sender dialog 的 read_outbox_max_id 并生成 updateReadHistoryOutbox;发送消息时不预先推进 read_outbox,避免假已读 | +| messages.getOutboxReadDate | done | real-private | TDesktop 已读详情入口;按当前 owner+peer+msg_id 校验 outgoing message,读取最早覆盖该 msg_id 的 `read_history_outbox` durable event 日期,无读回执时返回 `MESSAGE_NOT_READ_YET` | +| messages.getMessageReadParticipants | done | real-channel-small | TDesktop 小群已读详情入口;仅 channel/megagroup peer,校验消息存在与可见历史,按 `channel_members.read_inbox_max_id >= msg_id AND read_inbox_date > 0` 返回最多 50 个 `readParticipantDate`,并用 `read_inbox_date` 给出已读时间;初始 join/invite read 水位只用于 unread 基线,不会把新成员伪造成旧消息读者;broadcast/超阈值/过期返回空 | +| messages.getMessageEditData | done | real-text-only | 参考实现/参考实现 的编辑前校验语义:按 peer/msg_id 校验消息存在与作者/管理员 edit_messages 权限;当前只支持文本消息编辑,没有媒体 caption 状态,因此返回 `messages.messageEditData{caption=false}` | +| messages.getWebPagePreview | stub | empty-preview | TDesktop 输入框链接预览入口;参考实现 空预览与 参考实现 空文本校验,`message` trim 后为空返回 `MESSAGE_EMPTY`,entities/text 有界,返回 `messages.webPagePreview{media=messageMediaEmpty}`,不抓取外网、不落缓存 | +| messages.uploadMedia / messages.sendMedia / messages.sendMultiMedia | done | photo/document/sticker | 接入 files/media 存储(documents/photos/file_blobs + 本地 blob backend)与消息 media 快照列;`resolveInputMedia` 解析 `inputMediaUploadedPhoto/Document`(组装上传分片→建 Photo/Document)与 `inputMediaPhoto/Document`(按服务端 document id 引用已存在资源,含贴纸)→ `domain.MessageMedia`,经 sendOutgoing 走与文本相同的 pts/box/outbox/在线推送/离线 difference;私聊与 channel 均支持;`sendMedia(inputMediaEmpty/WebPage)` 仍降级为纯文本 `sendMessage`;`uploadMedia` 返回可复用 `messageMedia`;sendMultiMedia 各条作为独立消息发送(grouped_id 相册聚合留 todo);geo/contact/poll/todo/dice/story 等仍返回 `MEDIA_INVALID`;album cap=10,caption/entities/random_id/peer/access_hash 均校验 | +| messages.readMessageContents | done | real-partial | TDesktop 普通消息内容已读入口;校验 id cap=100、message_id 范围与当前账号 exact 可见私聊消息,对存在消息向当前账号其它在线 session 推 `updateReadMessagesContents`;当前无 media/reaction content-read 持久状态,返回当前账号 affectedMessages 且不生成新 pts | +| messages.getMessagesViews | done | real-channel-views | TDesktop 频道浏览计数入口;channel/supergroup peer 校验 access_hash 与 id cap=100,`increment=true` 时按 `(channel_id,message_id,viewer_user_id)` 去重后递增 `channel_messages.views_count`,按请求顺序返回显式 `views` 与 replies/comment context;不存在、删除或本地清历史前不可见的 id 返回空 `messageViews`;forwards 计数与 forwarded-channel source views 透传仍待媒体/转发统计模型补齐 | +| messages.getUnreadMentions | done | real-channel-mentions | Channel/supergroup peer 维护 `channel_unread_mentions(user_id,channel_id,message_id)` 独立索引;sendMessage 解析 mention-name entity 与 `@username`,写入 active 且可见成员;查询按 user+channel+top_msg_id+message_id seek,limit cap=100,返回 channel context | +| messages.readMentions | done | real-channel-mentions | Channel/supergroup peer 按 top_msg_id 有界清除 unread mention,单次最多 1000 条,重算 `channel_dialogs.unread_mentions_count`;返回 current channel pts/offset,供 TDesktop channel PtsWaiter 消费 | +| messages.reportSpam / messages.report | stub | reported-bounded | TDesktop peer bar/消息举报入口;校验 peer/access_hash/message id/option/comment 上限,不落 report 表;`report` 空 option 返回举报原因,`other` 返回 addComment,其余合法 option 返回 reported,非法 option 返回 `OPTION_INVALID` | +| messages.reportReaction / messages.reportMessagesDelivery / messages.reportReadMetrics / messages.reportMusicListen / messages.reportSponsoredMessage | stub | telemetry-noop | TDesktop 反应举报、Gateway 送达、阅读指标、音乐播放、广告举报入口;校验 peer/access_hash/id vector/metrics/document/duration/random_id 上限后返回 BoolTrue 或 sponsored reported,不落 telemetry/report 表 | +| messages.sendReaction / messages.getMessagesReactions / messages.getMessageReactionsList | done | real-private-channel-emoji | Private peer 支持 emoji reaction 持久化到 `private_message_reactions(private_message_id,user_id,reaction)`,双端 owner-visible message box 共享 reaction 聚合但按 viewer 重算 `my/chosen_order`,`sendReaction` 返回当前端 `updateMessageReactions`,向当前账号其它 session 与对端在线 session 推对应 peer/msg_id update,并写账号级 `message_reactions` durable event;离线设备经 `updates.getDifference` 补带 `message.reactions` 的 message + `updateMessageReactions`;history/getMessages/search 回填 `message.reactions`。Channel/supergroup peer 继续持久化到 `channel_message_reactions`,按当前用户替换/清除本消息 reaction,在线瞬时推送面向当前 active viewers 与反应者其它 session,history/getMessages/getMessagesReactions 可恢复当前聚合,list 按 `(reaction_date,reacted_user_id,reaction_value)` seek 分页,limit cap=100;custom emoji 普通消息 reaction 仍拒绝,paid reaction 走独立入口 | +| messages.setDefaultReaction | done | real-account-default | TDesktop quick/favorite reaction 保存入口;校验 emoji reaction 后持久化账号默认 reaction(默认 👍),供后续启动/设置路径复用 | +| messages.getPaidReactionPrivacy / messages.togglePaidReactionPrivacy / messages.sendPaidReaction | stub | privacy-real/send-balance-low | paid reaction privacy 持久化 default/anonymous/peer,并向当前账号其它 session 推 `updatePaidReactionPrivacy`;当前无 Stars 余额/paid reaction 计费模型,`sendPaidReaction` 校验 peer/msg_id/count/private 后返回 `BALANCE_TOO_LOW`,不伪造付费 reaction 聚合 | +| messages.deleteParticipantReaction / messages.deleteParticipantReactions | done | real-channel-moderation | 群/超级群管理员删除用户 reaction 入口;校验 peer/access_hash、participant user 与 delete_messages 权限,单条返回真实 `updateMessageReactions`,批量按最多 1000 条删除并给在线 active viewers 推受影响消息反应更新,同时刷新消息作者 unread reaction 计数;private peer 当前返回兼容成功/空 updates | +| messages.getUnreadReactions / messages.readReactions | done | real-channel-emoji-read | Channel/supergroup peer 按 `sender_user_id = 当前用户 AND unread` 维护 emoji reaction 未读状态;`sendReaction` 在反应者不是消息作者时写 unread 并重算 `channel_dialogs.unread_reactions_count`,查询按 user+channel+top_msg_id+message_id seek,limit cap=100,返回带 unread recent reaction 的 channel messages;`readReactions` 单次最多清 1000 条并重算 dialog count,返回 current channel pts/offset;TDesktop 打开 channel/supergroup history 发出的 `channels.readMessageContents` 会清理可见消息上的 unread reaction、回推 `updateMessageReactions` 并刷新 dialog reaction 计数;saved_peer/monoforum 与 reaction tag/ranking 仍留后续模型 | +| messages.getRecentReactions / messages.clearRecentReactions | done | real-account-recent | TDesktop reaction 面板后台入口;`messages.sendReaction(add_to_recent)` 会记录当前账号最近使用的私聊/channel/supergroup emoji reaction,limit cap=100,内容 hash 命中返回 notModified,clear 后返回 hash=0 空列表 | +| messages.getTopReactions | done | real-account-top | TDesktop reaction 面板后台入口;`messages.sendReaction` 会按当前账号累计私聊/channel/supergroup emoji reaction 使用次数,limit 0 时按 TDesktop 默认 14、cap=100,内容 hash 命中返回 notModified;账号 top 不足时优先用真实 `available_reactions` catalog(Files 服务缺失时才静态兜底)有界补齐,保证 top/recent/default 的 emoji id 能被 `messages.getAvailableReactions` 解析,避免右键 reaction selector 只有空白占位 | +| messages.getDefaultTagReactions | stub | empty/notModified | TDesktop saved-message tags 默认候选入口;当前无 saved reaction tag/custom-emoji document store,返回空 reactions/notModified;真实 tag ranking 与 custom emoji 候选后续补 | +| messages.getSavedReactionTags / messages.updateSavedReactionTag | done | real-account-title | TDesktop saved messages tag 入口;全局请求返回账号级 emoji saved reaction tag 标题,内容 hash 命中返回 notModified;update 校验 emoji reaction 与 title<=12 后持久化并向其它 session 推 `updateSavedReactionTags`;可选 peer 仍只校验 access_hash 并返回空,saved-message tag assignment/count、custom emoji 与默认 tag 候选留后续模型 | +| messages.getExtendedMedia | stub | empty-updates | TDesktop paid media 可见消息轮询入口;校验 peer/access_hash 与 id cap=100,当前无 paid media/extended media store,返回空 Updates,不伪造 `updateMessageExtendedMedia` | +| messages.sendVote / messages.getPollResults / messages.getPollVotes / messages.addPollAnswer / messages.deletePollAnswer / messages.getUnreadPollVotes / messages.readPollVotes | blocked | poll-store-missing | TDesktop poll UI 会直接调用这些入口;参考实现 都先由 peer+msg_id 找 poll media/poll_id,getPollVotes 有分页上限。当前 media/poll store 未接入,显式注册并校验 peer/access_hash/msg_id/options/offset/limit;read-only 返回空 updates/votes/unread 或 affectedHistory,真实 vote/add/delete 返回 `MESSAGE_ID_INVALID`,避免伪造 poll update;broadcast channel 拉投票人返回 `BROADCAST_FORBIDDEN` | +| messages.appendTodoList / messages.toggleTodoCompleted | blocked | todo-store-missing | Layer225 todo 媒体未接入;参考实现 的 todo item 上限配置与 append 对 `MessageMediaToDo` 的依赖,当前只做 peer/msg_id/list/id/title/entities 有界校验,空变更返回 `TODO_NOT_MODIFIED`,非空变更返回 `MESSAGE_ID_INVALID`,不生成不可恢复的 fake updates | +| messages.getSearchCounters / messages.search(media filters) | stub | zero-counters | TDesktop 搜索面板 counters 返回每个 filter 的 0 count;资料页 shared media count 会发 `messages.search(limit=0, filter=photo/video/document/url/gif/music/roundVoice/poll)`,当前无 media/webpage/poll/document store,显式返回空页/count=0,避免纯文本 channel history 污染 photos/videos/files 等计数;filter cap=32 | +| messages.getSearchResultsCalendar / messages.getSearchResultsPositions | stub | empty-bounded | TDesktop shared media 日历/位置辅助入口;校验 peer/access_hash/saved_peer/offset/limit 后返回空结果,calendar 回填请求 offset date/id,避免空月份重复拉取;真实媒体索引与按日聚合后续补 | +| messages.getReplies | done | real-channel-thread | 支持 channel/supergroup `reply_to_top_id` thread 分页;broadcast post 若已 linked discussion group,会映射到 linked megagroup root 后读取评论;forum topic root 支持 TDesktop `reply_to_msg_id=0/top_msg_id=topicRoot` 发送路径,返回 `messages.channelMessages{pts,topics}` 与 short topic;limit cap=100,add_offset clamp,按 message id seek,hash 命中返回 notModified,响应补齐 source/group chats/users | +| messages.getDiscussionMessage | done | linked-discussion-root | 校验 channel peer/access_hash/msg_id 后返回 thread root、`max_id/read_inbox/read_outbox/unread_count` 与 source/group chats;broadcast linked post 返回 discussion megagroup 中的 forwarded root message,未 linked 时返回当前 channel root/context;forum topic root 已由 `createForumTopic` 落 service message,topic 消息历史由 `messages.getReplies(topicRoot)` 补齐 | +| messages.readDiscussion | done | linked-read-state | 校验 root 后把 broadcast post 映射到 linked discussion megagroup,再推进该 target channel 的 read inbox 水位;不生成 channel pts,账号级 read update/outbox 用现有 channel read 机制补偿其它 session/作者回执 | +| messages.getForumTopics | done | forum-topic-page | 校验 channel peer/q/limit/offset,非 forum 返回 `CHANNEL_FORUM_MISSING`;forum 开启后返回虚拟 `General` topic(id=1) + 持久化 topic store,topic page 按 `(pinned,pinned_order,date,topic_id)` seek,不用 OFFSET;响应带 channel pts、channel context、root service messages 和 creator users | +| messages.getForumTopicsByID | done | forum-topic-lookup | topic id cap=100,非 forum 返回 `CHANNEL_FORUM_MISSING`;id=1 返回虚拟 `General`,其它 id 从 topic store 精确查询并补 root service message/context | +| messages.createForumTopic | done | root-service-message | 校验 peer/access_hash、forum megagroup、title/random_id/send_as;创建 `messageActionTopicCreate` channel service message,topic_id 使用 root message id,写入 `channel_forum_topics` 分区表,返回 `updateMessageID + updateNewChannelMessage` 并在线推送其它成员;重复 random_id 幂等返回既有 root | +| messages.editForumTopic | done | topic-edit-service | 校验 peer/access_hash、forum topic、title/icon/closed/hidden flags;topic creator 或具备 pin/manage 能力的成员可改;写 `messageActionTopicEdit` service message,reply_to_top_id 指向 topic root,更新 topic read model 并返回/推送 `updateNewChannelMessage` | +| messages.updatePinnedForumTopic / messages.reorderPinnedForumTopics | done | pinned-topic-updates | 校验 topic id/order cap=100 和 pin 权限;更新 `channel_forum_topics.pinned/pinned_order`,返回/在线推送 `updatePinnedForumTopic` 或 `updatePinnedForumTopics(order)`;该 TL update 本身不带 pts,离线重开通过 getForumTopics 补状态 | +| messages.deleteTopicHistory | done | bounded-topic-delete | 校验 forum topic 与权限;每次最多删除 `MaxDeleteHistoryBatch` 条 topic root/reply_to_top_id 消息,返回 `messages.affectedHistory.offset` 供 TDesktop 续删;最后一页才从 topic store 隐藏 topic,避免对十几万消息一次性生成 update 或 id vector | +| messages.getOnlines | done | bounded-online-members | 超级群/频道在线人数入口;校验 peer/access_hash 后用 `SessionManager.OnlineUserIDs(limit<=500)` 与 active channel member 做有界交集,caller 自动加入候选避免本连接快照遗漏;无在线 provider 时保留 参考实现 兼容 `onlines=1` | +| messages.editMessage | done | real-partial | 当前阶段支持私聊文本消息编辑:仅原发送者可编辑自己的 outgoing message,更新 shared private_messages 与所有可见 owner message_boxes,生成 updateEditMessage(pts_count=1) 并可靠投递;`inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media 返回 `MEDIA_INVALID`,reply_markup 返回 `REPLY_MARKUP_INVALID`,quick replies 返回 `MESSAGE_ID_INVALID`,scheduled 返回 `SCHEDULE_DATE_INVALID` | +| messages.deleteMessages | done | real-partial | 当前 owner 视角软删除指定私聊消息;`revoke` 会删除同一 private_message 在其它 owner 视角的 message_box;按 owner 生成 `updateDeleteMessages`,`pts_count=len(message_ids)`,并重算或删除 dialog | +| messages.deleteHistory | done | real-partial | 当前 owner 视角按 peer/max_id 清空历史;默认清空后无可见消息则删除 dialog,后续新消息可重建;`just_clear` 保留空 dialog;`revoke` 同步清理对端 owner 视角;min_date/max_date 第一阶段兼容 no-op | +| messages.search | done | real | 当前账号消息搜索;user peer 走 `message_boxes`,channel/supergroup peer 走单份 `channel_messages`;支持 q/from_id/min_date/max_date/offset_id/add_offset/limit/max_id/min_id/hash,limit cap,文本命中有 pg_trgm GIN 索引兜底;channel 结果附带 sender/fwd/reply 所需 users/chats | +| messages.searchGlobal | done | real-partial | TDesktop 搜索框全局消息分支;当前查当前 owner 私聊 message_boxes,并合并当前账号 active membership 的频道/超级群单份文本消息;支持 users_only/groups_only/broadcasts_only、filterEmpty、q、limit cap=50、offset_rate+offset_peer+offset_id channel seek、min/max_date 与 folder_id=0/1 下推,media filters 显式空结果;参考实现 的 joined channel list 语义与 参考实现 的 bounded search,后续再接外部全文搜索/媒体索引 | +| messages.toggleDialogPin | done | real | 当前 owner 的 dialog pinned/pinned_order 持久化;user peer 写 dialogs,channel peer 写 channel_dialogs;写 durable updateDialogPinned + dispatch_outbox,可靠投递给其它在线 session | +| messages.reorderPinnedDialogs | done | real | 当前 owner 的 pinned dialog 顺序持久化;支持混合 user/channel peer 与 force 清理未在 order 中的 pinned dialog,写 durable updatePinnedDialogs(order) + dispatch_outbox,可靠投递给其它在线 session | +| messages.markDialogUnread | done | real | 当前 owner manual unread mark 持久化;user peer 写 dialogs,channel peer 写 channel_dialogs/channel_members,`messages.getDialogs/getPeerDialogs` 返回 dialog.unread_mark;readHistory 清除 unread_mark,写 durable updateDialogUnreadMark + dispatch_outbox | +| messages.getDialogUnreadMarks | done | real+monoforum-stub | 返回当前 owner 所有 user/channel manual unread peer;`parent_peer` 分支校验 parent channel 后返回空 monoforum marks,`messages.markDialogUnread(parent_peer)` 同样校验 parent/sublist peer 后 BoolTrue no-op,避免 TDesktop SavedSublist/monoforum 后台触发 `NOT_IMPLEMENTED`;真实 monoforum topic unread store 后续补 | +| messages.hidePeerSettingsBar | done | real | 当前 owner 隐藏 peer action bar 状态持久化;后续 getPeerSettings 返回空 action bar,写 durable updatePeerSettings(settings) + dispatch_outbox,并可靠投递给其它在线 session | +| messages.setTyping | done | real-transient | 私聊 typing/cancel/upload 等 SendMessageAction 实时包装为 updateShort(updateUserTyping) 推给对端在线 session,并排除当前 auth_key_id+session_id;InputPeerChannel 走 `updateChannelUserTyping` 在线瞬时推送;`top_msg_id` 有界校验,非法返回 `MSG_ID_INVALID`;typing 不写 durable log、不做离线补偿 | +| messages.saveDraft | done | real-partial | 接受 TDesktop 输入框云草稿保存/清空 RPC,channel peer 校验非零 access_hash,持久化 user/channel peer 文本、entities、reply、forum `top_msg_id`、no_webpage/invert_media/effect 与 webpage URL,并按 参考实现 语义向同账号其它在线 session 推 `updateDraftMessage`;发送消息 `clear_draft` 会清对应 draft 并推空 draft update;文件 media、suggested_post、monoforum saved_peer 与 getDifference draft event 后续补 | +| messages.getAllDrafts | done | real-partial | 从 `dialog_drafts` 读取最多 1000 条云草稿,返回对应 `updateDraftMessage`,forum draft 设置 `top_msg_id`,附带已知 users/chats;文件 media、suggested_post、monoforum saved_peer 暂不返回 | +| messages.clearAllDrafts | done | real-partial | 有界清空最多 1000 条当前账号云草稿,向其它在线 session 推 `draftMessageEmpty` updates;超大 draft 集合按后续调用继续清理,durable difference 事件后续补 | +| messages.getSavedDialogs | stub | empty/notModified | 第一阶段不做 saved messages 分会话 | +| messages.getPinnedSavedDialogs | stub | empty | 第一阶段不做 saved messages 分会话置顶 | +| messages.toggleSavedDialogPin | stub | ok | 第一阶段不做 saved dialogs 置顶,显式接受避免未知 RPC | +| messages.reorderPinnedSavedDialogs | stub | ok | 第一阶段不做 saved dialogs 置顶排序 | +| messages.getSavedDialogsByID | stub | empty | 第一阶段不做 saved dialogs 指定查询 | +| messages.getSavedHistory | stub | empty/notModified-bounded | TDesktop 打开群/用户资料页时会后台查询 saved history/monoforum 入口;当前不做 saved history,但会校验可选 `parent_peer` 必须是可见 channel、peer/access_hash、offset/add_offset/limit/id/date 边界,返回空 messages 或 hash notModified,并给 parent/peer channel 补 chat context | +| messages.readSavedHistory | stub | ok-bounded | 当前不维护 saved history read state;按 monoforum 语义要求 `parent_peer` 是可见 channel,校验 peer/access_hash 与 max_id 后返回 BoolTrue | +| messages.deleteSavedHistory | stub | affected-empty-bounded | 当前不维护 saved history;校验可选 `parent_peer`、peer/access_hash、max_id/min_date/max_date 后返回 `affectedHistory{pts=current,pts_count=0,offset=0}` | +| messages.getCommonChats | done | common-megagroups | TDesktop 用户资料页共同群/机器人共同群入口;校验 input user/access_hash、max_id、limit<=100,只返回双方均 active 的 megagroup/supergroup,排除 broadcast/left/kicked/deleted,按 channel id seek 分页;PG 走按 `user_id` 主键的 `user_channel_member_index`,避免 `users.getFullUser.common_chats_count` 在私聊打开路径反向规划 `channel_members` 64 分区;`users.getFullUser` 同步填 `common_chats_count` | +| messages.getScheduledHistory / messages.getScheduledMessages / messages.sendScheduledMessages / messages.deleteScheduledMessages | stub | scheduled-store-missing | 第一阶段不做定时消息;history/exact get 返回空 messages 并校验 peer/id cap=100,send-now 对非空 id 返回 `MESSAGE_ID_INVALID`,delete 返回 `updateDeleteScheduledMessages` 清理调用端本地幽灵项;真实 scheduled store、has_scheduled dialog 标记与 job sender 后续补 | +| messages.getAvailableReactions | done | real-resources | 从 `available_reactions` 表(由 `TELESRV_STICKER_SEED_DIR` 真实导出 seed)加载 74 个 reaction,static_icon/appear/select/activate/effect/around/center 均返回真实 `document`(seed source id 已在导入阶段归一为服务端 document id,access_hash/file_reference/dc_id/attributes/thumbs 重写为本 DC 数据),TDesktop 经 `upload.getFile` 从本地 blob backend 下载动画;启动 seed 会校验所有 reaction document 的 `doc:` 主 blob,自动修复半导入状态,且导入扫描只把 `_thumb\d+_` 识别为缩略图,避免 `reaction_thumbs_up/down...` 主文件被误跳过;hash 命中返回 notModified;Files 服务缺失或未 seed 时回退固定 emoji + documentEmpty stub | +| messages.getAvailableEffects | stub | empty | TDesktop message effects 后台预取;参考实现 空响应与 参考实现 无数据响应,返回 `messages.availableEffects{hash=0,effects=[],documents=[]}` | +| messages.getAllStickers / messages.getEmojiStickers | done | real-sets | 列出已 seed 的贴纸集(`getAllStickers`→stickers 类,`getEmojiStickers`→emoji 类)元数据(cover/thumb),稳定 hash 命中返回 notModified;TDesktop 再按集调 `getStickerSet` 拉文档;无 seed 时回退空目录 | +| messages.getRecentStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取最近贴纸;第一阶段返回空列表或 hash notModified | +| messages.getFavedStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取收藏贴纸;第一阶段返回空列表或 hash notModified | +| messages.getStickers | stub | notModified | 第一阶段不提供贴纸搜索结果 | +| messages.getStickerSet | done | real-sets | `stickerSetRefFromInput` 把 `inputStickerSetID/ShortName/AnimatedEmoji/Dice/EmojiGenericAnimations` 解析为 `domain.StickerSetRef`,从 `sticker_sets` 加载集元数据 + 按 `document_ids` 顺序加载真实文档(含 packs;document id / pack document id / thumb_document_id 均为 seed 阶段归一后的服务端 id),返回完整 `messages.stickerSet`;hash 命中返回 notModified;未 seed 的系统集/未知短名回退空集 stub 避免破坏客户端 | +| messages.getFeaturedStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取推荐贴纸;第一阶段返回空列表或 hash notModified | +| messages.getEmojiGroups | stub | notModified | 第一阶段不提供 emoji 分组目录 | +| messages.getEmojiStickerGroups | stub | notModified | 第一阶段不提供 emoji sticker 分组目录 | +| messages.getEmojiProfilePhotoGroups | stub | empty | TDesktop 头像/群头像编辑页 custom-emoji profile photo categories 入口;当前无 custom-emoji profile photo 分组,返回空 `messages.emojiGroups`,避免后台 `NOT_IMPLEMENTED` 重试 | +| messages.getEmojiStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 emoji stickers;第一阶段返回空目录或 hash notModified | +| messages.getFeaturedEmojiStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 featured emoji stickers;第一阶段返回空列表或 hash notModified | +| messages.getEmojiKeywordsLanguages | stub | empty | TDesktop 输入框打开后拉 emoji keyword 语言,第一阶段返回空列表 | +| messages.getEmojiKeywords / messages.getEmojiKeywordsDifference | stub | empty-difference | TDesktop emoji keyword 增量入口;校验 lang_code 和 from_version,返回空 `emojiKeywordsDifference`,difference 版本回显客户端 from_version,避免重复大包拉取 | +| messages.getCustomEmojiDocuments | done | real-documents | 按服务端 document id 批量从 `documents` 表加载真实文档(单批 cap=100、id 必须为正),命中返回真实 `document`,未命中返回 `documentEmpty{id}` 占位;无 Files 服务时返回空 | +| messages.getAttachedStickers | stub | empty | TDesktop 图片/视频“查看相关贴纸包”入口;nil media 返回 `MEDIA_EMPTY`,有效 media 返回空 sticker set list | +| messages.searchStickerSets / messages.searchStickers | stub | empty-bounded | TDesktop 贴纸/emoji 云搜索入口;query/emoticon/lang/offset/limit/hash 有界,hash 非零返回 notModified,当前无云贴纸索引,返回空 found results | +| messages.getSavedGifs | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 saved GIFs;第一阶段返回空列表或 hash notModified | +| messages.getAttachMenuBots | stub | notModified | 第一阶段不提供 attach menu bot | +| messages.getQuickReplies | stub | notModified | 第一阶段不做 business quick replies | +| messages.getWebPage | stub | empty | Settings/消息预览路径可能预取 instant view;第一阶段返回 webPageEmpty | +| messages.getDefaultHistoryTTL | stub | disabled | 参考实现,返回 `defaultHistoryTTL{period=0}`,表示新聊天默认不自毁 | +| messages.getSponsoredMessages | stub | empty | 参考实现,返回 `messages.sponsoredMessagesEmpty`,避免群/频道页面后台广告拉取重试 | +| messages.sendMessage | done | real-private-text | 私聊文本消息;支持 random_id 幂等、silent/noforwards、reply_to 私聊消息头(含 quote 文本/entities/offset 与双端 message_id 翻译;quote_text≤1024,quote_offset 按消息文本 offset 限制在 4096 内)、双端 message box、dialog、pts/update event、transactional outbox、在线 session 批量推送、4096 字符上限与 Redis 用户级窗口限流;outbox 排除当前设备使用 auth_key_id+session_id;pts_count 恒 1;channel peer 的 `send_as` 支持 self/current channel 校验链路;media/scheduled 仍返回 `MEDIA_INVALID`/`SCHEDULE_DATE_INVALID`;reply_markup/quick_reply/effect/paid/suggested 与 story/monoforum/todo/poll reply 返回显式 TL 错误,避免 TDesktop 高级输入路径产生 `NOT_IMPLEMENTED` | +| messages.forwardMessages | done | real-private-text | 支持当前 owner 可见文本消息在 user/channel peer 间转发,保留原 forward header 或生成 messageFwdHeader,支持目标会话 `reply_to`、TDesktop forum topic `top_msg_id`、silent/noforwards/drop_author、random_id 幂等和 100 条上限;目标 channel 的 `send_as` 支持 self/current channel 校验链路;源消息自身 reply 不继承,响应、difference 与 outbox 都补齐 fwd/reply/send_as 来源 users/chats;被 noforwards 保护的源消息返回 `CHAT_FORWARDS_RESTRICTED`;scheduled/monoforum/quick_reply/effect/video_timestamp/paid/suggested 返回显式 TL 错误,不再落 `NOT_IMPLEMENTED` | +| messages.createChat | done | real-as-megagroup | 不落 legacy chat 持久化;按 docs/channel-module.md 直接创建 `megagroup` channel。TDesktop New Group 回调只接受 `updates.chats[0] == chat`,因此 TDesktop ctx 下同步响应返回 `legacy chat(migrated_to=channel)` + 真实 `channel`;后续 `InputPeerChat` 主路径映射回同 id channel,避免 `ContactsBox::creationDone` 的 `chat not found in updates` | +| messages.getChats | done | legacy-as-channel | 无 legacy chat 长期形态;若 TDesktop/export 路径用 chat_id 询问,按同 id 查询 megagroup channel 并返回 `tg.Channel` | +| messages.getFullChat | done | legacy-as-channel | 无 legacy chat 长期形态;将 chat_id 映射为 channel_id,返回 `channels.getFullChannel` 语义 | +| messages.addChatUser | done | legacy-as-channel | legacy 普通群入口映射到 `channels.inviteToChannel`;仍执行 channel invite 权限和单次 invite cap | +| messages.deleteChatUser | done | legacy-as-channel | legacy 普通群入口映射到 self leave 或 channel kick;仍执行 ban/kick 权限 | +| messages.editChatTitle | done | legacy-as-channel | legacy 普通群标题入口映射到 `channels.editTitle`,生成 megagroup service message 与 channel pts | +| messages.editChatPhoto | done | legacy-as-channel | legacy 普通群头像入口映射到 `channels.editPhoto`:上传/引用照片落 `channels.photo_*` 反范式列并返回 `updateChannel` + 推 channel state;`inputChatPhotoEmpty` 清除头像 | +| messages.editChatAdmin | done | legacy-as-channel | legacy 普通群管理员入口映射到 `channels.editAdmin`;`is_admin=true` 授予 basic group 管理权,`false` 清空 admin rights | +| messages.editChatAbout | done | legacy-as-channel | legacy/basic wrapper 支持 `InputPeerChat` 与 `InputPeerChannel`,真实持久化 `channels.about` 并校验 change_info 权限 | +| messages.editChatDefaultBannedRights | done | real-channel-permissions | 按 参考实现 语义要求 ban_users 权限;持久化 `channels.default_banned_rights`,TDesktop full channel 可见;普通成员发送/邀请会实时受 default rights 与个人 banned rights 限制 | +| messages.editChatParticipantRank | done | legacy-as-channel | legacy 管理员 rank 入口映射到 `channels.editAdmin`,保留当前 admin rights,仅更新 rank,rank 长度有界 | +| messages.editChatCreator | blocked | needs-2fa-transfer | 显式注册并校验 peer/user;当前未实现账号 2FA/SRP 与所有权转移事务,返回 `PASSWORD_HASH_INVALID` 而非 fallback | +| messages.setChatTheme | stub | channel-context/private-ack | TDesktop private/theme 入口;user peer 返回空 updates,legacy chat/channel peer 校验可见性并返回 channel state,避免设置页后台 unknown RPC | +| messages.toggleNoForwards | done | real-channel | legacy chat/channel content protection 入口映射到 `channels.noforwards`,校验 change_info 权限,返回/推送 `updateChannel`;后续 channel message 自动继承 noforwards | +| messages.setChatAvailableReactions | done | real-channel-policy | legacy chat/channel reaction 设置入口;参考实现 持久化 channel reaction policy,校验 change_info、reaction vector/reactions_limit cap=64,`channels.getFullChannel` 返回 `available_reactions/reactions_limit/paid_reactions_available`,返回/推送 channel state | +| messages.saveDefaultSendAs | done | real-current-channel | TDesktop send-as 选择器保存入口;当前 `channels.getSendAs` 返回 self,并在 creator、broadcast post admin、megagroup anonymous admin 可用时追加 current channel;保存入口同样只接受 self/current channel,拒绝其它 `send_as`;默认身份写入当前 user 的 `channel_dialogs.default_send_as_peer_*`,保存 self 会清空默认;`channels.getFullChannel` 只在默认身份仍有效时输出 `channelFull.default_send_as`;`sendMessage/forwardMessages(InputPeerChannel)` 未显式带 `send_as` 时会读取并重新校验该默认身份,失效默认值自动降级 self | +| messages.migrateChat | done | legacy-as-channel | 服务端无 legacy chat 长期形态;校验 change_info/creator 权限后把 chat_id 视作既有 megagroup channel_id,返回 `updateChannel + tg.Channel`,不创建迁移双写路径 | +| messages.sendMessage(InputPeerChannel) | done | real-channel-text | channel/supergroup 单份消息写 `channel_messages`,分配 channel message id + channel pts,生成 `updateNewChannelMessage`;支持同 channel 可见消息 reply_to,服务端反算 `reply_to_top_id` 并保留 quote metadata;支持 `send_as` self/current channel,未显式带 `send_as` 时读取已保存默认身份并重新校验,消息 `from_id` 按 send_as 输出并补齐 peers;`random_id` 幂等重试返回原始 send snapshot,不受后续 edit/delete 污染;broadcast 校验 post 权限;发送事务只对小超级群同步刷新 `channel_dialogs` 普通未读缓存,broadcast/超阈值 megagroup 不做全员 unread 写入,`messages.getDialogs/getPeerDialogs/channels.getFullChannel` 按 `channel_members.read_inbox_max_id + available_min_id + channels.top_message_id + channel_messages.deleted` 读时派生普通未读;发送者 `channel_members.read_inbox` 推到新消息,自己消息不计入动态未读;持久 channel updates 在线 fanout 走 updates-ready session 的 channel membership 索引,按当前在线 active 成员 best-effort 推送(不再受 500 cap 截断),离线靠 `updates.getChannelDifference` 补偿 | +| messages.getHistory(InputPeerChannel) | done | real-channel | 查 `channel_messages(channel_id,id)`,按 seek pagination 返回;PG 使用 `limit+1` 探测下一页,不做 `count(*) over()` / SQL 大 OFFSET;带 username 的公开 channel/supergroup 允许非成员只读预览历史,私有频道与 ban/kick/view_messages 禁止仍返回对应错误 | +| messages.readHistory(InputPeerChannel) | done | real-channel | 推进当前 user 的 `channel_dialogs.read_inbox_max_id`,未读惰性重算;bounded 扫描最近 read delta,推进相关发送者 `read_outbox_max_id` 并在线推 `updateReadChannelOutbox`,不对全体成员做 O(n) fanout | +| messages.editMessage(InputPeerChannel) | done | real-channel-text | 作者或具备 edit_messages 权限的管理员可编辑单份 `channel_messages` 文本,生成 `updateEditChannelMessage(pts_count=1)` 并进入 `channel_update_events`;网页预览 media 降级文本编辑,真实 media/reply_markup/quick replies/scheduled 按现有 messages.editMessage 显式错误拒绝 | +| messages.deleteHistory(InputPeerChannel) / channels.deleteHistory(local) | done | real-channel-clear | 当前 user 本地清空时只推进 `available_min_id/read_inbox` 和 `channel_dialogs`,不生成 channel pts;同时写账号级 `channel_available_messages` durable update,并在线推 `updateChannelAvailableMessages` 给同账号其它 session,TDesktop 会 `clearUpTill(available_min_id)`;返回/推送实际应用后的单调 `available_min_id=max(old, requested)`,避免多设备 stale clear 让客户端水位回退;`messages.deleteHistory(InputPeerChannel,revoke)` 映射为有权限时的 channel 维度批量删除,返回 `affectedHistory.offset` 供后续 RPC 续删,单批 cap=1000 | +| messages.forwardMessages(InputPeerChannel) | done | real-channel-text | 支持 channel→channel、channel→user、user→channel 的文本转发,保留 forward header 或按 `drop_author` 隐藏;支持目标会话 `reply_to` 与 forum topic `top_msg_id`,后者映射为 topic-only reply 并由 store 校验 topic/权限/可见性,但不继承源消息自身 reply;目的 channel 仍单份写入并生成 channel pts;目标 channel 支持 `send_as` self/current channel,未显式带 `send_as` 时读取已保存默认身份并重新校验,响应/difference/outbox/channelDifference 补齐可解析的 send_as/fwd_from/reply_to user/channel 上下文,单次 cap=100 | +| messages.setTyping(InputPeerChannel) | done | real-channel-transient | megagroup/topic typing 走 `updateChannelUserTyping` 在线瞬时推送;`top_msg_id` 只作为 forum/thread 维度透传,范围非法返回 `MSG_ID_INVALID` 且不广播;不写 durable log、不做离线补偿;仅推给已通过 channel-specific RPC(如 getHistory/getFullChannel/getChannelDifference/getPeerDialogs)标记的 active viewers,不向全体在线成员扩散 | + +## Channels / Supergroups + +| method | status | behavior | note | +|---|---|---|---| +| channels.createChannel | done | real | 创建 broadcast channel 或 megagroup;写 channel、creator member、service message、channel_update_events;响应包含 `tg.Channel`;history import/geogroup 暂无业务模型,显式返回 `CHAT_INVALID`/`ADDRESS_INVALID`,负 `ttl_period` 返回 `TTL_PERIOD_INVALID`,避免阶段外 flags 继续落 `NOT_IMPLEMENTED` | +| channels.getChannels | done | real | 按 inputChannel 返回当前用户可见的 `tg.Channel` 列表;精确 ID vector cap=100;非零 access_hash 不匹配时跳过该项,避免异常客户端触发无界逐项查询或绕过最小 channel hash 校验 | +| channels.getFullChannel | done | real-minimal | 返回 `ChannelFull` 最小必需字段:about、participants/read/unread/notify/pts/admin rights/default banned rights;非零 access_hash 不匹配返回 `CHANNEL_PRIVATE`;带 username 的公开 channel/supergroup 允许非成员只读预览 full/history,并以 left/self 无 unread 视图返回,避免 TDesktop 公开搜索结果打开后空白;`read_outbox_max_id` 来自当前 viewer 的 channel dialog/member 状态;有邀请管理权限的管理员会收到 `requests_pending/recent_requesters`,供 TDesktop join request 管理入口恢复 | +| channels.getSendAs | done | real-current-channel | TDesktop 群/频道输入框预取 send-as peers;参考实现/参考实现 语义,当前返回 self,并在 creator、broadcast post admin、megagroup anonymous admin 可用时追加 current channel,同时返回可解析的 current channel chat + self user;公开非成员预览返回 self-only,不因非成员报 `CHANNEL_PRIVATE`;真实 public channel send_as 候选留后续 | +| channels.checkUsername | done | real | 校验 channel 可见性与 username 规则,大小写不敏感检查 users/channel_usernames 占用;同一 channel 当前 username 视为可用 | +| channels.updateUsername | done | real | creator 可设置/清除频道或超级群主 username;PG 事务内更新 `channels.username` 与 `channel_usernames`,大小写不敏感唯一约束兜底,成功后向在线成员推 `updateChannel` | +| channels.getAdminedPublicChannels | done | real | 返回当前用户 active creator/admin 且带主 username 的公开 channel/supergroup,用于 TDesktop public username limit/选择入口 | +| channels.toggleSignatures | done | real-minimal | creator/change_info admin 可切换 `channels.signatures` 并返回/推送 `updateChannel`;profiles_enabled 先不单独持久化 | +| channels.togglePreHistoryHidden | done | real-minimal | creator 可切换 `channels.pre_history_hidden`,返回/在线推 `updateChannel`,`getFullChannel` 暴露 `hidden_prehistory`;新加入/导入/受邀成员按当前 `top_message_id` 初始化 `available_min_id/read_inbox`,并按加入前 channel pts 初始化 `available_min_pts`,避免看到旧历史或补到入群前成员事件 | +| channels.toggleSlowMode | done | real-minimal | change_info 管理员可持久化 `channels.slowmode_seconds`,`Channel.slowmode_enabled/ChannelFull.slowmode_seconds` 可见;普通成员发送按 `channel_members.slowmode_last_send_date` 返回 `SLOWMODE_WAIT_X`,管理员/creator 豁免 | +| channels.setStickers | stub | empty-only | 当前无群贴纸集 store;仅 megagroup + `inputStickerSetEmpty` 清空入口做权限校验 no-op 成功,非空 sticker set 返回 `STICKERSET_INVALID`,避免 TDesktop 误以为贴纸集已落库 | +| channels.reorderUsernames | stub | permission-ok | 当前无 Fragment/多 username 模型,限制 order<=32、校验 change_info 后返回 BoolTrue | +| channels.toggleUsername | stub | permission-ok | Fragment/额外 username 入口;当前不改主 username,只校验 username 格式与 change_info 权限后返回 BoolTrue,避免误删 `channels.updateUsername` 的主 username | +| channels.deactivateAllUsernames | stub | permission-ok | Fragment/额外 username 入口;当前无 purchased username 模型,仅校验 change_info 后返回 BoolTrue | +| channels.updateColor | done | real-appearance | 持久化 `channels.color/profile_color` 与 background emoji id,保留 color flag 显式 0;校验 access_hash + change_info 后返回/推送带 `Channel.color/profile_color` 的 `updateChannel`;boost level 暂不强制,避免本地测试频道外观入口失效 | +| channels.updateEmojiStatus | done | real-minimal | 支持 `emojiStatusEmpty` 清除与普通 `emojiStatus(document_id,until)` 持久化并输出到 `Channel.emoji_status`;collectible emoji status 缺少 gift/read model 时返回 `EMOJI_STATUS_INVALID`,不伪造 collectible 元数据 | +| channels.exportMessageLink | done | real-message-link | 校验 channel/access_hash、msg_id 范围和当前成员可见的单份 channel message;公开 channel 返回 `t.me/{username}/{msg_id}`,私有 channel 返回 `t.me/c/{channel_id}/{msg_id}`,`thread=true` 且消息有 reply root 时追加 `?thread={root_id}`;`grouped/html` 与 public discussion `comment=` 精细链接后续补 | +| channels.readMessageContents | done | real-partial | 校验 channel/access_hash、id vector cap=100 与可见 exact message;对存在消息向当前用户其它在线 session 推 `updateChannelReadMessagesContents`,当前无 media/reaction content-read 持久状态,不生成 pts | +| channels.reportSpam | stub | ok | 校验 channel、participant 与 id cap=100 后返回 BoolTrue;风控/举报队列后续补 | +| channels.getLeftChannels | done | real-left-export | TDesktop takeout/export 路径;按当前 user 的 left channel/supergroup membership 返回有界 pageSize=100,offset<=10000,带 full count 与 left channel flag;最终非空页返回 `messages.chats`,越界空页返回空 `messages.chatsSlice` 让导出流程结束 | +| channels.getInactiveChannels | done | real-least-active | Premium limits 路径;按当前用户 active 频道/超级群的可见 top message date 旧到新返回,dates 与 chats 对齐,limit cap=100;不实现 Premium 限额策略 | +| channels.getGroupsForDiscussion | done | real-owned-megagroups | 讨论组选择入口;返回当前用户可管理的 megagroup/supergroup(无 legacy basic group),按 ID 倒序限流,TDesktop 可直接选择 | +| channels.setDiscussionGroup | done | real-link | 校验 access_hash、broadcast/group 类型、管理权限与 hidden prehistory;双向维护 `linked_chat_id`,清理旧链接,返回 BoolTrue 并推送相关 `updateChannel` | +| channels.editLocation | stub | permission-ok | geogroup 未接入;校验 change_info 后返回 BoolTrue | +| channels.convertToGigagroup | stub | updateChannel | 当前 createChat 已直接创建 megagroup;校验 change_info 后返回 `updateChannel` | +| channels.deleteParticipantHistory | done | bounded-delete | 管理员按 `participant` 的 `sender_user_id` 分批软删 channel_messages,单批 cap=1000,返回 `affectedHistory(offset=1 表示仍需续删)` 并生成 `updateDeleteChannelMessages`,`pts_count=len(ids)` | +| channels.toggleJoinToSend | done | real-join-settings | 持久化 `join_to_send`,校验 megagroup + invite/admin 权限,返回/推送带 flags.28 的 `updateChannel` | +| channels.toggleJoinRequest | done | real-join-settings | 持久化 `join_request`,仅 public megagroup 可开启;`channels.joinChannel` 对非成员写入 pending public join request 并返回 `INVITE_REQUEST_SENT`,并向管理员推有界 `updatePendingJoinRequests`;approve/dismiss 复用 `messages.hideChatJoinRequest` 有界路径 | +| channels.toggleForum | done | real-setting | 持久化 megagroup `forum/forum_tabs`,仅 creator 可改;已绑定 discussion group 时返回 `CHAT_DISCUSSION_UNALLOWED`;返回/推送 `updateChannel` 并写 `channelAdminLogEventActionToggleForum`;`messages.getForumTopics*` 与 create/edit/pin/reorder/deleteTopicHistory 已支持虚拟 General + 持久化 topic store | +| channels.toggleAntiSpam | done | real-setting | megagroup 管理员可持久化 `channels.antispam`,返回/推送 `updateChannel`;`ChannelFull.antispam` 恢复 TDesktop 开关状态,并写 `channelAdminLogEventActionToggleAntiSpam`;真实反垃圾删除管线未接入 | +| channels.reportAntiSpamFalsePositive | stub | ok | 校验 change_info 与 msg_id 后返回 BoolTrue;真实 antispam admin-log 后续补 | +| channels.toggleParticipantsHidden | done | real-hidden-members | 持久化 `channels.participants_hidden`;creator/具备 ban_users 的管理员可切换,返回/推送 `updateChannel`;`ChannelFull.participants_hidden` 恢复 UI 状态,非管理员成员 `getParticipants(recent/search/bots/contacts/mentions)` 只返回 aggregate count 不暴露列表,`messages.getMessageReadParticipants` 返回空避免隐藏成员场景泄漏已读名单 | +| channels.toggleViewForumAsMessages | done | real-local-dialog | 持久化当前账号 `channel_dialogs.view_forum_as_messages`;返回/可靠投递 `updateChannelViewForumAsMessages` 同步同账号其它 session,并在 `messages.getDialogs` 的 `Dialog.view_forum_as_messages` 与 `channels.getFullChannel` 的 `ChannelFull.view_forum_as_messages` 回填;forum topic create/read/edit/pin/reorder/delete 已接入 | +| channels.getChannelRecommendations | done | real-public-broadcasts | TDesktop similar/recommended 入口;返回公开 username broadcast channel,指定 source 时校验 access_hash 且排除 source,无 source 时排除当前用户 active membership;默认 10、cap=100,真实相似度/订阅画像/Premium 扩容后续补 | +| channels.setBoostsToUnblockRestrictions | stub | permission-update | boost bypass 未接入;按 Layer225 限制 boosts 为 0..8(0 关闭)并校验 change_info 后返回/推送 `updateChannel` | +| channels.setEmojiStickers | stub | empty-only | 当前无 custom emoji sticker store/boost gating;仅 megagroup + `inputStickerSetEmpty` 清空入口做权限校验 no-op 成功,非空 sticker set 返回 `STICKERSET_INVALID` | +| channels.restrictSponsoredMessages | done | real-setting | 持久化 `channels.restricted_sponsored`,校验 change_info 后返回/推送 `updateChannel`;`channels.getFullChannel` 回填 `ChannelFull.restricted_sponsored`,真实广告投放/boost 等级校验后续补 | +| channels.searchPosts | done | real-public-post-search | 全局公开频道/超级群帖子搜索;按 Layer225 校验 query/hashtag 二选一、文本长度<=256、limit cap=50、offset_id 范围、offset_peer 可为空或公开/可见 channel peer、paid stars 非负;只搜索 username 非空且未删除的公开 channel_messages,PG 走 body trigram `ILIKE` + `(message_date,channel_id,id)` seek 分页,满页返回 `messages.messagesSlice(next_rate,search_flood)` | +| channels.updatePaidMessagesPrice | done | real-setting | 持久化 `channels.send_paid_messages_stars` 与 broadcast `channels.broadcast_messages_allowed`;按 TDesktop app config 默认限制 stars<=10000,supergroup 负数返回 `STARS_AMOUNT_INVALID`,broadcast direct messages 允许 `-1` 表示关闭;返回/推送 `updateChannel` 并在 `Channel`/`ChannelFull` 回填状态;真实 paid messages/monoforum/结算后续补 | +| channels.toggleAutotranslation | done | real-setting | 持久化 `channels.autotranslation` 并记录 admin log `toggle_autotranslation`;校验 change_info 后返回/推送 `updateChannel`,`channels.getChannels` 回填 `Channel.autotranslation` | +| channels.getMessageAuthor | done | real-partial | monoforum 专有权限未接入;按可见 channel message 精确 id 查 sender 并返回 user,找不到或非用户作者报 MESSAGE_ID_INVALID | +| channels.checkSearchPostsFlood | stub | free-bounded | 校验 query 必填且长度<=256,返回 query_is_free=true 与剩余免费次数,避免 Search Posts 付费检查阻塞;真实付费/限额模型后续接入 | +| channels.setMainProfileTab | stub | permission-ok | profile tabs 未接入;校验 change_info 后返回 BoolTrue | +| channels.getMessages | done | real-channel | 按 channel_id + InputMessageID 精确拉取 channel_messages,单次 cap=100;稀疏 id 走 `id=ANY`,不存在返回 messageEmpty | +| channels.getParticipants | done | real | 支持 recent/admins/search/kicked/banned/bots/contacts/mentions 最小过滤语义,limit cap=200、offset cap=10000;banned/kicked 对非 admin 不暴露管理列表,搜索走 user 字段匹配,避免恶意深分页拖垮 PG | +| channels.getParticipant | done | real | 返回当前/指定 user 的成员身份与权限;`inputPeerSelf` 对普通成员返回 `channelParticipantSelf`,避免 TDesktop 记录 `Got self regular participant`;缺失按 TL 错误映射 | +| channels.inviteToChannel | done | real | 校验 invite 权限与用户数量 cap,创建成员/dialog,megagroup 生成 service message;单人重复邀请返回 `USER_ALREADY_PARTICIPANT`,被踢/禁看用户只能由 creator 或具备 ban_users 的 admin 恢复,普通成员单人恢复返回 `USER_KICKED`;broadcast 不写普通成员服务消息 | +| channels.joinChannel | done | real | 创建/恢复 member/dialog;megagroup 生成 join service message;重复加入返回 `USER_ALREADY_PARTICIPANT`;join/rejoin read 水位推进到加入动作后的 top,避免旧历史计入未读或后续打开会话时批量生成旧消息读回执 | +| channels.leaveChannel | done | real | 标记 member left,更新 participants_count;megagroup 生成 leave service message | +| channels.readHistory | done | real | TDesktop 可能直接调用 channels.readHistory;语义同 messages.readHistory(InputPeerChannel),含发送方 `updateReadChannelOutbox` 在线通知 | +| channels.deleteMessages | done | real-channel | 管理员/作者权限校验后软删单份 channel messages,生成有界 `updateDeleteChannelMessages`,`pts_count=len(ids)`,单次 id cap=1000 | +| channels.deleteHistory | done | real-partial | `for_everyone` 执行一个有界管理员删除 page 并推 `updateDeleteChannelMessages`,单批 cap=1000;该 TL 返回 `Updates` 且 TDesktop 不读取 offset,禁止在同步 RPC 内循环构造超大 id/update;非 for_everyone 只清当前用户可见历史/read/dialog,不写扩散、不生成超大 update | +| channels.editAdmin | done | real | creator 或具备 add_admins 的 admin 可更新 admin rights/rank;非 creator 只能授予自己拥有的权限;禁止改 creator;写 `channel_participant` durable event,返回/在线推 `updateChannelParticipant/updateChannel/updateChannelTooLong(pts)` | +| channels.editBanned | done | real | creator 或具备 ban_users 的 admin 可更新 banned rights/kicked 状态,刷新 participants/admin/banned/kicked 计数;写 `channel_participant` durable event,返回/在线推 `updateChannelParticipant/updateChannel/updateChannelTooLong(pts)` | +| channels.editTitle | done | real | creator/change_info admin 可改标题;写 megagroup service message `messageActionChatEditTitle` 与 channel pts,返回 `updateChannel + updateNewChannelMessage` | +| channels.editPhoto | done | real-photo | change_info 权限校验后解析 `inputChatPhoto`:`inputChatUploadedPhoto` 组装上传→建 Photo,`inputChatPhoto{inputPhoto}` 引用已存在照片,`inputChatPhotoEmpty`/`inputPhotoEmpty` 清除;落 `channels.photo_id/photo_dc_id/photo_stripped` 反范式列,`tgChannel.Photo`(ChatPhoto)/`tgChannelFull.ChatPhoto` 渲染真实头像,返回 `updateChannel` + 推 channel state;服务端无 Files 时按 `PHOTO_INVALID` 处理;in-history `MessageActionChatEditPhoto` service 消息留 todo | +| channels.deleteChannel | done | real | creator 权限,标记 channel deleted,返回/推送 `updateChannel + channelForbidden`,dialog 列表过滤 deleted channel;后续 admin log 细化 | +| channels.getAdminLog | done | real-minimal | 校验 creator/admin,按 channel admin log event store 有界查询;支持 actor admins、events_filter、q、max_id/min_id/limit,覆盖 metadata、成员权限、pin、send/edit/delete;forum/group_call/subscription 等长尾 action 待对应业务模型 | +| messages.updatePinnedMessage(InputPeerChannel) | done | real-channel | pin/unpin channel message,校验 pin 权限,更新 `channels.pinned_message_id`,写 `updatePinnedChannelMessages(pts_count=1)`;不生成超大 update | +| messages.unpinAllMessages(InputPeerChannel) | done | real-channel-single | TDesktop pinned section 清空入口;当前 channel 模型只维护单个 `pinned_message_id`,有置顶则复用单条 unpin 生成 `affectedHistory` 与 `updatePinnedChannelMessages`,无置顶返回当前 channel pts + `pts_count=0`;forum/monoforum 参数有界 no-op,后续多置顶模型再做分页 offset | +| stats.getBroadcastStats / stats.getMegagroupStats | stub | empty-graphs | TDesktop 频道/超级群统计页入口;校验 channel/access_hash/admin 与 broadcast/megagroup 类型后返回可解析空图表,真实统计聚合后续补 | +| stats.getMessageStats / stats.getMessagePublicForwards | stub | empty-graphs | TDesktop 消息统计/公开转发入口;校验 channel/access_hash/admin/msg_id,public forwards `limit<=100`、`offset<=128` 后返回空结果,避免 unknown RPC 与无界分页 | +| stats.loadAsyncGraph / stats.getStoryStats / stats.getStoryPublicForwards / stats.getPollStats | stub | empty/error | Layer 225 stats 域剩余入口全部显式注册;graph token 过长返回 `statsGraphError`,story/poll 校验 peer/id/limit 后返回空图表或空 public forwards | +| premium.getBoostsStatus | stub | zero-status | TDesktop 频道统计、颜色/权限和 boost 弹窗入口;校验 channel/access_hash 后返回零 boost 状态和必填 `boost_url` 空串,不写入虚假 boost 状态 | +| premium.getBoostsList / premium.getUserBoosts | stub | empty-list | TDesktop boost 列表入口;校验 channel/access_hash/admin/user,`limit<=100`、`offset<=128` 后返回空列表,真实 boost 聚合后续补 | +| premium.getMyBoosts / premium.applyBoost | stub | empty-noop | TDesktop boost 弹窗入口;`getMyBoosts` 返回空槽位,`applyBoost` 校验 slots 存在、非空、数量上限后空 no-op,真实 premium/boost 模型后续补 | +| messages.exportChatInvite(InputPeerChannel) | done | real-channel | 支持导出 channel invite link,校验 invite/change_info 权限,支持 expire/usage_limit/request_needed/title 与 legacy revoke permanent | +| messages.checkChatInvite | done | real-channel | 按 invite hash 返回 `chatInvite` / `chatInviteAlready`,处理过期、撤销、已封禁与已加入状态 | +| messages.importChatInvite | done | real-channel | 通过 invite hash 加入 channel/megagroup,megagroup 生成 join service message 与 channel pts;read 水位先落到加入前 top,再推进到自己的 join service,避免旧历史计入未读或打开会话时批量生成旧消息读回执;PG 在事务内锁 invite row 后检查/递增 usage_count,usage limit 满返回 `USERS_TOO_MUCH`,request_needed 持久化 pending importer、返回 `INVITE_REQUEST_SENT` 并向管理员推 `updatePendingJoinRequests`,用户后续通过其它 invite 加入会清理旧 pending request | +| messages.getExportedChatInvites / messages.getExportedChatInvite | done | real-invite-list | TDesktop invite links 管理页入口;校验 channel/access_hash/admin、admin user、link、limit/offset 边界后按 admin/revoked 返回持久化 invite 列表与 detail;`limit<=100`,分页使用 `offset_date + offset_link` seek,不做 OFFSET 大分页 | +| messages.editExportedChatInvite / messages.deleteExportedChatInvite / messages.deleteRevokedExportedChatInvites | done | real-invite-state | 支持 title/expire/usage_limit/request_needed 修改、永久链接 revoke 后返回 replacement、单链接删除与按 admin 清理 revoked invites;撤销链接仍可被管理页 detail 查询,import 会拒绝 revoked hash | +| messages.getAdminsWithInvites / messages.getChatInviteImporters | done | real-importers | TDesktop invite admins、importers、join request 管理入口;按 admin 聚合 active/revoked invite 数,importer 支持 requested/link/q/offset_user/offset_date 查询;`limit<=100`、link/q 长度有界,`q+link` 返回 `SEARCH_WITH_LINK_NOT_SUPPORTED` | +| messages.hideChatJoinRequest / messages.hideAllChatJoinRequests | done | real-join-request | 支持 pending join request approve/dismiss;approve 复用 channel join 流并生成 megagroup join service update,dismiss 清理 pending;响应和管理员在线 push 都包含新的 `updatePendingJoinRequests`;`hideAll` 单批 cap=1000,避免对超大 dialog 硬生成无界 updates | + +## Contacts / Users + +| method | status | behavior | note | +|---|---|---|---| +| contacts.getContacts | done | real | 从 contacts 表查询当前账号通讯录;支持 hash notModified;返回当前 owner 保存的姓名/电话/备注视角,互相关系按双方是否互存维护 | +| contacts.getContactIDs | done | real | 返回当前 owner 通讯录 user_id 列表,支持 hash notModified | +| contacts.getStatuses | done | persisted-presence | 返回当前通讯录联系人 `contactStatus`;状态来自运行时 presence、活跃 session 兜底与持久化 `users.last_seen_at`,离线联系人返回精确 `userStatusOffline.was_online`,未知用户退回 `userStatusRecently`。当前未接 account privacy,粗粒度 `recently/lastWeek/lastMonth/empty` 隐私降级仍为后续项 | +| contacts.importContacts | done | real | 按手机号匹配已注册用户,写入当前 owner 视角的联系人姓名/电话/备注,返回 imported/users,并写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | +| contacts.addContact | done | real | 写入当前 owner 视角联系人资料;禁止 self/空姓名/不存在 user,维护 reverse mutual,并写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | +| contacts.deleteContacts | done | real | 删除当前 owner 的联系人关系,并清理对方 reverse mutual;写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | +| contacts.updateContactNote | done | real | 更新当前 owner 对某个联系人的备注与备注实体,不影响其它 owner 对同一 user 的备注,并写 durable updateContactsReset + dispatch_outbox | +| contacts.search | done | real | TDesktop 搜索框 peer 分支;strip `@`、空/过短查询报 SEARCH_QUERY_EMPTY/QUERY_TOO_SHORT,limit cap=50;联系人 user 进 MyResults,非联系人 user 进 Results;公开 username channel/supergroup 同步返回 PeerChannel + Chats,当前已加入的放 MyResults,其它公开命中放 Results 并以 left chat 标记只读预览,避免 TDesktop 误显示已加入;用户搜索走手机号前缀/username/姓名/owner 保存姓名索引,公开频道搜索走 username/title trgm 索引 | +| contacts.resolveUsername | done | real | 按大小写不敏感 username 解析 user 或公开 channel/supergroup peer;channel 返回 PeerChannel + Chats;不存在返回 USERNAME_NOT_OCCUPIED,非法格式返回 USERNAME_INVALID | +| contacts.resolvePhone | done | real-partial | 按手机号解析 user peer;当前阶段未接完整 privacy,默认已知手机号可解析,未命中返回 PHONE_NOT_OCCUPIED | +| contacts.getBlocked | stub | empty | Settings 隐私/安全预取;第一阶段无 blocklist,limit cap=50 | +| contacts.getTopPeers | stub | disabled | 第一阶段不维护 top peers 统计 | +| contacts.getSponsoredPeers | stub | empty | 第一阶段不做 sponsored peers,TDesktop 搜索框分支返回 sponsoredPeersEmpty | +| users.getUsers | done | real | InputUserSelf 与已知 InputUser 返回用户(含 777000 官方账号);未登录则跳过(空列表) | +| users.getFullUser | done | real-minimal | InputUserSelf/已知 InputUser 返回 user + 最小 userFull;空账号主界面需要 | +| users.getSavedMusic | stub | empty | TDesktop 资料页/profile music/export 路径会按 offset/limit/hash 拉取;校验 input user、offset>=0、limit<=100,当前无 profile music media store,返回 `users.savedMusic{count=0,documents=[]}` | +| users.getSavedMusicByID | stub | empty | TDesktop saved music 文件引用刷新入口;校验 input user 与 documents vector cap=100,当前返回空 `users.savedMusic` | + +## Stories / Payments / AI Compose + +| method | status | behavior | note | +|---|---|---|---| +| stories.getAllStories | stub | empty | 第一阶段不做 stories,返回空列表与空 stealth_mode | +| stories.getStoriesArchive | stub | empty | TDesktop 主界面后台拉 archived stories;第一阶段返回空 archive | +| stories.getPinnedStories | stub | empty | TDesktop 资料页 stories 区域后台查询;当前返回空 `stories.stories` | +| stories.getAlbums | stub | empty | TDesktop 资料页 story albums 后台查询;当前无 story album 模型,返回空 `stories.albums` | +| stories.sendReaction | stub | validated-noop | 当前无 story store;校验 peer/story_id/reaction,`add_to_recent` 会更新账号 top/recent emoji reaction,返回空 updates,避免 story 反应入口落 unknown | +| payments.getStarGiftActiveAuctions | stub | notModified | 第一阶段不做 stars/gifts | +| payments.getSavedStarGifts | stub | empty | TDesktop 资料页 gifts 区域后台查询;当前返回空 `payments.savedStarGifts` | +| payments.getSavedStarGift | stub | empty | 单个 gift 详情入口;当前返回空 `payments.savedStarGifts` | +| aicompose.getTones | stub | notModified | 第一阶段不做 AI compose tones | + +## Langpack + +| method | status | behavior | note | +|---|---|---|---| +| langpack.getLangPack | done | real | PG-backed;启动 seed `tdesktop_en/zh-hans_v12000000.strings` | +| langpack.getDifference | done | real | 按 version 返回语言包差异;当前 seed 同版本全量 | +| langpack.getStrings | done | real | 按 key 查询 PG 中的语言包字符串 | + +## Files / Media / Photos + +| method | status | behavior | note | +|---|---|---|---| +| upload.saveFilePart | done | real-localfs | 累积 small file 分片到 `upload_parts`(PG),part>=0、单片 ≤512KB;需已登录 | +| upload.saveBigFilePart | done | real-localfs | 累积 big file 分片,校验 total_parts 上限;组装见 uploadMedia/uploadProfilePhoto | +| upload.getFile | done | real-localfs | 把 `inputDocumentFileLocation/inputPhotoFileLocation/inputPeerPhotoFileLocation` 推导为 `file_blobs.location_key`(document 查 `doc:[:type]`,photo 查 `photo::`),从本地 blob backend 按 offset/limit 切片返回 `upload.file`,storage type 优先按 bytes 魔数、再按 mime 判定,避免历史 seed 把 WebP thumb 误标 `image/jpeg` 后客户端解码失败;`location_key→FileBlob` 元数据 LRU 消除每 chunk PG 查,≤256KB sticker/reaction/thumb 小 blob 走 `object_key→bytes` LRU 并由启动 `WarmCaches` 预热;CDN/legacy `inputFileLocation`/`inputStickerSetThumb` 返回 `LOCATION_INVALID`(todo);location 仅按 `id` 解析,**不校验 `access_hash`/`file_reference`**(安全取舍见下方 note) | +| upload.getFileHashes | stub | empty | 本阶段不做 CDN/分片完整性校验,返回空 hash 列表(客户端信任数据) | +| photos.uploadProfilePhoto | done | real-photo | 组装上传分片→建头像 Photo(合成 a/c 尺寸,落 `photos`/`file_blobs`/`profile_photos`),设为当前头像,返回 `photos.photo{photo, 带头像 self}` 并向其它在线 session 推 `updateUser`+self(头像即时同步,见下方 note);仅支持 file 变体,fallback/video/emoji-markup 返回 `PHOTO_INVALID` | +| photos.updateProfilePhoto | done | real-photo | `inputPhoto` 把历史头像设为当前;`inputPhotoEmpty` 停用当前头像;变更后向其它在线 session 推 `updateUser`+self 同步头像 | +| photos.getUserPhotos | done | real-photo | 按 `profile_photos` 返回某用户头像历史(最新在前),offset/limit/max_id 有界,返回 `photos.photos[Slice]` + target user | +| photos.deletePhotos | done | real-photo | 按 inputPhoto id 停用头像,返回被删 id 列表 | + +> 头像渲染:`users` 表反范式 `photo_id/photo_dc_id/photo_stripped`,users 服务对 getUsers/getFullUser/self/resolve 批量富化,`tgUser`/`tgSelfUser` 输出 `userProfilePhoto`;channel 头像反范式 `channels.photo_*`,`tgChannel.Photo`/`tgChannelFull.ChatPhoto` 渲染。资源 seed 来自 `TELESRV_STICKER_SEED_DIR` 真实导出,启动时幂等导入(reactions/default 系统集/常规集)。 + +> 头像多设备同步:`uploadProfilePhoto`/`updateProfilePhoto`(含 `inputPhotoEmpty` 清除)变更后复用 `pushUserUpdates`,向该账号其它在线 session 推 `updateUser` + `Updates.users` 携带含新 `userProfilePhoto` 的 self;当前设备经 RPC 返回更新,TDesktop 经 `processUser→setPhoto→peerUpdated(Photo)` 即时刷新(与 参考实现 `MakeUpdatesByUpdatesUsers([self],updateUser)` 对齐;`updateUserName` 不含 photo 无法刷新头像)。联系人/对话方不主动广播头像(Telegram 同行为,对端下次拉取 user 时刷新);频道头像变更经 `channels.editPhoto` → `pushChannelStateToMembers` 推在线成员。 + +> 安全取舍(待生产补强):`upload.getFile` 与 `messages.sendMedia` 引用既有 `inputMediaPhoto`/`inputMediaDocument` 均只按 `id` 解析、**不校验 `access_hash`/`file_reference`**(不返回 `FILE_REFERENCE_EXPIRED`),依赖 64-bit `crypto/rand` 不可枚举 id 防越权——任意登录用户若得知某 `document`/`photo` id 即可引用/下载其内容。dev 单 DC 可接受,生产需补 owner/access_hash 校验与 file_reference 轮换。 + +> 媒体性能与容量债见 `performance-audit.md`「媒体管线」条目:`upload_parts` 无 GC/每用户配额(未 assemble 分片滞留 PG)、`media` JSONB 内联放大 fan-out 写、`getUserPhotos` N+1 + OFFSET 分页。`upload.getFile` 整文件入内存 + 每 chunk 查 PG 已于 2026-06-03 修:`BlobBackend.GetRange` 段读(`ReadAt`)+ `location_key→FileBlob` 元数据 LRU;sticker/reaction/thumb 首开冷路径已补 `object_key→bytes` 小 blob LRU、完整 sticker set cache 与启动预热。 + +## Unknown Trace + +未注册 RPC 经 rpc.Router fallback 记录到日志(type_id + layer),返回 NOT_IMPLEMENTED rpc_error。 +最近一次 TDesktop 联调发现的异步预取 trace 已全部转入上方各分区并实现第一阶段空响应;2026-05-31 复跑登录注册→空账号主界面后,无 `Unhandled RPC` / `NOT_IMPLEMENTED`。 + +| method/type_id | first_seen | raw_note | +|---|---|---| +| — | — | 当前无未实现 RPC trace | \ No newline at end of file diff --git a/docs/message-module.md b/docs/message-module.md new file mode 100644 index 00000000..cd9b69de --- /dev/null +++ b/docs/message-module.md @@ -0,0 +1,252 @@ +# Message Module Design + +Date: 2026-05-31 + +## Scope + +二阶段先实现私聊文本闭环: + +- `messages.sendMessage` +- `messages.forwardMessages` +- `messages.getDialogs` +- `messages.getHistory` +- `messages.readHistory` +- `messages.editMessage` +- `messages.deleteMessages` +- `messages.deleteHistory` +- `updates.getState` +- `updates.getDifference` +- 在线 session update 推送 + +不在本阶段实现:媒体、定时消息、群组/频道、Bot API、文件 DC。 + +## Storage + +大表从第一版开始分区: + +| table | partition key | purpose | +|---|---|---| +| `private_messages` | `sender_user_id` HASH | 共享私聊消息主体;`sender_user_id + random_id` 保证发送幂等 | +| `message_boxes` | `owner_user_id` HASH | owner 视角消息盒;TDesktop 看到的 message id 即 `box_id` | +| `dialogs` | `user_id` HASH | 会话摘要;`folder_id=0/1` 表示主列表/归档,置顶、manual unread、action bar 隐藏均是 owner 视角 | +| `dialog_filters` / `dialog_filter_settings` | `user_id` HASH | TDesktop 自定义 dialog filter、filter 顺序与 folder tags 开关;不把自定义 filter 伪装成 dialogs.folder_id | +| `user_update_events` | `user_id` HASH | 账号级 pts durable log;承载新消息、已读 inbox/outbox、文本编辑、删除消息,也承载 contacts reset、dialog pinned/order/manual unread、peer settings、dialog filters 与 folder peers 等 owner 视角状态事件 | +| `dispatch_outbox` | `target_user_id` HASH | transactional outbox,事务后批量推送在线 session;投递成功即删除,仅保留未完成任务 | + +Redis 只存可恢复计数: + +- `counter:pts:{user_id}`:账号级 pts。 +- `counter:box_id:{user_id}`:owner 视角 box_id。 +- `ratelimit:messages:send:{user_id}`:发送窗口限流,当前为每用户每分钟 30 条。 + +Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box_id)` 恢复。 +分配器热路径使用 Redis Lua 脚本递增;首次 miss 时先从 PG durable log 读取恢复值,再通过恢复脚本完成「初始化 + 首次自增」,避免并发 first-use 把 `pts` / `box_id` 分配出重复或回退。 + +## Send Flow + +`messages.sendMessage` 在 RPC 层只做 TL 转换和当前 user/session 校验,业务写入由 `MessageStore.SendPrivateText` 单事务完成: + +1. 写 `private_messages`,遇到同 `sender_user_id + random_id` 直接返回原消息盒。 +2. Redis 分配 sender/recipient 的 `box_id` 与 `pts`。 +3. 写 sender/recipient `message_boxes`,并保存 `silent/noforwards/reply_to/fwd_from` 元数据;reply 会把当前 owner 的 `reply_to_msg_id` 翻译成对端 owner 视角的 box_id。 +4. upsert 双方 `dialogs`。 +5. 写双方 `user_update_events(new_message)`。 +6. 写双方 `dispatch_outbox`,sender 侧带 `exclude_session_id`。 +7. 提交后由 outbox worker 批量推送在线 session。 + +若事务失败但 Redis 已分配 pts,store 会尽力写 `noop` 事件占位,避免 pts 回退;PG 不可用时该补偿也可能失败,后续需要告警指标覆盖。 + +## Forward / Reply Flow + +`messages.forwardMessages` 当前覆盖私聊文本转发,参考实现 的业务语义但保持 telesrv 的 owner 视角模型: + +1. RPC 层校验 from/to peer、id/random_id 等长、单次最多 100 条;scheduled/monoforum/quick reply/effect/paid/suggested 等当前阶段外能力用显式 TL 错误拒绝,目标为 channel 的 `send_as` 交由频道模块校验 self/current channel。 +2. store 按当前 owner + from peer + box_id 保序读取源消息,源消息带 `noforwards` 时返回 `CHAT_FORWARDS_RESTRICTED`。 +3. 未设置 `drop_author` 时,若源消息已有 forward header 则沿用;否则用源消息 `from/date` 生成 `messageFwdHeader`。`drop_author` 会去掉 forward header。 +4. 每条转发复用 `SendPrivateText` 写入双端 message box / dialog / update event / dispatch outbox,`pts_count=1`,并使用对应 random_id 保证幂等。 + +`messages.sendMessage` 的 `reply_to` 支持 `inputReplyToMessage` 的私聊同 peer 回复和 quote 文本/entities/offset;`quote_text` 按 TDesktop `quote_length_max=1024` 限制,`quote_offset` 是原消息文本内 offset,不是 message id,按当前文本消息上限 4096 收口。cross-peer reply、story、monoforum、todo/poll reply 仍拒绝,但返回 `REPLY_MESSAGE_ID_INVALID` / `STORY_ID_INVALID` / `REPLY_TO_MONOFORUM_PEER_INVALID` / `POLL_OPTION_INVALID` 等显式错误,不再落 `NOT_IMPLEMENTED`。服务端保存 sender 视角和 recipient 视角各自的 `reply_to_msg_id`,避免一个 owner 的 box_id 泄漏到另一个 owner。 + +## Read Flow + +`messages.readHistory` 采用 owner 视角的双端回执模型,参考实现 的 inbox 已读链路和 参考实现 的 inbox/outbox read history event: + +1. reader 侧锁定当前 dialog,按 `max_id` 与当前可见 incoming message 计算新的 `read_inbox_max_id`,清零 unread_count 并清除 manual unread。 +2. 如果 reader 确实推进了已读水位,给 reader 生成 `updateReadHistoryInbox(pts_count=1)`,写入 `user_update_events + dispatch_outbox`。 +3. 同一事务中找出本次被读到的最新 incoming message,定位原 sender 的 dialog,推进 sender 侧 `read_outbox_max_id`。 +4. sender 水位真正推进时,给 sender 生成 `updateReadHistoryOutbox(pts_count=1)`,离线设备可通过 `updates.getDifference` 补齐。 + +发送消息时不会预先推进 sender 的 `read_outbox_max_id`;只有对端实际 readHistory 后才产生 outbox 已读回执,避免“刚发出就被标成已读”的假状态。 + +`messages.getOutboxReadDate` 复用 sender 侧 durable `read_history_outbox` 事件:先校验当前 owner 的 `msg_id` 是该 peer 下可见 outgoing message,再取最早一条 `max_id >= msg_id` 的 outbox read event 日期返回 `outboxReadDate`;未被读到返回 `MESSAGE_NOT_READ_YET`。PG 上有 `(user_id, peer_type, peer_id, max_id, date) WHERE event_type='read_history_outbox'` partial index,避免 TDesktop 已读详情查询扫全量 update log。 + +## Edit Flow + +`messages.editMessage` 当前只支持私聊文本编辑: + +1. RPC 层校验当前账号、peer、message id、文本长度和 entities;`inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media/reply_markup/quick replies/scheduled 返回显式 TL 错误。 +2. store 锁定当前 owner 的 message_box,确认它是当前 user 发出的 outgoing 私聊消息。 +3. 更新共享 `private_messages` 文本与 `edit_date`,并更新同一 private message 下所有未删除 owner message_box。 +4. 每个受影响 owner 各自分配 `pts`,写 `updateEditMessage(pts_count=1)` 与 `dispatch_outbox`。 +5. 当前请求设备直接拿到 `updates` 响应;其它在线设备走 reliable outbox,离线设备走 `updates.getDifference`。 + +如果文本和 entities 完全未变化,返回 `MESSAGE_NOT_MODIFIED`;非作者编辑返回 `MESSAGE_AUTHOR_REQUIRED`。 + +## Delete Flow + +`messages.deleteMessages` / `messages.deleteHistory` 以 owner 视角软删除 `message_boxes`,不会删除共享 `private_messages` 主体。默认 deleteHistory 清空后如果该 peer 已无可见消息,则删除当前 owner 的 dialog;后续任意新消息会通过正常 send/upsert 路径重建 dialog。`just_clear=true` 对齐 参考实现 语义:清空历史但保留一个空 dialog(当前阶段不生成 `messageActionHistoryClear` 服务消息)。 + +`revoke=true` 时按 `(message_sender_id, private_message_id)` 找到同一私聊消息在其它 owner 下的 message_box 并软删除。每个受影响 owner 都生成自己的 `updateDeleteMessages`,`message_ids` 是该 owner 视角 box_id,`pts_count=len(message_ids)`,并写入 `user_update_events + dispatch_outbox`。如果删除后仍有可见消息,dialog 的 top/unread 会按剩余消息重算;否则删除 dialog 或在 `just_clear` 下保留空 dialog。 + +全清也必须让所有被删的 owner 视角 message_id 最终进入 update/difference,但不能合成一个超大 update。`messages.deleteHistory` 单次最多删除 `MaxDeleteHistoryBatch=1000` 条,按 box_id 倒序批量软删并返回 `affectedHistory.offset=1` 表示客户端应继续发起下一批;每一批各自产生一条有界 `updateDeleteMessages`。`messages.deleteMessages` 单次 id 数限制为 `MaxDeleteMessageIDs=1000`,服务端还会丢弃 `<=0` 或超过 TL/PG int4 可表达范围的 id。 + +## Query Path + +- `messages.getHistory` 以 `owner_user_id + peer_user_id + box_id/date` 命中 `message_boxes` 分区索引;`offset_id`、`offset_date`、`max_id`、`min_id` 都下推为游标条件;`add_offset` 只允许 `[-100,100]` 的小窗口偏移,避免异常客户端把超大偏移变成 SQL 跳过扫描或内存 slice capacity。 +- `messages.search` / `messages.searchGlobal` 当前只覆盖当前 owner 私聊文本搜索;查询仍限定在 `owner_user_id` HASH 分区内,文本条件由 `pg_trgm` GIN 索引兜底。参考实现 的经验,未接外部全文搜索前禁止无索引大表模糊扫;后续群组/频道/全局多 peer 搜索应接专用搜索索引或 FTS。 +- `messages.getDialogs` 以 `user_id` 分区定位当前账号,再按 `top_message_date/top_message_id/peer_id` 做 seek pagination;folder_id=0/1 直接走 `dialogs.folder_id`,folder_id>=2 先取当前账号 `dialog_filters` 后按 include/exclude/contact 规则过滤;`hash` 与 `count` 基于当前筛选后的完整会话集计算。 +- `messages.getDialogFilters` 返回 `dialogFilterDefault` + 当前账号持久化 filters;`messages.updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags` 与 `folders.editPeerFolders` 都是 owner 视角写入,归档只允许 folder_id 0/1,自定义 filters 从 ID 2 开始。 +- `updates.getDifference` 只按 `user_id + pts` 顺序扫描 `user_update_events`,多设备各自的 `(auth_key_id,user_id)` state 只记录消费位置,不参与账号事件归属;离线设备通过同一条 durable log 恢复新消息、已读 inbox/outbox、文本编辑、删除消息、联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filters/order/reload 与 folder peers 变化,置顶顺序、peer settings flags、filter payload 与 folder peers 会随事件负载持久化。 + +## 参考实现 Comparison + +参考实现 的 `SyncUpdatesNotMe` 设计有两个值得借鉴的点:所有“其它端通知”收敛到 sync 服务,以及带 pts 的消息/已读类 update 会写 `user_pts_updates` 后再推 session。它的不足也很明确:`updatePeerSettings` 只在线 push 不入 durable pts 队列,`markDialogUnread` / `hidePeerSettingsBar` 仍有 TODO,离线设备可能依赖后续主动刷新才能看到状态变化。 + +telesrv 当前做得更进一步:联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filter/order/reload 与 folder peers 都写入账号级 `user_update_events`,并通过 Postgres `AppendWithDispatch` 在同一事务里写入 `dispatch_outbox`。`updatePinnedDialogs.order`、`updatePeerSettings.settings`、`updateDialogFilter.filter`、`updateDialogFilterOrder.order`、`updateFolderPeers.folder_peers` 都是 durable payload,不依赖后续主动刷新;在线投递由 outbox worker 负责,排除当前设备时同时携带 `exclude_auth_key_id + exclude_session_id`,避免同一 session_id 在不同 auth key 下误排除或串号。RPC handler 在检测到可靠 outbox 后不再额外手动 push,避免其它设备收到重复在线通知。 + +dialog 分组借鉴 参考实现 的 `dialog_filters`/`editPeerFolders` 业务语义与 参考实现 的 `UpdateDialogFilter` / `UpdateFolderPeers` 事件模型,但修正两点:filter 设置不仅在线 push,也有持久化真值表和 durable update;归档 folder 与自定义 filter 分离,避免一个 `folder_id` 字段同时承担归档状态和任意筛选规则。 + +删除链路借鉴 参考实现 的 `just_clear` / dialog 删除边界:普通 deleteHistory 会移除 dialog,`just_clear` 保留 dialog。借鉴 参考实现 的点是“先按 owner 计算受影响 message_id,再按 owner 重建 top message 并发 `updateDeleteMessages`”,而不是只对当前请求账号返回 affectedMessages;这样 revoke、多设备离线补偿和 dialog 后续重建都在同一条 owner 视角语义里。 + +已读和编辑链路也分别借鉴了两边的优点:参考实现 在 edit 时会校验 sender 并把编辑同步到 inbox/outbox,参考实现 把 readHistory 分成 reader 的 `UpdateReadHistoryInbox` 和 sender 的 `UpdateReadHistoryOutbox`。telesrv 将这两类事件都落到账号级 durable log,同事务写 reliable outbox,且 edit 更新共享消息主体和所有 owner 视角 message_box,避免只在线 push 或只改当前盒子导致离线设备/对端历史不一致。 + +reply/forward 的语义参考实现 的 handler:send/forward 都透传 `silent` 和 `noforwards`,reply 用 `inputReplyToMessage`,forward 在源消息没有 `fwd_from` 时构造原作者 header,遇到受保护内容拒绝转发。telesrv 额外修正 owner 视角 id 翻译与 durable update 负载:对端收到的 reply header 使用对端自己的 message id,离线设备通过 `updates.getDifference` 也能拿到同样的 `reply_to/fwd_from` 元数据。 + +## Observability + +消息链路 RPC 层预留 `Metrics` 接口,当前覆盖: + +- `MessageSend`:记录发送成功、payload 长度与双端 pts。 +- `MessageRateLimited`:记录用户级窗口限流。 +- `OutboxClaimed` / `OutboxDelivered` / `OutboxFailed`:记录 outbox claim、在线推送成功与重试失败。 + +默认实现是 no-op,生产接 Prometheus/OpenTelemetry 时只需在 `rpc.Deps` 注入实现,不污染业务服务和 store 边界。 + +## Next Execution Plan + +连接层已具备 per-connection outbound actor、scoped session context 与 bounded inbound RPC scheduler。消息模块下一轮按以下顺序推进,不再先扩 RPC 面: + +1. 压测基线:单机目标 200 msg/s 私聊文本、p99 sendMessage < 150ms、p99 getDifference < 100ms。已落地 `internal/loadtest` 真实 PG+Redis 压测 harness,首版基线见下「Load Baseline」节;1k online session 的网络 fanout 属连接层,本轮 harness 未覆盖(binder 用零连接 SessionManager,只测 outbox 的 PG 排空)。 +2. Redis 计数强一致:`pts` / `box_id` 分配已收敛到 Lua 脚本路径,覆盖 Redis miss 从 PG durable log 恢复与并发 first-use 连续分配;事务失败 noop gap 与 random_id 幂等已有 store 测试覆盖,后续补真实 Redis+PG 混合压测。 +3. PG 分区验收:已有 `message_boxes` history seek 与 `dispatch_outbox` stale retry 索引;已补 explain 集成测试,固定 `message_boxes` / `user_update_events` 单用户查询命中分区索引,`dispatch_outbox` 全局 claim 允许跨分区但必须走分区 partial index、不出现 Seq Scan。 +4. Outbox 背压(已做到生产级,见下「Load Baseline」):worker 有 batch claim、lease timeout、retry/backoff、failed 终态和 claim/deliver/fail 指标;batch size / interval / lease timeout / **worker 数** 均配置化(env `TELESRV_OUTBOX_BATCH` / `_INTERVAL` / `_LEASE_TIMEOUT` / `_WORKERS`)。**已并行化(N worker 竞争 `FOR UPDATE SKIP LOCKED` claim)并批量化(每批一次 `BatchListDispatchEvents` unnest join + 一次 `MarkDispatchDeliveredBatch`,取代逐条往返)**,排空上限从单 worker ~270 行/s 提到 ~12k 行/s(本机 PG,~45x);批量化后瓶颈转移到 PG 本身。主动 push 仍只走 `auth_key_id + session_id + user_id` scoped API。 +5. TDesktop 私聊闭环:用双账号/多设备验证 sendMessage、forwardMessages、reply、getDialogs、getHistory、readHistory、getOutboxReadDate、editMessage、deleteMessages/deleteHistory、updates.getDifference、当前设备过滤与退出换号不串号。2026-05-31 已用 Alice/Bob 两个 TDesktop workdir 验证在线 read/edit/delete 与 Bob 离线 send+edit/read/delete 差量同步;删除后 dialog 可清除,后续新消息仍按正常路径重建。2026-06-01 已补 reply/forward 服务端路径和单测,仍待重新用双 TDesktop 实机点 UI 行为(通知声音、reply 预览、forward header)。 +6. 兼容矩阵回填:所有真实跑到的未知 RPC 自动入 trace,按 TDesktop 首屏和私聊流程补 `done/stub/todo` 状态。 + +## Load Baseline + +压测 harness:[`internal/loadtest/send_load_test.go`](../internal/loadtest/send_load_test.go),env-gated,未设 `TELESRV_TEST_POSTGRES_DSN`+`TELESRV_TEST_REDIS_ADDR` 时 Skip,对默认 `go test ./...` 无副作用。 + +**方法**:与 main.go 一致装配连接池 + Redis 分配器 + PG 消息存储 + transactional outbox + 真实 `OutboxDispatcher`(binder 用零连接 `SessionManager`,`PushToUserExceptSession` 返回 0,因此 outbox 走完整 `claim→批量取事件→push→批量标记` 的 PG 往返,测排空而非网络 fanout)。closed-loop 饱和:`concurrency` 个 worker 各自不停发满 `messages` 条。`TELESRV_LOAD_DEFER_DISPATCH=1` 时先把积压攒满、发送结束才启 dispatcher,用以隔离测量 outbox **纯排空上限**(否则 dispatcher 实时跟上发送、积压近 0 量不出天花板)。 + +**运行**(PowerShell): + +```powershell +$env:TELESRV_TEST_POSTGRES_DSN = "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable" +$env:TELESRV_TEST_REDIS_ADDR = "localhost:6399" +# 稳态(dispatcher 与发送并发) +go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 240s +# 纯排空上限(先攒满积压再排空) +$env:TELESRV_LOAD_MESSAGES=30000; $env:TELESRV_OUTBOX_BATCH=200; $env:TELESRV_LOAD_DEFER_DISPATCH=1 +go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 300s +``` + +可调 env:`TELESRV_LOAD_USERS`(50) / `_CONCURRENCY`(32) / `_MESSAGES`(5000) / `_POOL_CONNS`(64) / `_DEFER_DISPATCH`;outbox 用 `TELESRV_OUTBOX_WORKERS`(loadtest 默认 8,运行时默认 2) / `_BATCH`(100) / `_INTERVAL` / `_LEASE_TIMEOUT`;`TELESRV_LOAD_ENFORCE_SLO=1` 时 SLO 超标硬失败(回归门禁)。正确性(无发送错误/无意外重复/全部发出/outbox 必须排空/无终态失败)始终硬断言。 + +**生产化做了什么**(2026-05-31): + +- **连接池配置化**(`TELESRV_POSTGRES_MAX_CONNS` 默认 50,`TELESRV_POSTGRES_MIN_CONNS` 默认 16,经 `postgres.WithMaxConns/WithMinConns`)。`postgres.Open` 会在启动时显式预热 min 连接,避免 TDesktop 双端冷启动时大量 RPC 一边建 PG 连接一边排队;pgx 默认 `max(4,NumCPU)` 在高并发下排队,是首版 send max 2s 突刺的根因。 +- **dispatcher 并行**(`TELESRV_OUTBOX_WORKERS`,运行时默认 2,loadtest 默认 8,`rpc.WithOutboxWorkers`)。N worker 各跑 claim 循环,靠 `FOR UPDATE SKIP LOCKED` 互不重叠;本地 TDesktop 双端启动默认偏保守,避免多个 worker 同扫 64 分区父表触发 PG lock/shared-memory 压力。 +- **dispatcher 批量**(`store` 具备批量能力时自动启用,否则逐条回退)。每批一次 `BatchListDispatchEvents`(双 `unnest WITH ORDINALITY USING(ord)` 配对 (user_id,pts) 的 join)+ 一次 `MarkDispatchDeliveredBatch`,把每条 ~2 次 PG 往返降到每批 ~3 次。 + +**outbox 排空上限演进**(本机 docker PG+Redis,攒满 6 万行积压后纯排空): + +| 方案 | drain 速率 | 相对 | +|---|---|---| +| 单 worker 逐条(首版) | ~270 行/s | 1x | +| 8 worker 逐条(仅并行) | ~1711 行/s | ~6x | +| **8 worker 批量(当前)** | **~12285 行/s** | **~45x** | + +稳态下(concurrent,出厂默认 users=1000/concurrency=32/8 worker/batch100):**峰值积压仅 ~330 行、排空 ~80ms**——dispatcher 实时跟上发送(~2500 行/s 入队);send p99 ~114ms、getDifference p99 ~2ms、吞吐 ~1255 msg/s,SLO 全过。12k 行/s ≈ 6k msg/s 可持续 outbox 吞吐;16 worker/batch500 仍 ~11.7k 行/s(与 8 worker 持平 → 瓶颈已是 PG 而非 worker 数)。 + +> send p99 对**用户池集中度**敏感:harness 用 50 用户时 p99 飙到 ~220–325ms(32 并发挤少数用户的 dialog/message_box 行,行锁争用),2000 用户降到 ~58ms。这是小池假象,生产 20 万用户分散后争用极低;默认已取 users=1000。 + +**面向 20 万在线的剩余 levers(按 §8 记账,本轮未做)**: + +1. **PG 已是共享瓶颈**:批量化后 send 与 drain 都打在同一 PG 上,本机 ~12k 行/s 触顶。生产需更强的 PG(多核/NVMe/调参),更高规模再上读副本 / 按 user 分库。worker/batch/池均可调以匹配硬件。 +2. **send 路径 Redis 分配已移出事务(已做)**:原先 sender/recipient 的 `pts`/`box_id` 分配(4 次 Redis 往返)嵌在 PG 事务内,持连接空等 Redis。现已移到 `BeginTx` 之前——分配本就走 Redis 不属 PG 事务,前移后不再在持有 PG 连接(与行锁)期间空等,连接周转更快(池受压时收益最大;收益随 Redis RTT 增大,远端 Redis 更明显)。本机 docker(Redis 亚毫秒 RTT)下 pool=24/conc=96(4x 超订)实测 send p99 ~130ms(<150ms)、~1800 msg/s、0 err 0 dup、SLO 全过。 + - **配套的 pts 连续性兜底(mtproto 对齐,已做)**:分配前移加宽了「pts 已分配未提交」的瞬时空洞窗口(并发发送 commit 重排序,本就存在),故 `updates` 服务改为只暴露**连续 pts**:`getState` 报告最大连续已提交 pts(非 allocator 最大已分配值),`getDifference` 只返回从客户端 pts 起连续的事件、遇空洞即截断、`State.Pts` 取最后连续值、超 `limit`(100) 置 `updates.differenceSlice`。客户端永不越过在途空洞,空洞由 commit/补洞在毫秒内自愈,下次拉取补齐——绝不丢消息。连续值由 `UpdateEventStore.MaxContiguousPts`(PG 顶部 4096 窗口)计算。正确性已由单测(空洞截断/slice 翻页/getState 连续)+ 真实 PG 集成测试(200 并发发送 pts 严格 1..200 无空洞无重复无丢失、PG 空洞场景)覆盖。 + - 仍可继续:把 4 次 Redis 分配 pipeline/合并成 1 次(每用户 pts+box 一个 Lua、sender/recipient 并行),进一步缩短分配耗时。 +3. **连接层 `SessionManager` 全局锁——已实测,非吞吐瓶颈(结论被数据修正)**:原假设是"pushToUser 抢全局锁 = 20 万在线最大瓶颈"。新增连接层 benchmark(`session_manager_bench_test.go`,20 万连接、内存 Conn 不走真实 socket)实测: + - **push fanout**:~4.6M push/s(24 核),延迟 1→24 核仅 249ns→328ns,**几乎不随核数恶化**——临界区只是 snapshot 一个 per-user 小 map,锁极短。20 万在线 @ 200 msg/s × 双端 = ~40 万 push/s,**比上限低一个数量级,push 不是瓶颈**。 + - **Register/Unregister churn**:全局**写锁**,~2067ns/op @ 24 核 ≈ 48 万 churn/s。稳态 churn(20 万连接 × 平均存活分钟级)≈ 千级/s,余量巨大。 + - mutexprofile 确认两者都 100% 串行在同一把锁,但因临界区极短,**当前规模下不构成瓶颈**。 + - **真正的风险点**(分片的价值所在,但非紧急):重连风暴(断网恢复 / 发版重启)时瞬时 churn 飙升,**写锁会阻塞同期所有 push 读锁**。按 userID 分片可把写锁从全局 1 把拆成 N 片,churn 风暴只影响 1/N 的 push——这是**稳健性/尾延迟**提升,不是吞吐解锁。建议留待真有重连风暴尾延迟问题、或单机连接数再上一个量级时再做。 + - 仍未覆盖:真实 socket 的 per-conn 加密/写出背压、20 万 fd 的内核态开销——那需要真实网络压测环境,非本 harness(内存 Conn)目标。 + +## Multi-Account / Multi-Device + +- `authorizations` 是 auth_key 到 user 的权威绑定。 +- active session 缓存 `auth_key_id + user_id`;router 额外按业务 auth_key 缓存 `user_id`,并用 singleflight 合并启动期并发 miss,避免同一永久 auth_key 派生的多个 temp session 重复查授权表。 +- `update_states` 主键是 `(auth_key_id, user_id)`;同设备退出登录或换账号只清 auth_key 设备状态,不删除账号级 `user_update_events`。 +- 一个账号多设备共享 `user_update_events`,各设备通过自己的 `auth_key_id + user_id` 状态和 `updates.getDifference` 补偿。 +- 联系人备注、dialog pinned/order/manual unread、peer settings 都是 owner 视角数据;写业务表后同步写账号级 durable update event,并把投递任务写入 `dispatch_outbox`,避免“只在线 push、离线设备永远不知道”的状态漂移。 + +## ACK / Global Sequence Note + +`参考实现` 的可借鉴点是:`msgs_ack` 不直接生成业务事件,它只确认某个已发送 server msg_id / RPC response 已被客户端收到;server 通过 ack cache 找回该响应对应的 `pts` / `globalSeqNo`,再推进 `(auth_key_id,user_id)` 维度的已确认水位。这个设计适合频道、多 peer 混合更新和 server 侧 delivered watermark。 + +当前阶段私聊文本只需要账号级 `pts` durable log,客户端 `updates.getDifference` 仍以请求里的 `pts` 为准;MTProto ACK 只释放出站重发缓存。后续引入频道或跨 peer 全局更新流时,再新增独立的 per-device `global_seq_no` / delivered watermark,不把它塞进 MTProto ACK 状态本身。 + +## 客户端 pts 重排依赖 pts_count 准确(TDesktop PtsWaiter,已核对源码) + +> 结论来自实读 TDesktop pinned baseline(`9caf32dffc`)源码: +> `tdesktop/Telegram/SourceFiles/data/data_pts_waiter.cpp`、`data_pts_waiter.h`、`api/api_updates.cpp`。 + +**背景**:outbox worker 多 worker 并发 claim + 发送事务乱序提交,所以**主动推送可能乱序到达客户端**(pts=6 的 `UpdateNewMessage` 先于 pts=5 发出)。这不会让客户端乱序或丢消息——前提是每条 update 的 `pts` / `pts_count` 准确。 + +**客户端如何处理乱序**(`PtsWaiter::check`,data_pts_waiter.cpp:170-187):客户端维护 `_good`(已应用的最大连续 pts)、`_last = max(见过的 pts)`、`_count += 每条 update 的 pts_count`: + +- 收到 pts=6(count=1):`_last=6, _count=5` → `_last > _count`(中间缺 pts)→ 这条 update **不应用,先缓存进 `_updatesQueue`**,`setWaitingForSkipped(1000ms)`。 +- 随后收到 pts=5(count=1):`_last=6, _count=6` → 相等 → `_good=6`,`applySkippedUpdates` 把缓存的 5、6 **按序应用**(data_pts_waiter.cpp:48-72)。 +- 若 1 秒(`kWaitForSkippedTimeout`,data_pts_waiter.h:24)内空缺没补上 → 触发 `getDifference` 主动补齐。 + +即客户端**缓存乱序、等空缺、按序应用,1 秒补不齐才 getDifference**——比"丢弃重拉"更优雅。普通私聊 updates 与 channel 复用同一个 `_ptsWaiter` 单例(调用时 channel 传 `nullptr`,api_updates.cpp:603-604)。 + +**对 server 的硬约束**:客户端这套重排完全依赖 `_count += pts_count` 累加,因此 server 必须保证: + +1. **每条 update 的 `pts_count` 准确等于它推进的 pts 步数**。私聊文本、已读 inbox/outbox、文本编辑恒为 1;批量删除为 owner 视角删除 message_id 数量:`UpdateDeleteMessages{Pts: event.Pts, PtsCount: len(message_ids)}`。 +2. **每个分配出去的 pts 最终都能被 getDifference 拿到**(即使事务回滚,也要写 `noop` 占位,见 `recordPtsGaps`)——否则连续水位永远卡在空缺处,客户端会永久 `getDifference` 重试或永久 gap。 + +**当前安全**:私聊文本、转发、已读回执、文本编辑 `pts_count` 恒为 1,删除消息 `pts_count` 等于删除数量;每个 pts 都有真实事件或 noop 占位,且 getState/getDifference 只暴露连续 pts(见上文 Load Baseline 的「pts 连续性兜底」)。**未来风险**:引入其它“一次操作产生多条 update / `pts_count ≠ 1`”的场景(批量 service action、频道 editMessage 等)时,若 server 的 `pts_count` 算错,客户端 `_count` 会永久错位 → 永久 gap。新增此类 RPC 时必须同步核对 pts_count 语义。 + +## Current Limits + +- `messages.sendMessage` 仅支持私聊文本;reply 仅支持同一私聊 peer 的 `inputReplyToMessage`。 +- `messages.forwardMessages` 仅支持当前 owner 可见的私聊文本消息转发到 user peer,单次最多 100 条。 +- `messages.editMessage` 仅支持文本消息编辑;网页预览 media 降级文本编辑,真实媒体、reply markup、quick replies、scheduled edit 留后续并返回显式 TL 错误。 +- `messages.deleteMessages` / `messages.deleteHistory` 仅支持私聊消息;`min_date/max_date` 当前作为兼容 no-op,服务消息 `messageActionHistoryClear` 留到后续消息类型扩展。 +- `messages.deleteHistory` 全清按 1000 条一批推进,`offset>0` 续删;`deleteMessages` 单次最多 1000 个 id,避免客户端构造超大数组或超大 `max_id` 导致服务端 OOM。 +- `messages.getHistory/messages.search` 的 `add_offset` 统一 clamp 到 `[-100,100]`;TDesktop 正常滚动只会使用小偏移,恶意超大值不能触发无界内存分配或大 SQL OFFSET。 +- `reply_markup`、quick replies、effects、paid send、suggested posts、story/monoforum/todo/poll reply 均返回客户端可理解的显式 TL 错误(如 `REPLY_MARKUP_INVALID`、`SHORTCUT_INVALID`、`EFFECT_ID_INVALID`、`PAYMENT_UNSUPPORTED`、`SUGGESTED_POST_PEER_INVALID`、`STORY_ID_INVALID`、`REPLY_TO_MONOFORUM_PEER_INVALID`、`POLL_OPTION_INVALID`),不再落 `NOT_IMPLEMENTED`;`send_as` 仅在目标为 channel 时按频道模块规则接受 self/current channel,私聊目标返回 `SEND_AS_PEER_INVALID`;目标为 channel 且请求未显式带 `send_as` 时,RPC 层读取 `messages.saveDefaultSendAs` 保存的默认身份并重新校验。 +- 定时消息返回 `SCHEDULE_DATE_INVALID`。 +- `messages.search` / `messages.searchGlobal` 当前是私聊文本 `ILIKE` + `pg_trgm`,后续大规模数据需要按语言/分词策略补全文索引或外部搜索服务。 +- 单条文本当前限制 4096 个 Unicode code point;超限返回 `MESSAGE_TOO_LONG`,触发窗口限流返回 `FLOOD_WAIT_X`。 + +## 媒体消息(2026-06-02) + +- `messages.sendMedia` / `uploadMedia` / `sendMultiMedia` 接入:RPC 层 `resolveInputMedia` 把 `inputMediaUploadedPhoto/Document`(组装 `upload.*` 分片→建 `Photo`/`Document`)与 `inputMediaPhoto/Document`(引用已存在资源,含贴纸)转成 `domain.MessageMedia`,经抽取的 `sendOutgoing` 走与文本完全相同的 pts/box/outbox/在线推送/离线 `getDifference` 路径;私聊与 channel 共用。 +- `private_messages` / `message_boxes` 增 `media` JSONB 快照列:发送在事务内随 body/entities 一起写双端盒子,历史 `getHistory`/`getMessages`/dialog preview 读取时随消息一并解码,无需 join `documents`/`photos`。转发复制源消息 media(同一文档引用);文本编辑保留 media。 +- `tgMessage` 在 media 非空时 `SetMedia(MessageMediaPhoto/Document)`,客户端经 `upload.getFile` 从 blob backend 下载。Document id 在 domain/store 中保持 telesrv-owned 正数;外部 seed source id 在导入阶段归一,`InputDocument` / `inputDocumentFileLocation` 入站直接按服务端 id 解析,避免把第三方导出 id 当成本服资源身份。 +- 仅媒体消息(无 caption):放宽 `private_messages` body 非空 CHECK 为 `body<>'' OR media<>'{}'`。 +- 范围外:grouped_id 相册聚合(sendMultiMedia 当前各条独立成消息)、geo/contact/poll/todo/dice/story media 仍 `MEDIA_INVALID`。 \ No newline at end of file diff --git a/docs/performance-audit.md b/docs/performance-audit.md new file mode 100644 index 00000000..046c1cad --- /dev/null +++ b/docs/performance-audit.md @@ -0,0 +1,249 @@ +# telesrv 性能审计报告 + +- **日期**:2026-06-02 +- **审计范围**:全部 140 个 Go 源文件(约 4.7 万行非测试代码)——连接层 `mtprotoedge`、`rpc`、业务 `app/*`、`store`(PostgreSQL / Redis / memory)、`queries/*.sql` 与 55 个迁移的索引。 +- **方法**:5 个并行深度审计(PG 私聊查询 / PG channel 查询 / 连接层 fan-out / RPC 私聊热路径 / channel RPC + Redis),再对全部 P0 逐条读码核实(见附录 A)。 +- **性质**:本文是当前阶段的**性能快照与债务维护清单**。RPC 实现进度仍以 [compatibility-matrix.md](compatibility-matrix.md) 为权威;性能项完成后需在本文记录状态、验证方式与剩余阻断。 +- **当前阶段**:已不再是第一阶段/纯私聊阶段;当前目标是 TDesktop 主路径功能闭环 + 性能/可靠性硬化,大规模群组/频道放开前必须先清掉 P0 阻断项。 +- **本轮落地状态(2026-06-02 / 2026-06-03)**:已修复 P0-1/P0-2/P0-4/P0-5、P1-b/P1-e/P1-i/P1-l、`users.getFullUser.common_chats_count` 反向规划 channel 分区、媒体 range read/元数据 LRU/sticker 小资源预热与 P2 update watermark/retention 的公共根因;P0-6 的 AES key schedule 属 MTProto per-message msg_key 成本,不做错误缓存优化,先保留 buffer/编码压测项;P1-a 受 `gotd/td` transport 抽象限制,禁止修改 `github.com/gotd/td`,需另做 telesrv-owned transport writer spike。 +- **严重度口径**:`P0` 当前即瓶颈 / 开闸即炸;`P1` 上量级会炸或高频路径浪费;`P2` 浪费但不致命;`P3` 轻微。 +- **规模假设**(用于判断后果):单机目标 20 万在线 TCP 长连接;私聊 ~200 msg/s × 双端写;在线推送 fan-out ~40 万 push/s;PostgreSQL(默认连接池 50)+ Redis。 + +--- + +## 1. 总体结论 + +**基础工程素养很好**——大表按 user / channel 做 HASH 64 分区、私聊热路径 keyset 游标分页(无大 OFFSET)、服务端 limit 钳制几乎覆盖所有 RPC、`convert` 层预分配 + 去重、send 路径 Redis 已合并单 Lua、outbox 已批量化、SessionManager 锁临界区已被实测确认极短。**没有发现「按客户端巨值无界分配」这类可被单请求 DoS 打爆的洞。** + +真正的性能债高度集中,归为**两条主线**: + +| 主线 | 何时致命 | 性质 | +|---|---|---| +| **A. channel / 群组 fan-out 乘积放大** | 放开群组规模后 | 一条群消息触发「全员写 + 全量排序 + 数千次单查」的乘积爆炸 | +| **B. 连接层出站热路径 + 服务层缺批量** | 当前 TDesktop 主路径上量后放大 | 全局锁串行、每消息多次堆分配 / syscall、N+1 | + +> 注:channel 代码虽已实现并通过功能联调,但**性能压测主要覆盖私聊**。主线 A 的几项在小规模群联调下不会暴露,需在放开大规模群组前先拆。 + +--- + +## 2. 核心洞察:一条群消息的乘积放大链 + +发一条 megagroup 消息,会同步触发**四段独立放大**(全部经代码核实,见附录 A): + +``` +发送 1 条群消息(设 10 万成员,其中在线池 20 万里有该群成员若干) +│ +├─① 存储层:upsertChannelDialogsForMessageTx internal/store/postgres/channel.go:7958 +│ 对【全体活跃成员】INSERT…ON CONFLICT,WHERE 无 LIMIT +│ → 写 ~100,000 行 / 条消息,全在发送事务内,长持行锁 + 撑爆 WAL +│ +├─② 选收件人:pushChannelUpdates → OnlineChannelMemberUserIDs +│ 已从旧的全局在线 map 遍历改为 channel→online session 成员索引 +│ → 持久 channel updates 按当前在线 active 成员 best-effort 推送;typing/reaction 等瞬时事件走 viewer 索引 +│ +├─③ 推送构造:pushChannelUpdates 对【每个 recipient】调 build(viewer) internal/rpc/channels.go:3053 +│ 发送者 / 频道信息对所有 recipient 相同,却被重算 500 遍 +│ +└─④ 每次 build 内:tgUsersForIDs 逐个 Users.ByID internal/rpc/channels.go:3143 + 而 Service.ByID 每次先查 Self 再查目标(×2) internal/app/users/service.go:50 + → 500 viewer × ~5 引用 × 2 ≈ 5,000 次单行 PG 查询 / 条消息 +``` + +**旧链路合计:单条群消息 ≈ 10 万行写 + 一次 20 万项排序 + ~5,000 次单行查询。** 当前已拆掉全局在线排序和 users 公共 N+1,剩余风险集中在大群 dialog 写放大、收件人无关 update 模板化,以及 channel 读 RPC 的专用批量化。 + +--- + +## 3. P0 — 必修(当前即瓶颈 / 开闸即炸) + +### P0-1 广播频道发消息对全员同步写 dialog(无上限写放大) +- **位置**:`internal/store/postgres/channel.go:7958` `upsertChannelDialogsForMessageTx`,由 `sendChannelMessageOnce` 在每条消息事务内调用。 +- **问题**:`FROM channel_members WHERE channel_id=$1 AND status='active'` 无 LIMIT,每发一条消息写 / 更新 = 成员数 行;讨论组联动再来一遍。 +- **影响**:唯一随频道规模**线性放大且在同步关键路径**的写。百万成员频道单条 post 跑数秒、长持行锁、阻塞该频道后续所有写。 +- **建议**:广播频道(broadcast)不维护 per-member dialog,改 Telegram 式「单一 read state + 读时按 `top_message_id` vs `read_inbox_max_id` 懒算未读」(`ListChannelDialogs` 已有该回退逻辑,fan-out 行其实可删);megagroup 设成员阈值,超阈值改异步 / 分批 job。 +- **状态**:已加同步写闸门:broadcast 跳过全员 dialog 写;megagroup 仅 `ParticipantsCount <= 1000` 时同步写,未知/超阈值不做全员写。读取端已不再信任 broadcast/大超级群的 `channel_dialogs.unread_count` 缓存,改按 read watermark、`available_min_id`、channel top 与未删除消息动态派生普通未读。仍需大频道 PG `EXPLAIN` 与端到端压测。 + +### P0-2 `OnlineUserIDs` 每次 fan-out 全量遍历 + 排序 20 万在线 +- **位置**:`internal/mtprotoedge/session_manager.go:469`。 +- **问题**:截断发生在排序**之后**——为拿 500 个先把 20 万个全 append 全 `sort.Slice`,且全程持 `m.mu.RLock`。 +- **影响**:每次 channel 更新都触发 O(n log n) 排序 + ~200KB 分配,持读锁阻塞连接注册 / 注销,是连接层热锁上的持续 CPU / GC 压力。 +- **建议**:提供 O(1) 的 `IsOnline(userID)`,用频道成员(已 LIMIT 500)反向过滤,不导出全量在线集;若必须导出则「遍历到 limit 即停、不排序」。 +- **状态**:已加 `IsUserOnline` / `OnlineUserIDsForCandidates` / viewer 索引 `OnlineChannelUserIDs` / membership 索引 `OnlineChannelMemberUserIDs`。`updates.getState/getDifference` 会分页加载当前 session 已加入的 active channel;join/leave/invite/ban/delete 路径会运行态增删索引。持久 channel updates 走在线成员索引并经 PG active membership 复核,取消 500 cap;typing/reaction 等瞬时事件走 viewer-only scope;`messages.getDialogs` 不再标记 viewer。旧 `OnlineUserIDs` 保留兼容但已改为到 limit 即停、不排序。Redis/PG 缓存在线关系暂不引入,待多实例或压测数据证明需要再做。 + +### P0-3 channel fan-out 每收件人重建相同 user/chat + N+1 单查 +- **位置**:`internal/rpc/channels.go:3053` `pushChannelUpdates` → `internal/rpc/channels.go:3143` `tgUsersForIDs`。 +- **问题**:收件人无关的发送者 / 频道信息被每个 viewer 重算;每次 `tgUsersForIDs` 逐个 `Users.ByID`。 +- **建议**:把收件人无关的富集提到 fan-out 循环**外**做一次,循环内只算 viewer 差异(out/self,纯内存);配合 P0-4 的批量接口。 +- **状态**:已先把 `tgUsersForIDs` 改为批量 `Users.ByIDs`,消除该公共 N+1。收件人无关的 channel update 模板化仍待压测后继续拆。 + +### P0-4 `Users.Service.ByID` 每次额外查一次 `Self`(全局 ×2 放大)+ 服务层缺批量 +- **位置**:`internal/app/users/service.go:50`,被 RPC 层 **24 处** N+1 循环调用。 +- **问题**:每次解析一个 user = 2 次 PG 查询;且 `UsersService` / `MessagesService` / `ChannelsService` **根本没暴露批量 `ByIDs`**,存储层明明已有 `listUsersByIDs` / `listChannelsByIDs`(`= ANY`)却用不上。 +- **建议**:① 给三个 service 加批量 `ByIDs`,底层接已有的 `= ANY` 查询;② `ByID` 热路径不必每次重查 `Self`(`currentUserID` 已由 session 鉴权)。**这是 P0-3 与下面一批 P1 N+1 的公共根因。** +- **状态**:已给 `UserStore` / `UsersService` 增加 `ByIDs`、`ByPhones`,`UsersService.ByID` 去掉重复 `Self` 查询;channel/user 富集公共函数已迁到批量 users。Messages/Channels 更细粒度批量接口仍在后续项。 + +### P0-5 `MessageIDGen` 进程级全局锁串行化所有连接的出站写 +- **位置**:`internal/mtprotoedge/outbound.go:432` → 共享单例 `internal/mtprotoedge/server.go:184`(创建)/ `server.go:202`(注入每个 Conn)。gotd `proto.MessageIDGen.New` 全程持 `g.mux`。 +- **问题**:全进程一个 `s.msgID`,20 万连接共享;每条出站消息(rpc_result / push / ack / pong)必过这把锁。40 万 push/s ⇒ ~50 万次/s 全局锁 acquire,多核下 cache-line 争用 + lock convoy,把本可并行的 N 个 outbound actor 强行串到一点。 +- **建议**:改 **per-Conn `MessageIDGen`**(msg_id 只需单连接内单调,无需跨连接全局唯一)。msg_id 已只在该连接 outbound actor 单 goroutine 内调用 → per-Conn 后退化为无争用锁甚至无锁 int64。**改动最小、收益最大。** +- **状态**:已完成 per-Conn `MessageIDGen`,移除 server 级共享 generator。 + +### P0-6 出站写路径每条消息 4–6 次堆分配,gotd 零拷贝编码被旁路 +- **位置**:`internal/mtprotoedge/outbound.go:419-481`(`body.Copy()` + `var out bin.Buffer` + 回退到 `Encode` 再复制 + 每条 `aes.NewCipher` 重建 key schedule)。 +- **影响**:40 万 push/s × 5~7 次分配 ≈ **250 万 alloc/s**,是稳态主导 GC 压力;20 万连接广播同一 update 时同份 body 被各连接独立 encode + copy + encrypt。 +- **建议**:outbound actor 内复用 per-Conn encode / encrypt buffer(单 goroutine 天然安全,免锁);走 `Message`-based `EncodeWithoutCopy`;**广播 push 在 fan-out 前把 body 编码一次**,各连接只做 salt/msgID 包头 + 加密。 +- **状态**:未完全落地。AES `NewCipher` 不作为缓存目标:MTProto 2.0 每条消息由 msg_key 派生 AES key/iv,不能按 auth key 长期复用 cipher。后续只做 TL body 预编码、buffer 复用和实测分配优化。 + +--- + +## 4. P1 — 上量级会炸 / 高频路径浪费 + +### 连接层(TDesktop 主路径上量后相关) +- **P1-a 每帧两次裸 `write` syscall,无 bufio** — `internal/mtprotoedge/outbound.go:473` → transport 直写裸 `net.Conn`,每条 ≈3 次 syscall(含 `SetWriteDeadline`)。40 万 push/s ⇒ ~120 万 write syscall/s。`gotd/td` transport 当前隐藏底层 writer,项目规则禁止改 `github.com/gotd/td`;已在 `Conn` 内加 telesrv-owned outbound writer 接口作为替换点,但真正 bufio/syscall 合并仍需 transport writer/codec spike 后再落地。 +- **P1-b fan-out 中 `c.Send` 同步阻塞,慢连接拖累整批** — `internal/mtprotoedge/session_manager.go:446` + `internal/mtprotoedge/outbound.go:117`:`Send` 同步等到写完 / 超时,队列满时阻塞 fan-out 协程最长到 ctx 超时(5s)。**状态:已新增 `Conn.SendBestEffort` 与 SessionManager best-effort fan-out,updates push 默认只等 `TELESRV_OUTBOUND_PUSH_TIMEOUT=200ms` 入队;RPC result/ack/pong 仍走可靠同步发送,outbox 入队失败不删 durable 任务。** +- **P1-c 入站每帧分配新 buffer** — `internal/mtprotoedge/server.go:344`:连接级 `bin.Buffer` 复用被 codec `ResetN` 的 `make` 抵消,每帧新分配明文缓冲。建议入站缓冲走 `sync.Pool`。 + +### N+1(根因 = P0-4 服务层缺批量) +- **P1-d `forwardSources` 转发逐条查源 + 富集 N+1** — `internal/rpc/messages.go:4155`:100 条转发 = 100 次取源 + ~100 次富集查询。 +- **P1-e `getMessages` 逐 ID 单查** — `internal/rpc/messages.go:2888`:高频 RPC,100 个 ID = 100 次 `Search(Limit:1)`。**状态:已新增 `MessagesService.GetMessages` / `MessageStore.GetByIDs`,PG 用 `unnest(ids) WITH ORDINALITY` 一次取 owner-visible box ids;RPC 保持缺失项返回 `MessageEmpty`。** +- **P1-f `getDifference` 富集逐 ID 单查 + 跨事件重复查** — `internal/rpc/update_peer_refs.go:9`:登录风暴期高频,每事件重建 map + 同 user 在多事件被重复查。 +- **P1-g channel 读 RPC 普遍 N+1** — `getParticipants` / `getAdminLog` / `getHistory` 等 24 处逐个 `Users.ByID`(`internal/rpc/channels.go:3350` 等)。 +- **P1-h `enrichChannelHistory` 与存储层重复解析** — `internal/rpc/update_peer_refs.go:45`:存储层已批量填好 `Users` / `Channels`,RPC 层又逐个重查一遍后 merge 丢弃(纯浪费)。 +- **P1-i `importContacts` 逐条 `ByPhone` + `Upsert`** — `internal/app/contacts/service.go:84`:登录潮高发,500 条 = ~1000 次串行往返,单请求长期占一条 PG 连接。建议 `WHERE phone = ANY($1)` + `unnest` 批量 upsert(~1000 → ~2)。 +- **状态**:已改为批量 `ByPhones` + 批量 `UpsertMany`,同一目标用户去重写入,`Imported` 保留各 client_id。 +- **P1-j `ListForumTopics` 每话题 3 次 COUNT** — `internal/store/postgres/channel.go:5513` → `:6804`:单次最多 300 条额外 COUNT。建议改 `GROUP BY` 批量聚合(同文件 reactions / replies 已有该范式)。 +- **P1-k `ListChannelDialogs` 逐个回查 top message** — `internal/store/postgres/channel.go:3601`:主查询已 JOIN 到 top_msg 却只取了 date,又逐行 +100 单查;且过量取数 500 再内存排序切片。 + +### COUNT(*) 全量统计 +- **P1-l `ListMessagesByUser` 每页都 COUNT 全量** — `internal/store/postgres/queries/message.sql:380` + `internal/store/postgres/message.go:566`:每次翻页 / 搜索都把整个匹配集数一遍(trigram 只缩候选,count 仍大扫)。建议仅首页算 count、翻页省略(Telegram 客户端只首屏用总数)。 +- **状态**:已新增 `MessageFilter.NeedTotalCount`,SQL 默认不 COUNT;私聊搜索首屏按显式 flag 计算,其余 history/翻页默认返回 0 count。 +- **P1-m `ListChannelRecommendations` 全库 COUNT** — `internal/store/postgres/channel.go:3824`:对全系统公开广播频道计数,带 `NOT EXISTS` 反连接。建议返回近似值或不返回精确总数。 +- **P1-n `users.getFullUser.common_chats_count` 反向规划 channel 分区** — ✅ **已修(2026-06-03)**:TDesktop 打开私聊会触发 `users.getFullUser`,旧实现为填 `common_chats_count` 调 `ListCommonChannels(limit=1)`,同时跑 COUNT + list,并从 `user_id` 入口反查按 `channel_id` hash 分区的 `channel_members/channels`,实测 Alice/Bob `EXPLAIN` planning 约 70ms、执行约 1.6ms,UI 表现为点开 Bob B 卡一下。现新增 `user_channel_member_index(user_id, channel_id)`,由 member upsert/leave/delete channel 事务同步;`users.getFullUser` 走 `CountOnly`,只在该 user 维度索引上 count。实测同一 count query planning 0.873ms、execution 0.079ms,Alice 打开 Bob B 日志中 `users.getFullUser` handler 3.26ms、端到端 9.49ms。 + +### PG 分区访问维度审计(2026-06-03) + +本轮逐个核对 20 张 HASH 分区表,重点检查「查询入口维度」是否等于分区键。结论:`dialogs/dialog_drafts/dialog_filters/dialog_filter_settings/user_update_events/message_boxes` 的 owner/user 主路径基本都带 user 分区键;`channel_messages/channel_update_events/channel_forum_topics/channel_invites/channel_invite_importers/channel_admin_log_events/channel_message_viewers/channel_message_reactions` 的 channel 主路径基本都带 channel 分区键。剩余风险集中在需要反向访问的 read model / 队列: + +| 级别 | 问题 | 实测计划 | 修复方向 | +|---|---|---:|---| +| P1 | `ListChannelDialogs` / `ListInactiveChannels` / `ListLeftChannels` / `ListDiscussionGroups` / `ListAdminedPublicChannels` / `ListActiveChannelIDsForUser` 从 `user_id` 入口读 `channel_members`,但表按 `channel_id` 分区 | `ListChannelDialogs` planning 118ms,`ListInactiveChannels` 114ms,`ListActiveChannelIDsForUser` 26ms,均展开 64 个 `channel_members` 分区;带 `channels/channel_messages` join 时最多展开 192 个分区 | 把 `user_channel_member_index` 升级为正式 user 维度 membership read model,或两步取 bounded channel_id 后用 `channel_id/id = ANY($1)` 访问 channel 分区;禁止 SQL 内直接 `index -> channel_members/channels` 动态 join,实测仍会展开 64 分区 | +| P1 | `message_boxes` 的编辑/reaction 可见 box 查询与 revoke 删除按 `(message_sender_id, private_message_id)` 反查,表按 `owner_user_id` 分区 | `ListVisibleMessageBoxesByPrivateMessage` planning 50ms、`DeleteMessageBoxesByPrivateMessages` 等价查询 48ms,展开 64 个 `message_boxes` 分区 | 增加 unpartitioned `private_message_box_index(message_sender_id, private_message_id, owner_user_id, box_id)`,或从 `private_messages(sender_user_id,id)` 得到 sender/recipient 后按 owner 分区两点查询 | +| P1 | `ListUserUpdateEventsAfter` / `BatchListDispatchEvents` 在账号级 update 查询里直接 left join `channels fwd_ch/reply_ch`,`channels` 按 id 分区但 join key 来自消息行 | planning 37ms,展开 128 个 `channels` 分区;`user_update_events/message_boxes` 本身已正确裁剪到 1 个 user 分区 | update 查询只返回 channel ref id,Go 层收集后批量 `listChannelsByIDs(id = ANY($1))` 富集,复用 channel difference 的做法 | +| P1 | `dispatch_outbox` worker 全局 claim/failed cleanup 按 `status/next_attempt_at/updated_at` 取任务,表按 `target_user_id` 分区 | claim planning 28ms,展开 128 个 `dispatch_outbox` 分区;这是队列热路径,不是用户 RPC 单次路径 | 引入 unpartitioned ready queue / claim index table,或改为固定 worker shard 维度分区;按 target_user_id 分区适合投递/删除,不适合全局 claim | +| P2 | `ResolvePublicChannelUsername` 从 `channel_usernames` join `channels`,动态 channel id 让 `channels` 64 分区全展开 | planning 37ms,展开 64 个 `channels` 分区 | 先查 `channel_usernames` 得到 channel_id,再 `getChannelByID` 单分区读取 | +| P2 | 删除频道消息时 `deleteChannelUnreadMentionsTx` 按 `channel_id + message_id` 删除 `channel_unread_mentions`,但该表按 `user_id` 分区;随后按 affected user 更新 `channel_dialogs` 也会动态展开 user 分区 | mentions delete planning 16ms,affected dialog update planning 24ms,分别展开 64 个 `channel_unread_mentions` / `channel_dialogs` 分区 | 删除前从 message mention read model 取 bounded affected user_id,或增加 `(channel_id,message_id,user_id)` 辅助索引表;更新 dialog 按 user_id 分批两点写 | + +验证补充: + +- `channels WHERE id = ANY($1)`、`channel_members WHERE channel_id = ANY($1)` 在参数/常量数组已知时能裁剪到对应分区;但 `WITH ids AS (...) SELECT ... WHERE id = ANY(ARRAY(SELECT ...))` 这种 SQL 内动态数组不会裁剪,planning 仍约 33ms/64 分区。 +- `user_channel_member_index` 单表 user 维度查询 planning 0.7ms;但 `user_channel_member_index -> channel_members/channels` 一条 SQL 动态 join planning 71ms,仍不是可接受修法。 +- `private_messages` 目前仅按 `sender_user_id` 幂等/编辑主体查询,未发现 recipient-only 热查询;`recipient_user_id` 索引存在但不会改变 sender 分区裁剪限制。 + +### 媒体管线(2026-06-03 媒体闭环审计新增,放开大文件 / 大群前评估) +- **P1-媒体-a `upload.getFile` 整文件入内存 + 每 chunk 查 PG / sticker 小资源首开冷路径** — ✅ **已修(2026-06-03)**:原 `blobs.Get` 一次 `os.ReadFile` 读整个 blob 再切片,客户端按 ≤512KB/1MB 分块多次请求 ⇒ 同一大文件被重复整读 N 次(O(N²))+ 每 chunk 一次 `GetFileBlob` PG 往返;sticker/reaction/thumb 虽小,但 TDesktop 重启或打开历史首次渲染时仍可能在 `messages.getStickerSet` + `upload.getFile` + 本地文件冷读上形成可感知卡顿。现 `BlobBackend.GetRange`/`LocalFS`(`internal/app/files/blobfs.go`)用 `ReadAt` 只读 offset+limit 段(`n` 受文件大小约束,超大 limit 不会按客户端值分配内存);并加 `location_key→FileBlob` 进程内 LRU(容量 65536,元数据不可变故只读填充无需失效)消除每 chunk 的 PG 查;再加 `object_key→bytes` 小 blob LRU(单项 ≤256KB,总 64MB)、完整 sticker set cache 与启动 `WarmCaches`,从已 seed 元数据预热 sticker/reaction document 和可下载缩略图。参考实现 用 MinIO ranged read(`object.ReadAt`)+ SSDB 两级,但其下载命中冷存储不回填、无 LRU/阈值、photo/头像直连 MinIO;telesrv 当前先做进程内元数据/小字节 LRU + 段读,**多实例共享缓存(Redis)仍待二阶段换对象存储时评估**。实测启动预热 `49 sets / 2313 docs / 2298 blobs`,TDesktop `messages.getStickerSet` 从约 18-24ms 降至 0-1.7ms;seed document id 归一后,首次新服务端 id 会触发必要的 `upload.getFile` 主体/缩略图请求,但 server 端大多 0.5-6ms、样例最长 59ms,重复打开 Bob 会话 0.58s 截图已可见 sticker。 + +- **P1-媒体-a.1 system sticker set 污染 installed stickers 本地索引** — ✅ **已修(2026-06-03)**:TDesktop 贴纸正文缓存 key 为 `dc_id + document_id`,但 installed stickers 本地索引写入依赖 set flags;`installed_date` 会使 set 进入 Installed,普通 stickers 类型的 `Installed + NotLoaded` set 会让 `writeInstalledStickers()` 中止。此前 seed 把 animated emoji/dice/generic animations 这类 system set 也标成 installed,可能导致重启后 installed sticker 索引反复失效与 set 元数据重拉。现 migration `0066_system_sticker_sets_not_installed` 修复既有库,seed 后续也不再把 `set_kind=system` 声明为 installed。 +- **P1-媒体-a.2 sticker document 同时暴露 `PhotoPathSize` 与 raster thumb,历史页静态图被占位短路** — ✅ **已修(2026-06-03)**:TDesktop `history_view_sticker.cpp` 在创建 sticker media view 时若 `thumbnailPath()` 非空就不调用 `thumbnailWanted()`,而 `PhotoPathSize` 只是 vector placeholder;此前 seed 给同一 document 同时返回 path + raster thumb,导致历史页持续画 path/等完整 TGS 解码,用户反复重启 Debug 仍看不到静态图。现 document 有 default/cached/progressive raster thumb 时过滤 `PhotoPathSize`,thumb blob MIME 按魔数写入;domain 中可用 cached bytes 做修复依据,但 RPC 对 document 统一暴露 downloadable `photoSize m`,避免 `PhotoCachedSize` 与旧本地 cache 叠加出不可替换状态。启动 repair 后 PG 中 document thumbs kind 仅剩 `cached=2313`,document thumb `file_blobs.mime_type` 全部为 `image/webp`。 +- **P1-媒体-a.3 复用导出 document id 命中 TDesktop 旧 `DocumentData` 缓存** — ✅ **已修(2026-06-03)**:TDesktop `Data::Session::document(id)` 按 `document_id` 单例化,`DocumentData::updateThumbnails()` 不会清空旧 inline/path thumbnail;因此 server 修掉坏 thumb 字段后,旧 Debug tdata 仍可能用同 id 的污染对象,打开历史先空白再等完整 TGS。根因是 seed 曾把外部导出 document id 直接作为本服资源主键。现 `internal/app/files` 在导入阶段把 source id 归一为 telesrv-owned storage id,RPC/download/custom emoji/channel appearance 全部直接使用该服务端 id;migration `0067_seed_document_id_namespace` 修复既有库的 documents/file_blobs/sticker_sets/available_reactions/message media/channel appearance 引用。这样不需要改官方客户端,也不在 RPC 层保留客户端特判。复测:migration 后 PG 相关引用均无 `>4e18` 外部 document id;双 TDesktop 重启后打开 Bob B/Alice A 首屏 250ms 已显示 sticker,`messages.getHistory` 约 6-12ms,`upload.getFile` 命中新 id 成功,server/client 日志无 location/hash/API 错误。 +- **P1-媒体-b `upload_parts` 无 GC / 无每用户配额** — `internal/app/files/service.go:30` + `deploy/migrations/0057_media.up.sql`:分片直接进 PG `bytea`,仅 `assembleUpload` 成功才删;**未 assemble 的分片永久滞留**,且不同 `file_id` 无上限 ⇒ 任意登录用户可用海量 fileID 各传几片撑爆 PG(容量 DoS)。建议每用户 in-flight 上传字节/分片配额 + 后台按 `created_at` 过期清理 worker。 +- **P1-媒体-c `media` JSONB 内联放大 fan-out 写** — `deploy/migrations/0057_media.up.sql:129`:`media` 快照内联在 `message_boxes`(owner 双份)/`channel_messages`,含 stripped thumb/attributes 使单行变大;叠加主线 A 的 channel 全员写扇出时,大群每条媒体消息按成员数复制整个 media JSONB。建议随主线 A 改懒算/单副本时一并评估 media 是否只存引用、下沉 `documents`/`photos`。 +- **P2-媒体-d `photos.getUserPhotos` N+1 + OFFSET** — `internal/app/files/photos.go:165` 逐个 `GetPhoto` + `internal/store/postgres/queries/media.sql:314` OFFSET 分页。头像数少、`limit<=100`,影响有限;建议批量 `GetPhotos(ids)` + keyset 分页。 +- **P2-媒体-e sticker set cover 元数据声明可下载但 seed 无 raster blob** — ✅ **已修(2026-06-03)**:TDesktop 会把 `StickerSet.thumbs` 中的 downloadable `PhotoSize` 变成 `inputStickerSetThumb` 下载;`TELESRV_STICKER_SEED_DIR` 当前 set_cover 只有 `PhotoPathSize` SVG(40 个 only-svg、4 个 empty),没有可服务的 jpg/png/webp。现 seed 与 `tgStickerSet` 转换均过滤 sticker set cover 的 downloadable thumb,只保留非下载占位;实际 sticker/reaction document 缩略图仍按 `inputDocumentFileLocation` 服务。 + +--- + +## 5. P2 / P3 — 浪费但不致命 / 轻微 + +| 级别 | 问题 | 位置 | +|---|---|---| +| P2 | `getDialogs` 全量拉内存排序 + 无条件拉 1000 条草稿(最高频 RPC 之一,应下推 SQL `ORDER BY LIMIT` + 按页 peer 取草稿) | `internal/app/dialogs/service.go:31` | +| P2 | `MaxContiguousPts` 每次 getState 读 4096 行(应 O(1) 持久化单列 / Redis) | ✅ 已新增 `user_update_watermarks`,`MaxContiguousPts` O(1) 读水位;缺行时一次性补算并 upsert。`Current()` 保留最大已提交 pts 语义,供 Redis allocator 恢复最大分配点,避免 gap 时回退 | +| P2 | **`user_update_events` / dispatch/outbox delivered rows 无保留期清理**,按长期在线量永久膨胀;`message_boxes` 是用户历史,不得在没有产品保留策略时通用 TTL 删除 | ✅ 已确认账号级 `user_update_events` 不能通用 TTL 裁剪,需永久保留以支持 TDesktop `differenceSlice` 续传;retention worker 仅清理 failed/过期 outbox,delivered outbox 已投递即删,不删除 `message_boxes` | +| P2 | `advanceChannelReadOutboxTx` 最多 128 次串行已读写(应单条集合写) | `internal/store/postgres/channel.go:5952` | +| P2 | `InviteToChannel` 200 人逐人循环 ~800 往返(应 `unnest` 批量) | `internal/store/postgres/channel.go:426` | +| P2 | `GetParticipants` OFFSET 分页 + `CASE role` 排序必触发 sort | `internal/store/postgres/channel.go:355` | +| P2 | `ReadHistory` / `EditMessage` / `DeleteMessages` 在 PG 事务内做 Redis pts 分配(send 已优化,这三条没跟上) | `internal/store/postgres/message.go:650` | +| P2 | `ReadHistory` / `RefreshDialog` 用 `COUNT(*)` 重算未读(应纯增量维护) | `internal/store/postgres/queries/message.sql:651` | +| P2 | `ListChannelDifference` 逐事件回查消息 | `internal/store/postgres/channel.go:6115` | +| P2 | `ListInactiveChannels` / `ListLeftChannels` 计算表达式排序 / OFFSET 分页(低频) | `channel.go:3756` / `:3729` | +| P2 | 私聊发送富集仍单查 self / 对端(最热路径,可缓存 self + 透传已查的对端) | `internal/rpc/messages.go:3911` | +| P2 | memory store 多处全 map O(n) 扫描(**仅测试 / 本地用,不影响生产**) | `internal/store/memory/memory.go:1287` 等 | +| P3 | SessionManager 注册表分片(仅重连风暴尾延迟,实测非稳态瓶颈,与既有结论一致) | `internal/mtprotoedge/session_manager.go` | +| P3 | `validateSeq` 每消息 O(400) map 扫描、热路径 hex 日志求值、`enrichUpdateEvents` 整片复制、`pushToUser` 每次切片快照 | 多处 | + +--- + +## 6. 已确认健康(避免误报,证明审计严谨性) + +- **send 路径 Redis 已是单 Lua**(`user_counter_allocator` 把 pts + box 合一,hash-tag 同 slot)——既定优化已落地,热路径仅 1 次 RTT。 +- **私聊核心已达标**:双写 / dialog upsert / event / outbox 单事务、`ON CONFLICT(sender, random_id)` 幂等、keyset 分页无大 OFFSET、批量转发 / 删除已 `unnest`、outbox `FOR UPDATE SKIP LOCKED` + 多 worker + 投递即删、相关索引齐全。 +- **channel 存储核心决策正确**:消息单副本存储(不给每成员写一行)、keyset 分页、服务端 limit 钳制、trigram 搜索索引、按 channel_id 分区;在线 fan-out 先用内存索引缩小到当前在线成员/active viewers,再用 `= ANY` 分批复核 active membership。 +- **`convert.go` 预分配 + 去重到位,RPC 入参钳制完整**——无「按客户端值无界分配」DoS。 +- **Redis 健康**:无大 key、无 `KEYS` / `SCAN` 全库扫、无 `SMEMBERS / LRANGE 0 -1`,RateLimiter 正常放行 1 RTT。 +- **SessionManager 锁**:push 在锁外发送,临界区极短——既有 benchmark 结论仍成立(注意:这与 P0-2 的 `OnlineUserIDs` 全量排序是两回事,后者是新发现)。 + +--- + +## 7. 修复优先级路线图 + +**第一梯队 — 当前主路径就该做(与群组规模无关,直接影响 20 万连接稳态)** +1. **P0-5 `MessageIDGen` 改 per-Conn** — ✅ 已完成。 +2. **P0-6 + P1-a 出站去分配 + bufio 合并 syscall** — 直击 250 万 alloc/s + 120 万 syscall/s。 +3. **P0-4 服务层加批量 `ByIDs` + 去掉 `ByID` 重复 `Self`** — ✅ 已完成 users 公共根因;messages/channels 专用批量接口继续推进。 +4. **P1-i `importContacts` 批量化** — ✅ 已完成。 +5. **P1-l `getHistory` 翻页省略 COUNT** — ✅ 已完成 `NeedTotalCount` 默认 false。 +6. **P2 `MaxContiguousPts` O(1) 化 + outbox 死任务清理** — ✅ 已完成 watermark;账号级 `user_update_events` 永久保留,retention worker 只清理 failed outbox。 + +**第二梯队 — 放开大规模群组 / 频道前必须拆雷(否则开闸即炸)** +7. **P0-1 广播频道去全员写扇出**(改懒算未读)— ✅ 已加同步写闸门,读取端已改为大群动态未读;仍需压测。 +8. **P0-2 `OnlineUserIDs` 去全量排序**(改 `IsOnline` / 频道 viewer+member 在线索引)— ✅ 已完成主 fan-out 路径;Redis 跨实例在线关系暂缓。 +9. **P0-3 + P1-g/h channel fan-out 与读 RPC 批量化、富集提到循环外、去重复解析。** — 部分完成:users 富集已批量化,模板化与 messages/channels 专用批量仍待做。 +10. P1-j/k、P2 channel 各项。 + +**第三梯队 — 稳健性 / 尾延迟** +11. P1-b fan-out 满即丢 — ✅ 已完成 best-effort updates push;P3 注册表分片待真有重连风暴尾延迟再做。 + +--- + +## 附录 A:关键 P0 复核记录 + +下列 P0 在出报告前已逐条读码核实属实(非纯 agent 转述): + +| 发现 | 核实点 | 结论 | +|---|---|---| +| P0-1 | `channel.go:7958` 的 SQL `WHERE m.channel_id=$1 AND m.status='active'` 确无 LIMIT,在发送事务内 | ✅ 属实 | +| P0-2 | `session_manager.go:469-487`:`make(…, len(m.byUser))` → `range` 全 map → `sort.Slice` → `ids[:limit]`(排序在截断前) | ✅ 属实 | +| P0-3 | `channels.go:3053-3082` `for _, userID := range recipients { build(userID) }`;`:3143` `tgUsersForIDs` 循环内 `Users.ByID` | ✅ 属实 | +| P0-4 | `users/service.go:49-58` `ByID` 先 `s.Self(ctx, currentUserID)` 再 `s.users.ByID`(每次 ×2) | ✅ 属实 | +| P0-5 | `server.go:147` 字段、`:184` 建一个、`:202` 注入每个 Conn 共享;`outbound.go:432` 每条出站调 `c.msgID.New` | ✅ 属实 | + +## 附录 B:复跑与验证 + +- **连接层**:`internal/mtprotoedge` 的 `BenchmarkSessionManager*` + `-mutexprofile`(既有 harness)。已新增 `BenchmarkSessionManagerOnlineCandidateFilter`,用于 20 万在线下候选过滤路径复跑。 +- **私聊 / 消息**:`internal/loadtest` + 本机 docker PG/Redis(env-gated `TELESRV_TEST_POSTGRES_DSN` + `TELESRV_TEST_REDIS_ADDR`,未设则 Skip)。 +- **PG 查询计划**:对 P1-l / P1-m / P0-1 用 `EXPLAIN (ANALYZE, BUFFERS)` 在有量级数据的分区上验证扫描行数与是否走索引。 +- **channel fan-out**:在放开大规模群组前,构造「大成员群 + 高在线占比」场景压测主线 A 的端到端放大。 +- **本轮回归**:`go test ./...` 已通过(2026-06-02)。2026-06-03 media/avatar 接手回归补充:`go test ./...`(PG/Redis env-gated)通过,`TestSeedMediaFromRealExport -count=1` 确认 sticker seed 74 reactions / 11 sets / 1355 docs / 2682 blobs,migration 状态 `59|f`;本轮新增头像编辑页 `messages.getEmojiProfilePhotoGroups` empty stub 属 TDesktop 兼容面补齐,不改变性能债优先级。双 TDesktop 群/频道头像 UI 回归仍待 Windows Computer Use 恢复后补实测证据。 + +--- + +## 附录 C:复核与落地轮(2026-06-02,第二轮) + +对第一轮已落地的优化做正确性复核,并把剩余 P0/P1 改到位 + 加测试验证。 + +### 协议关键纠正:账号级 `differenceTooLong` 不可用(§2.5 参考审计救场) +原计划给账号级 `getDifference` 补 `differenceTooLong` 让落后客户端整库重置。审计 TDesktop 基线源码发现 **`api_updates.cpp:516` 对账号级 differenceTooLong 只打一行日志、不读 pts、且漏 `setRequesting(false)`**——收到后 `_ptsWaiter.requesting()` 永真,之后所有 `getDifference()` 在 `:689` 早退,**永久锁死整个 update 引擎,重连/新 session 都救不回**。参考实现 也都故意不发账号级 too-long。 +→ 结论:账号级落后只能 `differenceSlice` 分批续传;**绝不能裁剪 `user_update_events` 到客户端够不着**——这直接决定了 P0 的修法(不删事件,而非加 too-long)。 + +### 本轮落地与验证 +- **P0(retention)✅**:retention worker 改为**不再删 `user_update_events`**(pts log 永久保留,对齐参考实现),仅保留 outbox failed 清理。根除「裁剪事件→落后客户端静默丢消息」。 +- **P1(watermark 死锁)✅**:先识破「预锁 watermark」会引入 watermark↔dialog 跨类型死锁,且 send 双向并发本就有 **dialog 行 pre-existing AB-BA**。终选**事务级 advisory lock 按 user_id 升序串行化同一对用户的写**(`lockUsersForUpdate`,独立锁空间、不与行锁交叉),一举消除 watermark+dialog+box 所有 AB-BA;覆盖 send/read/edit/delete/deleteHistory,delete 另配 dialog rebuild 升序。**强验证**:新增 `TestMessageStoreBidirectionalConcurrencyNoDeadlock`,临时禁用 advisory 时 116/320 操作 `deadlock detected (40P01)`(死锁点 `upsert dialog`),启用后 0 失败。 +- **P1(tgUsersForIDs)✅**:批量 `ByIDs` 失败不再静默 `return nil`,记 Warn 日志(批量无部分结果,不降级逐个以免 DB 抖动放大)。 +- **P1(outbox 拥塞)✅**:`pushOutboxUpdate` 区分 `retriable`——best-effort 队列拥塞保留 dispatching 行靠租约(30s)重投、**不计入 attempts 升级**;新增 `TestOutboxDispatcherDefersOnPushQueueFull`。 + +验证:`go build/vet ./...` 全绿;单测 16 ok / 0 FAIL;PG 集成**单独/分批**全绿(watermark / retention / 并发 / 双向死锁 / outbox 拥塞 / send-read-edit-delete 往返)。 + +### 发现:一批 pre-existing 集成测试失败(第一轮优化遗留,非本轮引入) +全量跑 `internal/store/postgres` 集成套件**整体不稳定**(stash 掉本轮改动的基线也大量失败,含无关的 `TestAuthKeyStoreRoundTrip`——根因是每测试建独立连接池不释放,全量连接耗尽)。逐个单独跑时,下列测试仍失败,**根因是第一轮优化改了行为但旧断言未同步**,已逐条核对非本轮 4 项修复引入: +- `TestMessageStoreDeleteHistoryBatchesHugeMaxID`:断言 `history.Count==2`,但 P1-l(`NeedTotalCount` 默认 false)使 history 返回 `Count=0`(删除断言本身通过)。 +- `TestChannelStoreSendMessageFansOutDialogRows` / `...ReadOutboxDoesNotRegressSenderDialogUnread` / `...SendFailureRecordsNoopPtsGap`:channel fan-out / read outbox 行为随 P0-1 等 channel 优化变化,旧断言未更新(`channel.go` 本轮未改)。 +→ **待办**:更新这些测试断言对齐新行为(count 语义 / channel fan-out),或确认行为符合预期;并考虑给集成测试套件改共享连接池以支持全量运行。属第一轮优化的测试债,独立于本轮性能修复。 \ No newline at end of file diff --git a/docs/persistence-layer.md b/docs/persistence-layer.md new file mode 100644 index 00000000..77fd4f35 --- /dev/null +++ b/docs/persistence-layer.md @@ -0,0 +1,174 @@ +# telesrv 持久化层设计 + +> 第一阶段(协议 + 登录 + 空账号主界面)的存储地基。后端已定:**PostgreSQL + Redis**, +> 依赖由 [`deploy/docker-compose.yml`](../deploy/docker-compose.yml) 启动;对象存储(MinIO)留第二阶段文件域。 +> 决策见 README.md。 + +## 0. 定位与第一价值 + +把登录链路所需状态可靠落地,使 **server 可反复重启而不丢握手态 / 登录态**——这是真机联调能否愉快迭代的前提: + +- **auth_key 丢** → TDesktop 缓存的 key 在 server 端查不到 → 客户端加密包解不开 → 触发重建密钥甚至重登。 +- **登录态丢** → 每次重启都要重走 `sendCode` / `signIn`。 + +所以持久化第一价值是「联调可重启」,不是上规模。 + +## 1. 后端职责划分 + +| 维度 | PostgreSQL(权威、强一致、可查询) | Redis(高频、易失、TTL、原子计数) | +|---|---|---| +| 第一阶段 | `auth_keys`、`users`、`authorizations`、`account_passwords`、`temp_auth_key_bindings`、`app_configs`、`countries`、`country_codes`、`update_states`、`contacts`、`lang_packs`、`lang_pack_strings` | 验证码 `phone_code_hash → code`(短 TTL)、session、登录尝试限流 | +| 第二阶段 | `private_messages`、`message_boxes`、`dialogs`、`user_update_events`、`dispatch_outbox` | `pts` / owner `box_id` 原子自增(Redis miss 时从 PG durable log 恢复)、在线态缓存 | +| 不放这里 | 大对象/媒体(→ 二阶段 MinIO) | 任何需要持久强一致的业务事实 | + +原则:**PG 存「事实」,Redis 存「态与计数」**。Redis 丢了能从 PG/重新计算恢复;PG 丢了就是数据丢失。 + +## 2. 第一阶段 schema(PostgreSQL) + +DDL 见 [`deploy/migrations/0001_init.up.sql`](../deploy/migrations/0001_init.up.sql)、 +[`deploy/migrations/0002_phase1_business.up.sql`](../deploy/migrations/0002_phase1_business.up.sql) 与 +[`deploy/migrations/0003_startup_config_security.up.sql`](../deploy/migrations/0003_startup_config_security.up.sql)、 +[`deploy/migrations/0004_temp_auth_key_binding_session.up.sql`](../deploy/migrations/0004_temp_auth_key_binding_session.up.sql) 与 +[`deploy/migrations/0005_system_login_messages.up.sql`](../deploy/migrations/0005_system_login_messages.up.sql)、 +[`deploy/migrations/0006_update_events.up.sql`](../deploy/migrations/0006_update_events.up.sql)、 +[`deploy/migrations/0007_read_history_events.up.sql`](../deploy/migrations/0007_read_history_events.up.sql)、 +[`deploy/migrations/0008_user_id_sequence_base.up.sql`](../deploy/migrations/0008_user_id_sequence_base.up.sql) 与 +[`deploy/migrations/0009_private_message_pipeline.up.sql`](../deploy/migrations/0009_private_message_pipeline.up.sql)、 +[`deploy/migrations/0010_message_performance_indexes.up.sql`](../deploy/migrations/0010_message_performance_indexes.up.sql) 与 +[`deploy/migrations/0011_drop_dead_tables.up.sql`](../deploy/migrations/0011_drop_dead_tables.up.sql)、 +[`deploy/migrations/0012_outbox_delete_on_deliver.up.sql`](../deploy/migrations/0012_outbox_delete_on_deliver.up.sql) 与 +[`deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql`](../deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql)、 +[`deploy/migrations/0014_settings_update_events.up.sql`](../deploy/migrations/0014_settings_update_events.up.sql)、 +[`deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql`](../deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql)、 +[`deploy/migrations/0016_delete_message_updates.up.sql`](../deploy/migrations/0016_delete_message_updates.up.sql)、 +[`deploy/migrations/0017_dialog_folders.up.sql`](../deploy/migrations/0017_dialog_folders.up.sql)、 +[`deploy/migrations/0018_user_search_indexes.up.sql`](../deploy/migrations/0018_user_search_indexes.up.sql)、 +[`deploy/migrations/0019_usernames.up.sql`](../deploy/migrations/0019_usernames.up.sql) 与 +[`deploy/migrations/0020_profile_message_state.up.sql`](../deploy/migrations/0020_profile_message_state.up.sql)。 + +> `0011` 清除了三张已被取代、运行时零引用的死表:`update_events`(一阶段 auth_key 级 update 队列,被 `user_update_events` 取代)、`messages_legacy` 与 `dialogs_legacy`(`0009` 重命名保留的迁移残骸,数据已迁入 `private_messages` / `message_boxes` / 新 `dialogs`)。`0006` / `0007` 作为历史演进记录保留,但其建立的 `update_events` 已不再使用。 + +第一批表: + +- **`auth_keys`** —— 密钥交换产物。`auth_key_id`(BIGINT,SHA1 低 64 位小端 int64) + `body`(256B BYTEA) + `server_salt`。 +- **`users`** —— 登录链路必须字段:`id` / `access_hash` / `phone`(UNIQUE) / `first_name` / `last_name` / `username` / `about` / `country_code` / `verified` / `support`。`access_hash` 为任何 `InputUser` 校验所必须,不可省;普通注册用户 ID 从 `1780243200`(2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳)起递增,内置 777000 官方系统账号显式保留在低位区间。注册页只写手机号与姓名,后续 Settings/Profile 通过 `account.updateProfile` 更新姓名与 bio。 +- **`authorizations`** —— `auth_key ↔ user` 绑定 + 设备信息(`layer` / `device_model` / `app_version` / `api_id` …)。PK 为 `auth_key_id`(一个 auth_key 一条授权),外键挂 `auth_keys` 与 `users`。 +- **`account_passwords`** —— 账号 2FA/SRP 配置。第一阶段默认 `has_password=false`,但 `account.getPassword` 已走持久化查询。 +- **`temp_auth_key_bindings`** —— `auth.bindTempAuthKey` 的 temp→perm 绑定记录。写入前校验 encrypted `bind_auth_key_inner`,持久化 `temp_session_id`,后续 temp auth_key RPC 在 router 入口解析为 perm auth_key 身份,并缓存在 active session 上。 +- **`app_configs`** —— `help.getAppConfig` 的 data-backed JSON config,包含 TDesktop read mark、quote reply 与 native anti-spam 管理入口所需参数。 +- **`countries` / `country_codes`** —— `help.getCountriesList` 的登录页国家区号目录。 +- **`update_states`** —— `auth_key_id + user_id` 维度的设备状态快照。账号级 pts 以 `user_update_events` 为权威,设备退出/换号只清当前 auth_key 状态,不删除账号事件。 +- **`user_update_events`** —— 按 `user_id` HASH 分区的账号级增量事件队列。承载 `new_message`、`read_history_inbox/read_history_outbox`(私聊与 channel peer)、`edit_message`、`delete_messages`、`contacts_reset`、dialog 置顶/顺序/manual unread/peer settings、dialog filter/order/reload、folder peers、channel 本地清空后的 `channel_available_messages`、当前账号 forum 展示模式 `channel_view_forum_as_messages` 与 allocator gap 的 `noop`,供 `updates.getDifference` 和 outbox worker 补偿错过的推送;设置类事件通过 `UpdateEventStore.AppendWithDispatch` 与 `dispatch_outbox` 同事务写入,并持久化 `event_peers` / `peer_settings` / `message_ids` / `dialog_filter` / `filter_order` / `folder_peers` 负载,避免状态只存在在线 push 中。 +- **`contacts`** —— 当前账号通讯录关系与 owner 视角联系人资料。`contact_phone/contact_first_name/contact_last_name/note/note_entities` 均只属于 `(user_id, contact_user_id)`,同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;`mutual` 由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。 +- **`private_messages`** —— 共享私聊消息主体,按 `sender_user_id` HASH 分区;`sender_user_id + random_id` 唯一保证 `messages.sendMessage/forwardMessages` 幂等;文本编辑更新共享 body/entities/edit_date;silent/noforwards/reply_to/fwd_from 元数据随消息持久化。 +- **`message_boxes`** —— owner 视角消息盒,按 `owner_user_id` HASH 分区;每个账号看到自己的 `box_id`、peer、outgoing、pts、edit_date 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box,`revoke` 通过 `(message_sender_id, private_message_id)` 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。该反向定位与分区键不一致,规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。 +- **`dialogs`** —— 当前账号会话摘要,按 `user_id` HASH 分区;只允许 `user` peer,支持 top message、置顶过滤、folder_id=0/1 主列表/归档与 offset 分页,并保存当前 owner 的 `pinned_order`、manual `unread_mark` 与 `hidden_peer_settings_bar`。列表查询 join `contacts` 时优先返回当前 owner 保存的联系人姓名/电话,避免不同账号看同一 peer 串备注;相关状态变化会写入账号级 durable update log,离线设备可通过 `updates.getDifference` 恢复。 +- **`channels` / `channel_messages` / `channel_message_viewers` / `channel_update_events`** —— 超级群/频道单份消息模型,按 `channel_id` HASH 分区;`channels.pts` 是 channel-scoped durable log 水位,`channels.forum/forum_tabs` 持久化 megagroup topics 开关与 TDesktop tabs/list 布局,`channels.participants_hidden` 持久化隐藏成员设置并由 `ChannelFull.participants_hidden` 恢复 TDesktop UI,`channels.antispam` 持久化 native anti-spam 开关并由 `ChannelFull.antispam` 恢复管理入口,`channels.color_set/color/color_background_emoji_id/profile_color_set/profile_color/profile_color_background_emoji_id/emoji_status_document_id/emoji_status_until` 持久化频道外观并回填 `Channel.color/profile_color/emoji_status`,`channels.linked_chat_id` 维护 broadcast 与 discussion megagroup 的双向链接并走 `channels_linked_chat_idx` 反查,`channel_update_events(channel_id, pts)` 存 new/edit/delete/pin/participant/noop 的恢复负载,`channel_messages(channel_id, id)` 走 seek pagination 并保存 `views_count` 聚合列,`channel_message_viewers(channel_id,message_id,viewer_user_id)` 用主键完成 views 去重递增,`reply_to_msg_id/reply_to_top_id` 支撑 thread/comment 分页,`discussion_channel_id/discussion_message_id` 把 broadcast post 映射到 linked megagroup root,禁止按成员写扩散。 +- **`channel_members` / `channel_dialogs` / `channel_unread_mentions`** —— 成员权限、读水位、owner 视角 channel dialog 与未读提及索引,分别按 `channel_id` / `user_id` / `user_id` HASH 分区;`available_min_id` 限制成员可见历史消息,`available_min_pts` 限制 `updates.getChannelDifference` 起点,避免新成员或重新加入成员恢复到入群前的 participant/admin/settings 事件。`channel_dialogs.unread_count` 是小超级群普通未读缓存字段,不是 broadcast/大超级群真值;大频道读取 dialog/full channel 时按 `channel_members.read_inbox_max_id`、`available_min_id`、`channels.top_message_id` 与未删除消息动态派生普通未读。`channel_dialogs.default_send_as_peer_type/default_send_as_peer_id` 保存当前 owner 的默认发送身份,由 `channels.getFullChannel` 输出为 `channelFull.default_send_as`,不参与历史分页或 dialog 排序;`channel_dialogs.view_forum_as_messages` 是当前账号本地 forum 展示模式,由 `Dialog/ChannelFull.view_forum_as_messages` 恢复 UI,并通过账号级 durable update 同步多 session。`channel_unread_mentions(user_id,channel_id,message_id)` 不复制消息正文,发送时只写解析出的 active/可见/未读成员,清除后重算 `channel_dialogs.unread_mentions_count`。共同超级群查询已迁到 `user_channel_member_index(user_id, channel_id)`,排除 broadcast 与非 active/deleted 成员;后续 `channels.getLeftChannels`、joined/admined channel 列表和启动 dialog 聚合也必须从 user 维度 read model 或两步 channel_id 列表读取,不能直接用 `channel_members WHERE user_id=...` 反向扫 `channel_id` 分区。 +- **`channel_invites` / `channel_invite_importers`** —— 邀请链接、导入者与 join request read model,均按 `channel_id` HASH 分区;invite 保存 `usage_count/requested_count`,importer 以 `(channel_id,user_id)` 保证同一用户只有一个 pending/approved 状态,管理页查询走 `admin/revoked/offset_link` 与 `requested/link/date/user_id` seek 索引,禁止按超大 limit 或 hash 反查做全表扫。public `channels.toggleJoinRequest` 使用 `channels.join_request` 与 `channel_invite_importers(invite_id=0, requested=true)` 表达非 invite-link pending request;管理员实时提醒用 bounded `updatePendingJoinRequests` + full channel 回填,不为每条 pending 状态生成无界 durable updates。 +- **`dialog_filters` / `dialog_filter_settings`** —— 当前账号自定义 dialog filter、filter 顺序与 folder tags 开关,按 `user_id` HASH 分区;自定义 filter 从 ID 2 开始,归档只由 `dialogs.folder_id=1` 表达,避免一列同时承担归档状态和任意筛选规则。 +- **`dispatch_outbox`** —— 按 `target_user_id` HASH 分区的 transactional outbox。发送事务内写入,RPC outbox worker 用 `FOR UPDATE SKIP LOCKED` 批量 claim,成功标记 delivered,失败退避重试;排除当前设备使用 `exclude_auth_key_id + exclude_session_id`,避免一个设备换号或多账号登录时误过滤。按 target 分区适合投递完成/失败按用户更新,但全局 claim/cleanup 与分区键不一致,规划会展开所有分区;上量前需引入 ready queue 或 worker shard read model。 +- **`lang_packs` / `lang_pack_strings`** —— TDesktop 语言包元信息与字符串。开发 seed 来自 导出的 `.strings` 数据文件。 + +搜索相关索引:`0018` 启用 `pg_trgm`,为 `users.phone` 前缀、`users.username`/姓名、`contacts` owner 保存姓名和 `message_boxes.body` 建索引;`0049` 为公开 username channel/supergroup peer 搜索补 `channels.username/title` trgm 索引。`contacts.search` 先按当前 owner 视角区分联系人/非联系人,并补公开频道/超级群的 `PeerChannel + Chats`;`messages.searchGlobal` 当前只查当前账号私聊文本;跨群组/频道的大规模全局搜索后续应接全文索引或外部搜索服务。 + +## 3. store 接口蓝图 + +```text +internal/domain/ + user.go # User(不依赖 tg.*) + authorization.go # Authorization(含设备信息) + +internal/store/ + authkey.go # AuthKeyStore —— PG(去掉现有 AuthKeyData.UserID) + session.go # SessionStore —— PG / Redis + user.go # UserStore —— PG:ByID / ByPhone / Search / Create / Update + authorization.go # AuthorizationStore —— PG:Bind / ByAuthKey / ByUser / Delete + code.go # CodeStore —— Redis:Set(TTL) / Get / Del + updatestate.go # UpdateStateStore —— PG:auth_key+user 维度 pts/qts/seq + update_event.go # UpdateEventStore —— PG:user 维度 getDifference 事件 + dispatch_outbox.go # DispatchOutboxStore —— PG:在线 update transactional outbox + contact.go # ContactStore —— PG:当前账号通讯录 + dialog.go # DialogStore —— PG:当前账号会话摘要 + message.go # MessageStore —— PG:账号视角下的私聊消息 + langpack.go # LangPackStore —— PG:TDesktop 语言包 + account.go # PasswordStore —— PG:账号 2FA/SRP 配置 + help.go # AppConfig/Country —— PG:启动配置与国家区号目录 + temp_auth_key.go # TempAuthKeyBinding —— PG:temp→perm auth key 绑定 + memory/ # 现有内存实现(保留作测试替身) + postgres/ # pgxpool 实现 + 迁移 runner + redisstore/ # go-redis 实现 +``` + +## 4. 关键设计决策 + +1. **auth_key 与 authorization 分表**:auth_key 是协议产物、授权是业务产物(README.md §3)。从现有 [`AuthKeyData`](../internal/store/authkey.go:11) 拆出 `UserID`,新增 `domain.Authorization` + `AuthorizationStore`。授权表同时是 [`rpc.ClientInfo`](../internal/rpc/context.go) 的落库归宿。 +2. **store 接口收发 domain 实体**,DTO 仅存在于实现内部;`AuthKeyData` / `SessionData` 这类纯协议数据保留为 store 层 DTO(它们不属业务 domain)。domain 不依赖 `tg.*`,下沉到接口签名安全。 +3. **`auth_key_id` 存 BIGINT**:内部 `[8]byte` 在 store 边界按小端转 int64(MTProto auth_key_id 定义即 SHA1 低 64 位)。可读、可索引、与日志一致。 +4. **update 状态分两层**:`user_update_events` 是账号级 durable log;`update_states(auth_key_id,user_id)` 是设备在某账号下看到的状态。这样同一设备退出后换号、或一个设备先后登录多个账号,都不会把旧账号差分串到新账号。 +5. **连接身份缓存**:`authorizations` 仍是权威事实;router 在 active session 首次 RPC 时解析 temp→perm auth_key,并把业务 auth_key_id 写回连接上下文;`auth_key→user_id` 额外做 router 级业务 auth_key 缓存并用 singleflight 合并并发 miss,避免 TDesktop 启动时多个 temp session 同时打 `authorizations`。登录前的未授权结果也做缓存;`auth.bindTempAuthKey` 切换业务 auth_key 时清掉旧 raw key 的 user 缓存,`auth.signIn/signUp` 写入真实 user,`auth.logOut` 清理同业务 auth_key 的活跃连接身份与 auth_key+user update state,账号级事件不随设备退出删除。 +6. **大表查询必须游标化**:`message_boxes`、`dialogs`、`user_update_events` 和 `dispatch_outbox` 从建表开始 HASH 分区;历史和会话列表使用 `box_id/date` 或 `top_message_date/top_message_id/peer_id` seek pagination,避免在 owner 分区内做大 SQL `OFFSET` 扫描。 +7. **查询入口必须匹配分区键**:分区表上的二级索引不能替代分区裁剪。user→channel、message→owners、global queue claim 这类反向访问必须有独立 read model,或拆成「先取 bounded id,再按分区键批量点查」两段;禁止把动态 join 当作分区裁剪。 +8. **联系人和 dialog 均是 owner 视角**:`users` 只保存全局账号资料;通讯录姓名/电话/备注、dialog 置顶顺序、manual unread、隐藏 action bar 都必须落在当前 owner 维度。后续群消息、群成员备注、会话排序可以复用这条边界,不能把个人备注写回全局 user。 +9. **搜索不能无界扫大表**:用户搜索限制 query/limit,并用 phone prefix / username / owner 保存姓名索引;消息全局搜索第一版只在当前 owner 分区内执行,并有 `pg_trgm` 兜底。参考实现,未接 Meilisearch/FTS 前不做跨全库模糊扫。 + +## 5. 选型 + +| 组件 | 选型 | 理由 | +|---|---|---| +| PG 驱动/连接池 | `jackc/pgx/v5` + `pgxpool` | Go 生态主流,性能好,原生 PG 协议,不引 ORM | +| Redis 客户端 | `redis/go-redis/v9` | 事实标准 | +| 迁移 | `golang-migrate/migrate/v4`,`iofs` 嵌入 `deploy/migrations` | 版本化、可 up/down,启动自动 `up` | +| 查询层 | `sqlc` 生成 pgx/v5 代码(以 `deploy/migrations` 为 schema 源,与 golang-migrate 共用同一份 SQL) | 类型安全、消除 `rows.Scan` 样板、schema 演进编译期暴露;动态查询用 pgx 手写补充 | + +## 6. 落地里程碑 + +- **P0 基础设施**(本文档 + docker-compose + 0001 schema + config 扩展)—— ✅ +- **P1 Go 接入**:`store/postgres`(pgxpool + golang-migrate runner + sqlc `AuthKeyStore`)+ `store/redisstore`(`SessionStore`);main 启动迁移并注入。连接层测试保持 Memory 替身(测协议不被 DB 绑架),PG/Redis 由 env-gate 往返集成测试验证。—— ✅ +- **P2 业务 store**:`UserStore` / `AuthorizationStore`(PG)+ `CodeStore`(Redis);Memory 实现抽到 `store/memory` 子包(三后端对称)。登录注册闭环已用上(auth.* + users.getUsers + updates.getState),手机号在 auth 业务层规范为纯数字以匹配 TDesktop 验证码页行为。—— ✅ +- **P3 启动业务 RPC 持久化**:`UpdateStateStore` / `ContactStore` / `DialogStore` / `LangPackStore`, + `updates.getDifference`、`contacts.getContacts`、`messages.getDialogs/getPinnedDialogs`、`langpack.*` + 已由 PG-backed 服务响应;空账号仍返回空业务数据。—— ✅ +- **P3.5 启动配置与账号安全持久化**:`PasswordStore` / `AppConfigStore` / `CountryStore` / + `TempAuthKeyBindingStore`,`account.getPassword`、`help.getAppConfig/getCountriesList`、 + `auth.bindTempAuthKey` 已由 PG-backed 服务校验并响应,temp auth_key 会映射到 perm auth_key 授权。—— ✅ +- **P3.6 官方系统账号与登录消息**:内置 777000 官方账号,`auth.signIn/signUp` 成功后写入登录消息, + `messages.getDialogs/getHistory/search/readHistory` 返回或更新账号视角下的官方会话,并支持 TDesktop + 的 offset/hash 参数;`messages.getDialogs` 的 count/hash 按企业版同样的“完整列表先统计、再分页”语义计算; + `messages.getPeerDialogs` 按企业版同样的指定 peer 查询路径返回 dialog/top message/users/state,缺失 user peer 返回空 dialog 占位;RPC 层延迟向当前 + session 推送 `updateNewMessage`,并向其它在线 session 推送新登录 `updateServiceNotification`; + 在线 session 需先完成 `updates.getState/getDifference` 才接收主动 updates,未 ready 的推送先暂存; + 同时写入账号级 `user_update_events`,`updates.getDifference` 可补偿登录官方消息与官方会话已读事件。—— ✅ +- **P4 消息闭环**:私聊文本 `messages.sendMessage/forwardMessages/readHistory/editMessage/deleteMessages/deleteHistory`、reply_to/forward header 元数据、双端 `message_boxes`、已读 inbox/outbox 回执、`user_update_events`、Redis `pts/box_id` allocator、transactional outbox、在线 session 批量推送、窗口限流、链路指标和游标分页已落地;媒体、群组/频道、完整 qts/seq 仍是后续阶段。 +- **P4.1 联系人与 dialog owner 视角**:`contacts.addContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses` 与 `messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks/hidePeerSettingsBar/getPeerSettings` 已接入 PG-backed 服务;集成测试覆盖不同 owner 对同一 user 的独立备注、备注 hash、reverse mutual、dialog 用户视角、置顶重排、manual unread 与隐藏 action bar。—— ✅ +- **P4.2 设置类 durable updates**:联系人 reset、dialog pinned、pinned order、manual unread、peer settings、dialog filters/order/reload 与 folder peers 均写入 `user_update_events`,并同事务写 `dispatch_outbox`;`updates.getDifference` 与 outbox TL 转换支持这些事件,且保存 pinned order / peer settings flags / dialog filter payload / folder peers / exclude auth key,在线 push 不再是唯一通知路径,也不会与 reliable outbox 双重推送。—— ✅ +- **P4.3 删除消息/清空历史**:`messages.deleteMessages/deleteHistory` 已支持 owner 视角软删除、`revoke` 对端清理、dialog top 重算/删除/`just_clear` 保留空 dialog、`updateDeleteMessages` durable payload(`message_ids` + `pts_count=len(message_ids)`)与 outbox 投递;后续新消息会正常重建被删除的 dialog。—— ✅ +- **P4.4 Dialog 分组/归档**:`messages.getDialogFilters/updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags` 与 `folders.editPeerFolders` 已接入 PG-backed 服务;集成测试覆盖 folder_id 0/1 主列表/归档、自定义 filter、tags 和归档还原。—— ✅ +- **P4.5 TDesktop 搜索入口**:`contacts.search` 已支持联系人/非联系人用户搜索,`contacts.getSponsoredPeers` 返回空 sponsored peers,`messages.searchGlobal` 接当前 owner 私聊文本搜索;查询保护与索引设计参考实现,避免客户端搜索框卡在 Loading。—— ✅ + +## 7. 与铁律的关系 + +- §2 类型边界:`store` 与 `domain` 均不依赖 `tg.*`,仅在 RPC 边界转换。 +- §3 协议/业务隔离:`auth_keys`(协议)与 `authorizations`(业务)分表落地。 +- §6 范围:第一阶段只实现登录 + 空账号主界面必经 RPC,以及登录必需的官方系统消息;二阶段已补私聊文本闭环,群组/频道/媒体仍留后续阶段。 + +## 8. 媒体层(documents / photos / blobs,2026-06-02) + +媒体闭环新增表(migration `0057_media`): + +- `upload_parts`(owner+file_id+part):`upload.saveFilePart/saveBigFilePart` 累积的分片,组装成 blob 后清理(transient)。 +- `file_blobs`(PK `location_key`):可下载二进制的索引,`doc:[:type]` / `photo::` → `backend/object_key/size/mime`;字节本身落 blob backend。 +- `documents` / `photos`:Telegram 文档 / 照片元数据(id、access_hash、file_reference、dc_id、attributes/thumbs/sizes JSONB)。 +- `documents.id` 持久化为 telesrv-owned 正数 id;外部导出资源的 source id 只在 seed 扫描文件名/JSON 时使用,进入 `documents` / `file_blobs` / `sticker_sets` / `available_reactions` 前已归一为服务端 id。RPC、`InputDocument` 与 `inputDocumentFileLocation` 均直接使用该 id。 +- `sticker_sets`:贴纸 / 自定义 emoji 集(含有序 `document_ids`、`packs`、`system_key` 路由系统集)。 +- `available_reactions`:reaction 目录(引用真实文档 id)。 +- `profile_photos`(owner_peer_type+id+photo_id):用户/频道头像历史,current=active 中 sort_order 最大者。 +- 消息表(`private_messages`/`message_boxes`/`channel_messages`)新增 `media` JSONB 快照列;放宽 body 非空 CHECK 为「body 非空 OR media 非空(OR channel action)」,支持「仅媒体」消息。 +- 头像反范式:`users.photo_*`(migration 0057 用户表无需改,富化在 users 服务)与 `channels.photo_*`(migration `0059`)。 +- `app_configs` 增 `reactions_default` 等(migration `0058`)。 + +blob backend:`internal/app/files`(`BlobBackend` 接口 + `LocalFS` 本地磁盘实现,内容寻址 sha256 两级 fanout 去重,默认 `data/blobs`)。`MediaStore`(PG)只管元数据 + `file_blobs` 索引;字节由 backend 按 `object_key` 读写。 + +种子导入:启动时 `files.Service.SeedMedia` 从 `TELESRV_STICKER_SEED_DIR`(真实 Telegram 导出,含 `available_reactions_raw.json` + 各集 `set_info.json` + `.tgs/.webp/缩略图`)幂等导入:JSON 元数据→表,二进制→blob,`dc_id` 重写为本 server DC。实测 74 reactions / 24 sets / ~1.5k documents / ~3k blob 索引 / ~2.8k 去重磁盘文件。 + +类型边界:`domain.Document/Photo/MessageMedia/StickerSet/AvailableReaction` 带 json tag(store 直接 marshal JSONB),完全不依赖 `tg.*`;domain↔tg 转换集中在 `internal/rpc/convert_media.go`。 \ No newline at end of file diff --git a/docs/tdesktop-patch-notes.md b/docs/tdesktop-patch-notes.md new file mode 100644 index 00000000..8d8b5a3e --- /dev/null +++ b/docs/tdesktop-patch-notes.md @@ -0,0 +1,116 @@ +# Telegram Desktop Patch Notes + +These notes describe the minimal Telegram Desktop patch required to connect a +self-built client to `telesrv`. + +## Target + +| Item | Value | +|---|---| +| Telegram Desktop commit | `9caf32dffc90ddd9bb08ad5777b865f729fa167b` | +| Describe | `v6.8.4-15-g9caf32dffc` | +| TL layer | 225 | +| Local DC id | `2` | +| Local endpoint | `127.0.0.1:2398` | +| IPv6 endpoint | `[::1]:2398` | + +`telesrv` defaults match this patch: + +```powershell +TELESRV_LISTEN=0.0.0.0:2398 +TELESRV_ADVERTISE_IP=127.0.0.1 +TELESRV_DC=2 +``` + +## RSA Key + +Telegram Desktop must contain the public key that matches the private key used +by your local `telesrv` instance. + +Start `telesrv` once so it creates `data/server_rsa.pem`, then export: + +```powershell +openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub +``` + +Copy the PEM contents of `data/server_rsa.pub` into both `kPublicRSAKeys` and +`kTestPublicRSAKeys` in: + +```text +Telegram/SourceFiles/mtproto/mtproto_dc_options.cpp +``` + +## DC List Patch + +In the same file, replace the built-in DC arrays with: + +```cpp +const BuiltInDc kBuiltInDcs[] = { + { 2, "127.0.0.1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsIPv6[] = { + { 2, "::1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsTest[] = { + { 2, "127.0.0.1", 2398 }, +}; + +const BuiltInDc kBuiltInDcsIPv6Test[] = { + { 2, "::1", 2398 }, +}; +``` + +In `DcOptions::constructFromBuiltIn()`, mark built-in endpoints TCP-only: + +```cpp +const auto flags = Flag::f_static | Flag::f_tcpo_only; +const auto flags = Flag::f_static | Flag::f_ipv6 | Flag::f_tcpo_only; +``` + +That is the whole client patch: local DC endpoints, matching RSA public key, +and TCP-only flags. + +## Build + +Follow Telegram Desktop's upstream build docs for your platform. For Windows +x64 at the pinned baseline: + +```powershell +git clone --recursive https://github.com/telegramdesktop/tdesktop.git +cd tdesktop +git checkout 9caf32dffc90ddd9bb08ad5777b865f729fa167b +git submodule update --init --recursive + +Telegram\build\prepare\win.bat +cd Telegram +configure.bat x64 -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH +``` + +Open `out\Telegram.slnx` in Visual Studio and build the `Telegram` project. + +## Multi-Client Local Test + +Run two clients with isolated working directories: + +```powershell +$tdesktop = "C:\path\to\tdesktop\out\Debug\Telegram.exe" +Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-alice") +Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-bob") +``` + +Use different phone numbers for Alice and Bob. The default development login +code is `12345`. + +## Verification Checklist + +- `telesrv` logs `tl_layer=225` and listens on `2398`. +- Telegram Desktop connects without reconnect loops. +- No new `NOT_IMPLEMENTED`, `Unhandled RPC`, `bad_msg`, panic, or internal error + appears in server logs during startup and basic messaging. +- A second client with a separate `-workdir` can sign in and exchange messages + with the first client. + +Keep every client-side workaround mapped to a server-side compatibility decision +and keep protocol changes separate from UI experiments. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..f085caf2 --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module telesrv + +go 1.25.0 + +require ( + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/gotd/ige v0.2.2 + github.com/gotd/td v0.144.0 + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa + github.com/jackc/pgx/v5 v5.9.2 + github.com/redis/go-redis/v9 v9.20.0 + go.uber.org/zap v1.28.0 + golang.org/x/sync v0.20.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/xor v1.0.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gotd/neo v0.1.5 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ogen-go/ogen v1.19.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.43.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..c0099c11 --- /dev/null +++ b/go.sum @@ -0,0 +1,172 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= +github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.144.0 h1:FOSnbzutdWGDL/6w8v/pilqbUJNWqbKI8GrqusOQeOM= +github.com/gotd/td v0.144.0/go.mod h1:h56ixbXbenLoRQKiy0Qq668I9JWHMbeuv6BuFRmOaPI= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ogen-go/ogen v1.19.0 h1:YvdNpeQJ8A8dLLpS6Vs4WxXL53BT6tBPxH0VSjfALhA= +github.com/ogen-go/ogen v1.19.0/go.mod h1:DeShwO+TEpLYXNCuZliSAedphphXsJaTGGbmSomWUjE= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/app/account/doc.go b/internal/app/account/doc.go new file mode 100644 index 00000000..876bcc00 --- /dev/null +++ b/internal/app/account/doc.go @@ -0,0 +1,2 @@ +// Package account 是账号安全与设置应用服务。 +package account diff --git a/internal/app/account/service.go b/internal/app/account/service.go new file mode 100644 index 00000000..e6940443 --- /dev/null +++ b/internal/app/account/service.go @@ -0,0 +1,158 @@ +package account + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand") + +// Service 提供账号安全配置查询。 +type Service struct { + passwords store.PasswordStore + reactions store.AccountReactionSettingsStore +} + +// ServiceOption 调整 account 服务依赖。 +type ServiceOption func(*Service) + +// WithReactionSettings 注入账号级 reaction 设置持久化。 +func WithReactionSettings(reactions store.AccountReactionSettingsStore) ServiceOption { + return func(s *Service) { + s.reactions = reactions + } +} + +// NewService 创建 account 服务。 +func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { + s := &Service{passwords: passwords} + for _, opt := range opts { + opt(s) + } + return s +} + +// GetPassword 返回当前账号 2FA 配置。未登录或无记录时返回持久化策略的默认 no-password 配置。 +func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) { + if s == nil || s.passwords == nil || userID == 0 { + return defaultPasswordSettings(), nil + } + settings, found, err := s.passwords.GetByUser(ctx, userID) + if err != nil { + return domain.PasswordSettings{}, err + } + if !found { + return defaultPasswordSettings(), nil + } + if len(settings.SecureRandom) == 0 { + settings.SecureRandom = append([]byte(nil), defaultSecureRandom...) + } + return settings, nil +} + +func defaultPasswordSettings() domain.PasswordSettings { + return domain.PasswordSettings{SecureRandom: append([]byte(nil), defaultSecureRandom...)} +} + +// GetReactionSettings returns account-level reaction preferences. +func (s *Service) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) { + if s == nil || s.reactions == nil || userID == 0 { + return domain.DefaultAccountReactionSettings(), nil + } + settings, found, err := s.reactions.GetReactionSettings(ctx, userID) + if err != nil { + return domain.AccountReactionSettings{}, err + } + if !found { + return domain.DefaultAccountReactionSettings(), nil + } + return normalizeReactionSettings(settings), nil +} + +// SetReactionsNotifySettings stores reaction notification preferences. +func (s *Service) SetReactionsNotifySettings(ctx context.Context, userID int64, notify domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error) { + settings, err := s.GetReactionSettings(ctx, userID) + if err != nil { + return domain.AccountReactionSettings{}, err + } + settings.Notify = normalizeNotifySettings(notify) + return s.saveReactionSettings(ctx, userID, settings) +} + +// SetDefaultReaction stores the account default quick reaction. +func (s *Service) SetDefaultReaction(ctx context.Context, userID int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error) { + settings, err := s.GetReactionSettings(ctx, userID) + if err != nil { + return domain.AccountReactionSettings{}, err + } + if reaction.Type == "" || reaction.Emoticon == "" { + reaction = domain.DefaultAccountReactionSettings().DefaultReaction + } + settings.DefaultReaction = reaction + return s.saveReactionSettings(ctx, userID, settings) +} + +// SetPaidReactionPrivacy stores the account default paid reaction privacy. +func (s *Service) SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error) { + settings, err := s.GetReactionSettings(ctx, userID) + if err != nil { + return domain.AccountReactionSettings{}, err + } + settings.PaidPrivacy = normalizePaidPrivacy(privacy) + return s.saveReactionSettings(ctx, userID, settings) +} + +func (s *Service) saveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) (domain.AccountReactionSettings, error) { + settings = normalizeReactionSettings(settings) + if s == nil || s.reactions == nil || userID == 0 { + return settings, nil + } + return settings, s.reactions.SaveReactionSettings(ctx, userID, settings) +} + +func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings { + defaults := domain.DefaultAccountReactionSettings() + settings.Notify = normalizeNotifySettings(settings.Notify) + if settings.DefaultReaction.Type == "" || settings.DefaultReaction.Emoticon == "" { + settings.DefaultReaction = defaults.DefaultReaction + } + settings.PaidPrivacy = normalizePaidPrivacy(settings.PaidPrivacy) + return settings +} + +func normalizeNotifySettings(settings domain.ReactionsNotifySettings) domain.ReactionsNotifySettings { + if !validNotifyFrom(settings.MessagesFrom) { + settings.MessagesFrom = domain.ReactionNotifyFromContacts + } + if !validNotifyFrom(settings.StoriesFrom) { + settings.StoriesFrom = domain.ReactionNotifyFromContacts + } + if !validNotifyFrom(settings.PollVotesFrom) { + settings.PollVotesFrom = domain.ReactionNotifyFromContacts + } + return settings +} + +func validNotifyFrom(value domain.ReactionNotifyFrom) bool { + switch value { + case domain.ReactionNotifyFromNone, domain.ReactionNotifyFromContacts, domain.ReactionNotifyFromAll: + return true + default: + return false + } +} + +func normalizePaidPrivacy(privacy domain.PaidReactionPrivacy) domain.PaidReactionPrivacy { + switch privacy.Kind { + case domain.PaidReactionPrivacyAnonymous: + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous} + case domain.PaidReactionPrivacyPeer: + if privacy.Peer != nil && privacy.Peer.ID != 0 { + peer := *privacy.Peer + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer} + } + } + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault} +} diff --git a/internal/app/auth/doc.go b/internal/app/auth/doc.go new file mode 100644 index 00000000..c20621fb --- /dev/null +++ b/internal/app/auth/doc.go @@ -0,0 +1,5 @@ +// Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。 +// 第一阶段用开发固定验证码,2FA 配置由 account 服务持久化查询。 +// +// 输入输出在 RPC 边界使用 gotd/td/tg 类型,本包内部只用 internal/domain 模型。 +package auth diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go new file mode 100644 index 00000000..e0fa8e56 --- /dev/null +++ b/internal/app/auth/service.go @@ -0,0 +1,362 @@ +package auth + +import ( + "context" + "crypto/aes" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/gotd/ige" + "github.com/gotd/td/bin" + mtcrypto "github.com/gotd/td/crypto" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// 登录错误。 +var ( + ErrCodeExpired = errors.New("phone code expired or not found") + ErrCodeInvalid = errors.New("phone code invalid") + ErrEncryptedMessageInvalid = errors.New("encrypted message invalid") +) + +// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。 +type Service struct { + users store.UserStore + auths store.AuthorizationStore + codes store.CodeStore + authKeys store.AuthKeyStore + tempKeys store.TempAuthKeyBindingStore + messages store.MessageStore + dialogs store.DialogStore + fixedCode string + codeTTL time.Duration +} + +// Option 调整登录服务的可选依赖。 +type Option func(*Service) + +// WithLoginMessages 在登录成功后写入官方系统账号的登录消息与会话摘要。 +func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) Option { + return func(s *Service) { + s.messages = messages + s.dialogs = dialogs + } +} + +// NewService 创建登录服务。fixedCode 为开发固定验证码。 +func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service { + s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute} + for _, opt := range opts { + opt(s) + } + return s +} + +// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。 +func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error { + if s.authKeys != nil { + inner, err := s.validateBindTempAuthKey(ctx, sessionID, binding) + if err != nil { + return err + } + binding.TempSessionID = inner.TempSessionID + } + if s.tempKeys == nil { + return nil + } + return s.tempKeys.Save(ctx, binding) +} + +// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。 +func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) { + if s == nil || s.tempKeys == nil { + return [8]byte{}, false, nil + } + binding, found, err := s.tempKeys.GetByTemp(ctx, authKeyID) + if err != nil || !found { + return [8]byte{}, found, err + } + if binding.ExpiresAt <= int(time.Now().Unix()) { + return [8]byte{}, false, nil + } + return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil +} + +// UserID 返回 auth_key 当前绑定的用户。未登录时 found=false。 +func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) { + if s == nil || s.auths == nil { + return 0, false, nil + } + a, found, err := s.auths.ByAuthKey(ctx, authKeyID) + if err != nil || !found { + return 0, found, err + } + return a.UserID, true, nil +} + +// SendCode 为 phone 生成 phone_code_hash,暂存(开发)固定验证码,返回 hash。 +func (s *Service) SendCode(ctx context.Context, phone string) (string, error) { + hash, err := randomHex(8) + if err != nil { + return "", err + } + if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: normalizePhone(phone), Code: s.fixedCode}, s.codeTTL); err != nil { + return "", fmt.Errorf("store code: %w", err) + } + return hash, nil +} + +// SignIn 校验验证码并尝试登录。 +// needSignUp=true 表示验证码正确但用户不存在,调用方应引导注册(此时不删验证码,留给 SignUp)。 +func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (u domain.User, loginMessage domain.Message, needSignUp bool, err error) { + phone = normalizePhone(phone) + rec, found, err := s.codes.Get(ctx, phoneCodeHash) + if err != nil { + return domain.User{}, domain.Message{}, false, err + } + if !found { + return domain.User{}, domain.Message{}, false, ErrCodeExpired + } + if rec.Phone != phone || rec.Code != code { + return domain.User{}, domain.Message{}, false, ErrCodeInvalid + } + + existing, found, err := s.users.ByPhone(ctx, phone) + if err != nil { + return domain.User{}, domain.Message{}, false, err + } + if !found { + return domain.User{}, domain.Message{}, true, nil // 验证码对、但需注册 + } + if err := s.bind(ctx, auth, existing.ID); err != nil { + return domain.User{}, domain.Message{}, false, err + } + loginMessage, err = s.recordLoginMessage(ctx, existing.ID, rec.Code) + if err != nil { + return domain.User{}, domain.Message{}, false, err + } + _ = s.codes.Del(ctx, phoneCodeHash) + return existing, loginMessage, false, nil +} + +// SignUp 在 SignIn 判定需注册后创建用户并绑定授权。 +// signUp 的 TL 请求不带验证码,这里校验 phone_code_hash 仍有效且手机号匹配。 +func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) { + phone = normalizePhone(phone) + firstName = strings.TrimSpace(firstName) + lastName = strings.TrimSpace(lastName) + if firstName == "" || utf8.RuneCountInString(firstName) > 64 || utf8.RuneCountInString(lastName) > 64 { + return domain.User{}, domain.Message{}, domain.ErrFirstNameInvalid + } + rec, found, err := s.codes.Get(ctx, phoneCodeHash) + if err != nil { + return domain.User{}, domain.Message{}, err + } + if !found { + return domain.User{}, domain.Message{}, ErrCodeExpired + } + if rec.Phone != phone { + return domain.User{}, domain.Message{}, ErrCodeInvalid + } + + accessHash, err := randomInt64() + if err != nil { + return domain.User{}, domain.Message{}, err + } + u, err := s.users.Create(ctx, domain.User{ + AccessHash: accessHash, + Phone: phone, + FirstName: firstName, + LastName: lastName, + }) + if err != nil { + return domain.User{}, domain.Message{}, err + } + if err := s.bind(ctx, auth, u.ID); err != nil { + return domain.User{}, domain.Message{}, err + } + loginMessage, err := s.recordLoginMessage(ctx, u.ID, rec.Code) + if err != nil { + return domain.User{}, domain.Message{}, err + } + _ = s.codes.Del(ctx, phoneCodeHash) + return u, loginMessage, nil +} + +// LogOut 解绑当前 auth_key 的授权。 +func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error { + return s.auths.Delete(ctx, authKeyID) +} + +func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error { + auth.UserID = userID + return s.auths.Bind(ctx, auth) +} + +const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram! + +This code can be used to log in to your Telegram account. We never ask it for anything else. + +If you didn't request this code by trying to log in on another device, simply ignore this message.` + +func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) { + if s.messages == nil || s.dialogs == nil { + return domain.Message{}, nil + } + body := fmt.Sprintf(loginMessageTpl, code) + codeOffset := len("Login code: ") + msg, err := s.messages.Create(ctx, domain.Message{ + OwnerUserID: userID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: int(time.Now().Unix()), + Body: body, + Entities: []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: len("Login code:")}, + {Type: domain.MessageEntityBold, Offset: codeOffset, Length: len(code)}, + }, + }) + if err != nil { + return domain.Message{}, err + } + if err := s.dialogs.Upsert(ctx, userID, domain.Dialog{ + Peer: msg.Peer, + TopMessage: msg.ID, + TopMessageDate: msg.Date, + UnreadCount: 1, + }); err != nil { + return domain.Message{}, err + } + return msg, nil +} + +func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) { + if binding.ExpiresAt <= int(time.Now().Unix()) { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + + permID := authKeyIDFromInt64(binding.PermAuthKeyID) + perm, found, err := s.authKeys.Get(ctx, permID) + if err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if !found { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + + inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage) + if err != nil { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + if inner.Nonce != binding.Nonce || + inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) || + inner.PermAuthKeyID != binding.PermAuthKeyID || + inner.TempSessionID != sessionID || + inner.ExpiresAt != binding.ExpiresAt { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + return inner, nil +} + +func decryptBindAuthKeyInner(perm store.AuthKeyData, encrypted []byte) (mtcrypto.BindAuthKeyInner, error) { + var msg mtcrypto.EncryptedMessage + if err := msg.Decode(&bin.Buffer{Buf: encrypted}); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if msg.AuthKeyID != perm.ID || len(msg.EncryptedData) == 0 || len(msg.EncryptedData)%aes.BlockSize != 0 { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + + key, iv := mtcrypto.KeysV1(mtcrypto.Key(perm.Value), msg.MsgKey) + block, err := aes.NewCipher(key[:]) + if err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + plaintext := make([]byte, len(msg.EncryptedData)) + ige.DecryptBlocks(block, iv[:], plaintext, msg.EncryptedData) + + const headerLen = 16 + 8 + 4 + 4 + if len(plaintext) < headerLen { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + b := &bin.Buffer{Buf: plaintext} + randomPrefix := make([]byte, 16) + if err := b.ConsumeN(randomPrefix, len(randomPrefix)); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if _, err := b.Long(); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if _, err := b.Int32(); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + msgLen, err := b.Int32() + if err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if msgLen <= 0 { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + bodyEnd := headerLen + int(msgLen) + if bodyEnd > len(plaintext) { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + if msg.MsgKey != mtcrypto.MessageKeyV1(plaintext[:bodyEnd]) { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + + body := plaintext[headerLen:bodyEnd] + var inner mtcrypto.BindAuthKeyInner + if err := inner.Decode(&bin.Buffer{Buf: body}); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + return inner, nil +} + +func authKeyIDFromInt64(v int64) [8]byte { + var id [8]byte + binary.LittleEndian.PutUint64(id[:], uint64(v)) + return id +} + +func authKeyIDInt64(id [8]byte) int64 { + return int64(binary.LittleEndian.Uint64(id[:])) +} + +func normalizePhone(phone string) string { + var b strings.Builder + b.Grow(len(phone)) + for _, r := range phone { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return phone + } + return b.String() +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + return hex.EncodeToString(b), nil +} + +func randomInt64() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, fmt.Errorf("rand: %w", err) + } + return int64(binary.LittleEndian.Uint64(b[:])), nil +} diff --git a/internal/app/auth/service_test.go b/internal/app/auth/service_test.go new file mode 100644 index 00000000..a73a3ce5 --- /dev/null +++ b/internal/app/auth/service_test.go @@ -0,0 +1,259 @@ +package auth + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + mtcrypto "github.com/gotd/td/crypto" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) { + ctx := context.Background() + keys := memory.NewAuthKeyStore() + tempBindings := memory.NewTempAuthKeyBindingStore() + permKey := testAuthKey(0x11) + tempKey := testAuthKey(0x55) + saveAuthKey(t, keys, permKey) + saveAuthKey(t, keys, tempKey) + + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345") + + const ( + nonce = int64(0x12345678) + sessionID = int64(0x1020304050) + msgID = int64(0x0102030405060708) + ) + expiresAt := int(time.Now().Add(time.Hour).Unix()) + encrypted, err := mtcrypto.EncryptBindMessage( + bytes.NewReader(bytes.Repeat([]byte{0xCD}, 128)), + permKey, + msgID, + &mtcrypto.BindAuthKeyInner{ + Nonce: nonce, + TempAuthKeyID: tempKey.IntID(), + PermAuthKeyID: permKey.IntID(), + TempSessionID: sessionID, + ExpiresAt: expiresAt, + }, + ) + if err != nil { + t.Fatalf("encrypt bind message: %v", err) + } + + err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{ + TempAuthKeyID: tempKey.ID, + PermAuthKeyID: permKey.IntID(), + Nonce: nonce, + ExpiresAt: expiresAt, + EncryptedMessage: encrypted, + }) + if err != nil { + t.Fatalf("BindTempAuthKey valid message: %v", err) + } + + err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{ + TempAuthKeyID: tempKey.ID, + PermAuthKeyID: permKey.IntID(), + Nonce: nonce, + ExpiresAt: expiresAt, + EncryptedMessage: encrypted, + }) + if !errors.Is(err, ErrEncryptedMessageInvalid) { + t.Fatalf("BindTempAuthKey wrong session err = %v, want ErrEncryptedMessageInvalid", err) + } +} + +func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) { + ctx := context.Background() + tempBindings := memory.NewTempAuthKeyBindingStore() + permKey := testAuthKey(0x11) + tempKey := testAuthKey(0x55) + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345") + + if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{ + TempAuthKeyID: tempKey.ID, + PermAuthKeyID: permKey.IntID(), + ExpiresAt: int(time.Now().Add(time.Hour).Unix()), + }); err != nil { + t.Fatalf("save temp binding: %v", err) + } + + got, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID) + if err != nil { + t.Fatalf("ResolveAuthKey: %v", err) + } + if !ok || got != permKey.ID { + t.Fatalf("resolved = %x ok=%v, want perm %x", got, ok, permKey.ID) + } +} + +func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + authz := memory.NewAuthorizationStore() + svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345") + + hash, err := svc.SendCode(ctx, "+15550004310") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + + _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, "15550004310", hash, "12345") + if err != nil { + t.Fatalf("SignIn with digits-only phone: %v", err) + } + if !needSignUp { + t.Fatal("SignIn needSignUp = false, want true") + } + + u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+1 555 000 4310", hash, "Test", "User") + if err != nil { + t.Fatalf("SignUp with formatted phone: %v", err) + } + if u.Phone != "15550004310" { + t.Fatalf("created phone = %q, want normalized digits", u.Phone) + } + if u.ID != domain.UserIDSequenceBase { + t.Fatalf("created user id = %d, want base %d", u.ID, domain.UserIDSequenceBase) + } +} + +func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) { + ctx := context.Background() + authz := memory.NewAuthorizationStore() + svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345") + var key1, key2 [8]byte + key1[0] = 1 + key2[0] = 2 + + hash1, err := svc.SendCode(ctx, "+15550005001") + if err != nil { + t.Fatalf("SendCode user1: %v", err) + } + user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key1}, "+15550005001", hash1, "One", "") + if err != nil { + t.Fatalf("SignUp user1: %v", err) + } + hash2, err := svc.SendCode(ctx, "+15550005002") + if err != nil { + t.Fatalf("SendCode user2: %v", err) + } + user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key2}, "+15550005002", hash2, "Two", "") + if err != nil { + t.Fatalf("SignUp user2: %v", err) + } + + got1, found, err := svc.UserID(ctx, key1) + if err != nil || !found || got1 != user1.ID { + t.Fatalf("key1 user = %d found=%v err=%v, want %d", got1, found, err, user1.ID) + } + got2, found, err := svc.UserID(ctx, key2) + if err != nil || !found || got2 != user2.ID { + t.Fatalf("key2 user = %d found=%v err=%v, want %d", got2, found, err, user2.ID) + } + if got1 == got2 { + t.Fatalf("auth keys mapped to same user id %d", got1) + } +} + +func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) { + ctx := context.Background() + authz := memory.NewAuthorizationStore() + svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345") + var key [8]byte + key[0] = 9 + + hash1, err := svc.SendCode(ctx, "+15550006001") + if err != nil { + t.Fatalf("SendCode user1: %v", err) + } + user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006001", hash1, "One", "") + if err != nil { + t.Fatalf("SignUp user1: %v", err) + } + if got, found, err := svc.UserID(ctx, key); err != nil || !found || got != user1.ID { + t.Fatalf("initial auth user = %d found=%v err=%v, want %d", got, found, err, user1.ID) + } + if err := svc.LogOut(ctx, key); err != nil { + t.Fatalf("LogOut: %v", err) + } + if got, found, err := svc.UserID(ctx, key); err != nil || found || got != 0 { + t.Fatalf("after logout user = %d found=%v err=%v, want none", got, found, err) + } + + hash2, err := svc.SendCode(ctx, "+15550006002") + if err != nil { + t.Fatalf("SendCode user2: %v", err) + } + user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006002", hash2, "Two", "") + if err != nil { + t.Fatalf("SignUp user2: %v", err) + } + if got, found, err := svc.UserID(ctx, key); err != nil || !found || got != user2.ID { + t.Fatalf("after switch user = %d found=%v err=%v, want %d", got, found, err, user2.ID) + } + if user1.ID == user2.ID { + t.Fatalf("user ids did not change after switch: %d", user1.ID) + } +} + +func TestSignUpWritesOfficialLoginMessage(t *testing.T) { + ctx := context.Background() + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginMessages(messages, dialogs)) + + hash, err := svc.SendCode(ctx, "+15550004311") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + u, msg, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004311", hash, "Test", "User") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + + list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID { + t.Fatalf("dialogs = %+v, want official system dialog", list.Dialogs) + } + if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID || !list.Users[0].Verified || !list.Users[0].Support { + t.Fatalf("users = %+v, want verified support system user", list.Users) + } + if msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") { + t.Fatalf("login message = %+v, want returned official login code message", msg) + } + if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Login code: 12345") { + t.Fatalf("messages = %+v, want login code message", list.Messages) + } + if list.Dialogs[0].TopMessage != list.Messages[0].ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("dialog top/unread = %+v, message = %+v", list.Dialogs[0], list.Messages[0]) + } +} + +func testAuthKey(seed byte) mtcrypto.AuthKey { + var raw mtcrypto.Key + for i := range raw { + raw[i] = seed + byte(i) + } + return raw.WithID() +} + +func saveAuthKey(t *testing.T, keys store.AuthKeyStore, key mtcrypto.AuthKey) { + t.Helper() + var value [256]byte + copy(value[:], key.Value[:]) + if err := keys.Save(context.Background(), store.AuthKeyData{ID: key.ID, Value: value}); err != nil { + t.Fatalf("save auth key: %v", err) + } +} diff --git a/internal/app/channels/doc.go b/internal/app/channels/doc.go new file mode 100644 index 00000000..a0417993 --- /dev/null +++ b/internal/app/channels/doc.go @@ -0,0 +1,2 @@ +// Package channels contains domain service orchestration for Telegram channels and supergroups. +package channels diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go new file mode 100644 index 00000000..a8514a89 --- /dev/null +++ b/internal/app/channels/service.go @@ -0,0 +1,1514 @@ +package channels + +import ( + "context" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service exposes channel/supergroup business operations. +type Service struct { + channels store.ChannelStore +} + +// NewService creates a channel service. +func NewService(channels store.ChannelStore) *Service { + return &Service{channels: channels} +} + +// CreateMegagroupFromCreateChat handles messages.createChat by directly creating a megagroup. +func (s *Service) CreateMegagroupFromCreateChat(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { + req.CreatorUserID = userID + req.Broadcast = false + req.Megagroup = true + return s.CreateChannel(ctx, userID, req) +} + +// CreateChannel creates a broadcast channel or megagroup. +func (s *Service) CreateChannel(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.CreatorUserID == 0 { + req.CreatorUserID = userID + } + if req.CreatorUserID != userID { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if strings.TrimSpace(req.Title) == "" { + return domain.CreateChannelResult{}, domain.ErrChannelTitleInvalid + } + if len(req.MemberUserIDs) > domain.MaxChannelInviteUsers { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if !req.Broadcast && !req.Megagroup { + req.Broadcast = true + } + return s.channels.CreateChannel(ctx, req) +} + +// GetChannel returns channel data personalized for userID. +func (s *Service) GetChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + return s.channels.GetChannel(ctx, userID, channelID) +} + +// GetJoinableChannel returns a channel shell so RPC can verify access hash before join. +func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.GetChannelByID(ctx, channelID) +} + +// GetParticipants returns a bounded participants page. +func (s *Service) GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelParticipantList{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength { + return domain.ChannelParticipantList{}, domain.ErrChannelInvalid + } + return s.channels.GetParticipants(ctx, userID, channelID, filter, offset, capLimit(limit, domain.MaxChannelParticipantsLimit)) +} + +// GetParticipant returns one participant. +func (s *Service) GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || participantUserID == 0 { + return domain.ChannelMember{}, domain.ErrChannelInvalid + } + return s.channels.GetParticipant(ctx, userID, channelID, participantUserID) +} + +// InviteToChannel invites users to a channel/supergroup. +func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(userIDs) == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if len(userIDs) > domain.MaxChannelInviteUsers { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date) +} + +// JoinChannel joins current user to a channel/supergroup. +func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.JoinChannel(ctx, channelID, userID, date) +} + +// LeaveChannel leaves current user from a channel/supergroup. +func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.LeaveChannel(ctx, channelID, userID, date) +} + +// EditTitle edits a channel/supergroup title. +func (s *Service) EditTitle(ctx context.Context, userID int64, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.EditChannelTitleResult{}, domain.ErrChannelTitleInvalid + } + return s.channels.EditChannelTitle(ctx, req) +} + +// EditAbout edits a channel/supergroup description. +func (s *Service) EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.EditChannelAbout(ctx, req) +} + +// EditAdmin edits a participant's admin rights. +func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + return s.channels.EditChannelAdmin(ctx, req) +} + +// EditBanned edits a participant's banned rights. +func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { + return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid + } + return s.channels.EditChannelBanned(ctx, req) +} + +// EditDefaultBannedRights edits the channel/supergroup default restrictions. +func (s *Service) EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.EditChannelDefaultBannedRights(ctx, req) +} + +// DeleteChannel deletes a channel/supergroup. +func (s *Service) DeleteChannel(ctx context.Context, userID int64, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.DeleteChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteChannel(ctx, req) +} + +// CheckUsername checks whether a channel username is syntactically valid and free. +func (s *Service) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return false, domain.ErrChannelInvalid + } + username = normalizeChannelUsername(username) + if !validChannelUsername(username) { + return false, domain.ErrUsernameInvalid + } + return s.channels.CheckUsername(ctx, userID, channelID, username) +} + +// UpdateUsername sets or clears a channel public username. +func (s *Service) UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + req.Username = normalizeChannelUsername(req.Username) + if req.Username != "" && !validChannelUsername(req.Username) { + return domain.Channel{}, domain.ErrUsernameInvalid + } + return s.channels.UpdateUsername(ctx, req) +} + +// ListAdminedPublicChannels returns public channels/supergroups administered by user. +func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, nil + } + return s.channels.ListAdminedPublicChannels(ctx, userID) +} + +// ResolvePublicUsername resolves a public channel/supergroup username visible to userID. +func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + username = normalizeChannelUsername(username) + if !validChannelUsername(username) { + return domain.Channel{}, false, domain.ErrUsernameInvalid + } + return s.channels.ResolvePublicChannelUsername(ctx, userID, username) +} + +// SearchPublicChannels returns public username channels/supergroups for contacts.search. +func (s *Service) SearchPublicChannels(ctx context.Context, userID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid + } + query = normalizeChannelUsername(query) + if query == "" { + return domain.PublicChannelSearchResult{}, nil + } + if utf8.RuneCountInString(query) > domain.MaxPublicChannelSearchQueryLength { + return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid + } + return s.channels.SearchPublicChannels(ctx, userID, query, capLimit(limit, domain.MaxPublicChannelSearchLimit)) +} + +// SetSignatures toggles channel post signatures. +func (s *Service) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetSignatures(ctx, userID, channelID, enabled) +} + +// SetPhoto 设置/清除频道头像(photo==nil 表示清除)。 +func (s *Service) SetPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetChannelPhoto(ctx, userID, channelID, photo) +} + +// SetPreHistoryHidden toggles hidden history for new supergroup members. +func (s *Service) SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetPreHistoryHidden(ctx, userID, channelID, enabled) +} + +// SetParticipantsHidden toggles whether non-admins can view the full member list. +func (s *Service) SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetParticipantsHidden(ctx, userID, channelID, enabled) +} + +// SetForum toggles forum topics and their preferred TDesktop layout for a megagroup. +func (s *Service) SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetForum(ctx, userID, channelID, enabled, tabs) +} + +// SetAutotranslation toggles channel autotranslation for all users. +func (s *Service) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetAutotranslation(ctx, userID, channelID, enabled) +} + +// SetRestrictedSponsored toggles the channelFull restricted_sponsored state. +func (s *Service) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetRestrictedSponsored(ctx, userID, channelID, restricted) +} + +// SetPaidMessagesPrice stores the currently advertised paid-message price state. +func (s *Service) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || stars < 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetPaidMessagesPrice(ctx, userID, channelID, stars, broadcastMessagesAllowed) +} + +// SetAntiSpam toggles native antispam mode for a supergroup. +func (s *Service) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetAntiSpam(ctx, userID, channelID, enabled) +} + +// SetSlowMode changes the supergroup slow mode interval in seconds. +func (s *Service) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetSlowMode(ctx, userID, channelID, seconds) +} + +// SetNoForwards toggles channel/supergroup content protection. +func (s *Service) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetNoForwards(ctx, userID, channelID, enabled) +} + +// SetJoinToSend toggles whether non-members must join before sending in a megagroup. +func (s *Service) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetJoinToSend(ctx, userID, channelID, enabled) +} + +// SetJoinRequest toggles public join request approval for a megagroup. +func (s *Service) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetJoinRequest(ctx, userID, channelID, enabled) +} + +// SetAvailableReactions stores the bounded reaction policy for a channel/supergroup. +func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionItems || + policy.Limit < 0 || + policy.Limit > domain.MaxChannelReactionItems { + return domain.Channel{}, domain.ErrChannelInvalid + } + for _, emoticon := range policy.Emoticons { + if strings.TrimSpace(emoticon) == "" || utf8.RuneCountInString(emoticon) > domain.MaxChannelReactionEmoticonLength { + return domain.Channel{}, domain.ErrChannelInvalid + } + } + return s.channels.SetAvailableReactions(ctx, userID, channelID, policy) +} + +// SetColor stores the channel message/profile accent color. +func (s *Service) SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetColor(ctx, userID, channelID, forProfile, color) +} + +// SetEmojiStatus stores or clears the channel emoji status. +func (s *Service) SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if status.DocumentID < 0 || status.Until < 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetEmojiStatus(ctx, userID, channelID, status) +} + +// ListAdminLog returns one bounded, channel-scoped admin log page. +func (s *Service) ListAdminLog(ctx context.Context, userID int64, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(req.Query) > domain.MaxChannelAdminLogQueryLength || len(req.AdminUserIDs) > domain.MaxChannelAdminLogAdmins { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelAdminLogLimit) + return s.channels.ListAdminLog(ctx, req) +} + +// GetChannelForChangeInfo validates the current user can change channel metadata. +func (s *Service) GetChannelForChangeInfo(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + view, err := s.GetChannel(ctx, userID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + if view.Self.Role == domain.ChannelRoleCreator || (view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.ChangeInfo) { + return view, nil + } + return domain.ChannelView{}, domain.ErrChannelAdminRequired +} + +// SaveDefaultSendAs persists the current user's default send-as peer for one channel/supergroup dialog. +func (s *Service) SaveDefaultSendAs(ctx context.Context, userID int64, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + return s.channels.SaveChannelDefaultSendAs(ctx, req) +} + +// GetMessageViews returns channel-scoped view counters and optionally records first-time views. +func (s *Service) GetMessageViews(ctx context.Context, userID int64, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelMessageViewsResult{}, domain.ErrMessageIDInvalid + } + } + return s.channels.GetChannelMessageViews(ctx, req) +} + +// SetMessageReactions replaces the current user's reactions for one channel/supergroup message. +func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + return s.channels.SetChannelMessageReactions(ctx, req) +} + +// GetMessageReactions returns reaction summaries for exact channel/supergroup message ids. +func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + } + return s.channels.GetChannelMessageReactions(ctx, req) +} + +// ListMessageReactions returns a bounded per-peer reaction list for one message. +func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelMessageReactionListLimit) + return s.channels.ListChannelMessageReactions(ctx, req) +} + +type messageReactionUsageStore interface { + RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error +} + +type participantReactionModeratorStore interface { + DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) + DeleteChannelParticipantReactions(ctx context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) +} + +// RecordMessageReactionUse updates account-level top/recent reaction lists. +func (s *Service) RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { + if s == nil || s.channels == nil || userID == 0 || len(reactions) == 0 { + return nil + } + recorder, ok := s.channels.(messageReactionUsageStore) + if !ok { + return nil + } + return recorder.RecordMessageReactionUse(ctx, userID, reactions, addToRecent, date) +} + +// DeleteParticipantReaction removes one participant reaction on a channel message. +func (s *Service) DeleteParticipantReaction(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.ParticipantUserID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + moderator, ok := s.channels.(participantReactionModeratorStore) + if !ok { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + return moderator.DeleteChannelParticipantReaction(ctx, req) +} + +// DeleteParticipantReactions removes a bounded page of one participant's reactions. +func (s *Service) DeleteParticipantReactions(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { + req.Limit = domain.MaxDeleteParticipantReactionsBatch + } + moderator, ok := s.channels.(participantReactionModeratorStore) + if !ok { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + return moderator.DeleteChannelParticipantReactions(ctx, req) +} + +// TopReactions returns the current account's most frequently used message reactions. +func (s *Service) TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxTopMessageReactions { + limit = domain.MaxTopMessageReactions + } + return s.channels.ListTopMessageReactions(ctx, userID, limit) +} + +// RecentReactions returns the current account's recently used message reactions. +func (s *Service) RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxRecentMessageReactions { + limit = domain.MaxRecentMessageReactions + } + return s.channels.ListRecentMessageReactions(ctx, userID, limit) +} + +// ClearRecentReactions clears the current account's recently used message reactions. +func (s *Service) ClearRecentReactions(ctx context.Context, userID int64) error { + if s == nil || s.channels == nil || userID == 0 { + return domain.ErrChannelInvalid + } + return s.channels.ClearRecentMessageReactions(ctx, userID) +} + +// SavedReactionTags returns account-level saved-message reaction tag titles. +func (s *Service) SavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.SavedReactionTag{}, nil + } + if limit > domain.MaxSavedReactionTags { + limit = domain.MaxSavedReactionTags + } + return s.channels.ListSavedReactionTags(ctx, userID, limit) +} + +// UpdateSavedReactionTag stores the account-level custom title for one saved-message reaction tag. +func (s *Service) UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error { + if s == nil || s.channels == nil || userID == 0 { + return domain.ErrChannelInvalid + } + if tag.UserID == 0 { + tag.UserID = userID + } + if tag.UserID != userID || tag.Reaction.Type != domain.MessageReactionEmoji || tag.Reaction.Emoticon == "" { + return domain.ErrChannelInvalid + } + return s.channels.UpsertSavedReactionTag(ctx, tag) +} + +// ReadMessageContents returns visible channel messages whose content-read state can be synced. +func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReadChannelMessageContentsResult{}, domain.ErrMessageIDInvalid + } + } + return s.channels.ReadChannelMessageContents(ctx, req) +} + +// GetMessageAuthor resolves the user author for one visible channel/supergroup message. +func (s *Service) GetMessageAuthor(ctx context.Context, userID int64, req domain.GetChannelMessageAuthorRequest) (domain.GetChannelMessageAuthorResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.GetChannelMessageAuthorResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.GetChannelMessageAuthorResult{}, domain.ErrChannelInvalid + } + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return domain.GetChannelMessageAuthorResult{}, domain.ErrMessageIDInvalid + } + history, err := s.channels.GetChannelMessages(ctx, userID, req.ChannelID, []int{req.ID}) + if err != nil { + return domain.GetChannelMessageAuthorResult{}, err + } + if len(history.Messages) != 1 || history.Messages[0].SenderUserID == 0 { + return domain.GetChannelMessageAuthorResult{}, domain.ErrMessageIDInvalid + } + return domain.GetChannelMessageAuthorResult{ + Channel: history.Channel, + MessageID: history.Messages[0].ID, + SenderUserID: history.Messages[0].SenderUserID, + }, nil +} + +// CreateForumTopic creates one forum topic root service message. +func (s *Service) CreateForumTopic(ctx context.Context, userID int64, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || (strings.TrimSpace(req.Title) == "" && !req.TitleMissing) { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(req.Title) > domain.MaxChannelForumTopicTitleLength { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + return s.channels.CreateForumTopic(ctx, req) +} + +// EditForumTopic edits topic metadata and emits a topic edit service message. +func (s *Service) EditForumTopic(ctx context.Context, userID int64, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" || utf8.RuneCountInString(title) > domain.MaxChannelForumTopicTitleLength { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + *req.Title = title + } + return s.channels.EditForumTopic(ctx, req) +} + +// UpdatePinnedForumTopic pins or unpins one forum topic. +func (s *Service) UpdatePinnedForumTopic(ctx context.Context, userID int64, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid + } + return s.channels.UpdatePinnedForumTopic(ctx, req) +} + +// ReorderPinnedForumTopics stores a bounded pinned topic order. +func (s *Service) ReorderPinnedForumTopics(ctx context.Context, userID int64, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid + } + for _, id := range req.Order { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + } + return s.channels.ReorderPinnedForumTopics(ctx, req) +} + +// DeleteForumTopicHistory deletes one bounded topic-history page. +func (s *Service) DeleteForumTopicHistory(ctx context.Context, userID int64, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteForumTopicHistory(ctx, req) +} + +// GetForumTopics returns a bounded topic page without SQL OFFSET semantics. +func (s *Service) GetForumTopics(ctx context.Context, userID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + if filter.Limit < 0 || filter.Limit > domain.MaxChannelForumTopicsLimit { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(filter.Query) > domain.MaxChannelHistoryQueryLength { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + return s.channels.ListForumTopics(ctx, userID, filter) +} + +// GetForumTopicsByID returns a bounded topic id lookup. +func (s *Service) GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(ids) > domain.MaxChannelForumTopicIDs { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelForumTopicList{}, domain.ErrMessageIDInvalid + } + } + return s.channels.GetForumTopicsByID(ctx, userID, channelID, ids) +} + +// SendMessage sends one channel/supergroup message. +func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + return s.channels.SendChannelMessage(ctx, req) +} + +// EditMessage edits a channel/supergroup text message. +func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.ID <= 0 { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + return s.channels.EditChannelMessage(ctx, req) +} + +// DeleteMessages deletes a bounded set of channel/supergroup messages. +func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || len(req.IDs) == 0 { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxDeleteMessageIDs { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteChannelMessages(ctx, req) +} + +// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone. +func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteChannelHistory(ctx, req) +} + +// DeleteParticipantHistory deletes one bounded page of messages sent by a channel participant. +func (s *Service) DeleteParticipantHistory(ctx context.Context, userID int64, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteChannelParticipantHistory(ctx, req) +} + +// UpdatePinnedMessage pins or unpins a channel/supergroup message. +func (s *Service) UpdatePinnedMessage(ctx context.Context, userID int64, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid + } + return s.channels.UpdatePinnedMessage(ctx, req) +} + +// ExportInvite exports a channel/supergroup invite link. +func (s *Service) ExportInvite(ctx context.Context, userID int64, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { + return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid + } + return s.channels.ExportInvite(ctx, req) +} + +// CheckInvite checks an invite hash. +func (s *Service) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CheckChannelInviteResult{}, domain.ErrChannelInvalid + } + if strings.TrimSpace(hash) == "" { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty + } + return s.channels.CheckInvite(ctx, userID, strings.TrimSpace(hash), date) +} + +// ImportInvite imports an invite and joins the channel if possible. +func (s *Service) ImportInvite(ctx context.Context, userID int64, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || strings.TrimSpace(req.Hash) == "" { + return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty + } + req.Hash = strings.TrimSpace(req.Hash) + return s.channels.ImportInvite(ctx, req) +} + +// ListExportedInvites returns a bounded invite management page. +func (s *Service) ListExportedInvites(ctx context.Context, userID int64, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelInviteList{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.AdminUserID == 0 || req.OffsetDate < 0 { + return domain.ChannelInviteList{}, domain.ErrChannelInvalid + } + req.OffsetHash = strings.TrimSpace(req.OffsetHash) + req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit) + return s.channels.ListExportedInvites(ctx, req) +} + +// GetExportedInvite returns one exported invite by hash. +func (s *Service) GetExportedInvite(ctx context.Context, userID int64, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelInvite{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" { + return domain.ChannelInvite{}, domain.ErrInviteHashEmpty + } + return s.channels.GetExportedInvite(ctx, req) +} + +// EditExportedInvite edits or revokes one exported invite. +func (s *Service) EditExportedInvite(ctx context.Context, userID int64, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelInviteResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" { + return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty + } + if req.ExpireDate < 0 || req.UsageLimit < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { + return domain.EditChannelInviteResult{}, domain.ErrChannelInvalid + } + return s.channels.EditExportedInvite(ctx, req) +} + +// DeleteExportedInvite deletes one exported invite. +func (s *Service) DeleteExportedInvite(ctx context.Context, userID int64, req domain.DeleteChannelInviteRequest) error { + if s == nil || s.channels == nil || userID == 0 { + return domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" { + return domain.ErrInviteHashEmpty + } + return s.channels.DeleteExportedInvite(ctx, req) +} + +// DeleteRevokedExportedInvites deletes revoked invites for one admin. +func (s *Service) DeleteRevokedExportedInvites(ctx context.Context, userID int64, req domain.DeleteRevokedChannelInvitesRequest) error { + if s == nil || s.channels == nil || userID == 0 { + return domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests) + return s.channels.DeleteRevokedExportedInvites(ctx, req) +} + +// ListAdminsWithInvites returns admins that created invite links. +func (s *Service) ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + return s.channels.ListAdminsWithInvites(ctx, userID, channelID) +} + +// ListInviteImporters returns users joined/requested via invite links. +func (s *Service) ListInviteImporters(ctx context.Context, userID int64, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + req.Query = strings.TrimSpace(req.Query) + if req.UserID != userID || req.ChannelID == 0 || req.OffsetDate < 0 { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(req.Query) > domain.MaxChannelParticipantsQueryLength { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit) + return s.channels.ListInviteImporters(ctx, req) +} + +// PendingJoinRequests returns the bounded admin-facing join request summary. +func (s *Service) PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { + if s == nil || s.channels == nil || channelID == 0 { + return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid + } + limit = capLimit(limit, domain.MaxChannelPendingJoinRecentRequesters) + return s.channels.PendingJoinRequests(ctx, channelID, limit) +} + +// HideChatJoinRequest approves or dismisses one pending join request. +func (s *Service) HideChatJoinRequest(ctx context.Context, userID int64, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.TargetUserID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.HideChatJoinRequest(ctx, req) +} + +// HideAllChatJoinRequests approves or dismisses pending join requests in a bounded batch. +func (s *Service) HideAllChatJoinRequests(ctx context.Context, userID int64, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + if req.UserID != userID || req.ChannelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests) + return s.channels.HideAllChatJoinRequests(ctx, req) +} + +// ListDialogs returns current user's channel/supergroup dialog page. +func (s *Service) ListDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelDialogList{}, nil + } + return s.channels.ListChannelDialogs(ctx, userID, filter) +} + +// GetDialogs returns channel/supergroup dialogs by IDs. +func (s *Service) GetDialogs(ctx context.Context, userID int64, channelIDs []int64) (domain.ChannelDialogList, error) { + if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 { + return domain.ChannelDialogList{}, nil + } + if len(channelIDs) > domain.MaxDialogFolderPeers { + return domain.ChannelDialogList{}, domain.ErrChannelInvalid + } + return s.channels.GetChannelDialogs(ctx, userID, channelIDs) +} + +// SetViewForumAsMessages stores the current user's local forum presentation mode. +func (s *Service) SetViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return false, domain.ErrChannelInvalid + } + return s.channels.SetChannelViewForumAsMessages(ctx, userID, channelID, enabled) +} + +// CommonChannels returns megagroups shared by userID and req.TargetUserID. +func (s *Service) CommonChannels(ctx context.Context, userID int64, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CommonChannelsResult{}, nil + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.TargetUserID == 0 || req.TargetUserID == userID || req.MaxID < 0 { + return domain.CommonChannelsResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxCommonChannelsLimit) + return s.channels.ListCommonChannels(ctx, req) +} + +// LeftChannels returns a bounded export page of channels/supergroups the user has left. +func (s *Service) LeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { + if s == nil || s.channels == nil || userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { + return domain.LeftChannelsResult{}, domain.ErrChannelInvalid + } + return s.channels.ListLeftChannels(ctx, userID, offset, capLimit(limit, domain.MaxLeftChannelsLimit)) +} + +// InactiveChannels returns least recently active channels/supergroups for limits UI. +func (s *Service) InactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelDialogList{}, domain.ErrChannelInvalid + } + return s.channels.ListInactiveChannels(ctx, userID, capLimit(limit, domain.MaxInactiveChannelsLimit)) +} + +// ChannelRecommendations returns public broadcast channels for TDesktop similar/recommended UI. +func (s *Service) ChannelRecommendations(ctx context.Context, userID int64, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.SourceChannelID < 0 { + return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 { + req.Limit = domain.DefaultChannelRecommendationsLimit + } else { + req.Limit = capLimit(req.Limit, domain.MaxChannelRecommendationsLimit) + } + return s.channels.ListChannelRecommendations(ctx, req) +} + +// DiscussionGroups returns manageable megagroups that can be linked to a broadcast channel. +func (s *Service) DiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + return s.channels.ListDiscussionGroups(ctx, userID, capLimit(limit, domain.MaxDiscussionGroupsLimit)) +} + +// SetDiscussionGroup links or unlinks a broadcast channel and a discussion megagroup. +func (s *Service) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid + } + if broadcastID == 0 && groupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + return s.channels.SetDiscussionGroup(ctx, userID, broadcastID, groupID) +} + +// GetHistory returns a channel/supergroup history page. +func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(filter.Query) > domain.MaxChannelHistoryQueryLength { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + filter.Limit = capLimit(filter.Limit, 100) + return s.channels.ListChannelHistory(ctx, userID, filter) +} + +// SearchPosts returns a bounded page of public channel/supergroup posts. +func (s *Service) SearchPosts(ctx context.Context, userID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.OffsetRate < 0 || req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + if req.OffsetChannelID < 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if (strings.TrimSpace(req.Query) == "") == (strings.TrimSpace(req.Hashtag) == "") { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if strings.Contains(req.Hashtag, "#") || + utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength || + utf8.RuneCountInString(req.Hashtag) > domain.MaxChannelHistoryQueryLength { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + req.Query = strings.TrimSpace(req.Query) + req.Hashtag = strings.TrimSpace(req.Hashtag) + req.Limit = capLimit(req.Limit, domain.MaxChannelSearchPostsLimit) + return s.channels.SearchPublicPosts(ctx, userID, req) +} + +// SearchJoinedMessages returns current-account-visible channel/supergroup hits for messages.searchGlobal. +func (s *Service) SearchJoinedMessages(ctx context.Context, userID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.OffsetRate < 0 || req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + if req.OffsetChannelID < 0 || req.MinDate < 0 || req.MaxDate < 0 || (req.HasFolderID && req.FolderID < 0) { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.BroadcastsOnly && req.GroupsOnly { + return domain.ChannelHistory{}, nil + } + req.Query = strings.TrimSpace(req.Query) + if req.Query == "" { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelGlobalSearchLimit) + return s.channels.SearchJoinedMessages(ctx, userID, req) +} + +// GetReplies returns a bounded message thread page. +func (s *Service) GetReplies(ctx context.Context, userID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 || filter.RootMessageID <= 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if filter.RootMessageID > domain.MaxMessageBoxID || filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID || filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID || filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit) + return s.channels.ListChannelReplies(ctx, userID, filter) +} + +// GetUnreadMentions returns a bounded unread mention page for a channel/supergroup. +func (s *Service) GetUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if filter.TopMsgID < 0 || filter.TopMsgID > domain.MaxMessageBoxID || + filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID || + filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID || + filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + filter.Limit = capLimit(filter.Limit, domain.MaxChannelUnreadMentionsLimit) + return s.channels.ListChannelUnreadMentions(ctx, userID, filter) +} + +// ReadMentions clears unread mentions for a channel/supergroup in a bounded batch. +func (s *Service) ReadMentions(ctx context.Context, userID int64, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID { + return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelReadMentionsBatch) + return s.channels.ReadChannelMentions(ctx, req) +} + +// GetUnreadReactions returns a bounded unread reaction page for a channel/supergroup. +func (s *Service) GetUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if filter.TopMsgID < 0 || filter.TopMsgID > domain.MaxMessageBoxID || + filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID || + filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID || + filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + filter.Limit = capLimit(filter.Limit, domain.MaxChannelUnreadReactionsLimit) + return s.channels.ListChannelUnreadReactions(ctx, userID, filter) +} + +// ReadReactions clears unread reactions for a channel/supergroup in a bounded batch. +func (s *Service) ReadReactions(ctx context.Context, userID int64, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID { + return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelReadReactionsBatch) + return s.channels.ReadChannelReactions(ctx, req) +} + +// GetMessages returns a bounded exact-id channel/supergroup message batch. +func (s *Service) GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if len(ids) > domain.MaxGetMessageIDs { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + } + return s.channels.GetChannelMessages(ctx, userID, channelID, ids) +} + +// GetDiscussionMessage returns the root message used to open a discussion thread. +func (s *Service) GetDiscussionMessage(ctx context.Context, userID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelDiscussionMessage{}, domain.ErrChannelInvalid + } + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid + } + return s.channels.GetDiscussionMessage(ctx, userID, channelID, msgID) +} + +// ReadHistory advances current user's channel read watermark. +func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 { + return domain.ReadChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ReadChannelHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.ReadChannelHistory(ctx, req) +} + +// GetMessageReadParticipants returns a bounded read receipt list for a small megagroup message. +func (s *Service) GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.ChannelReadParticipantsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelReadParticipantsResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelReadParticipants) + return s.channels.ListMessageReadParticipants(ctx, req) +} + +// ActiveChannelIDsForUser pages active joined channels for one user. +func (s *Service) ActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { + if s == nil || s.channels == nil || userID == 0 || afterChannelID < 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit) +} + +// ActiveMemberIDs returns a bounded list for transient online fanout such as typing. +func (s *Service) ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelTypingFanout { + limit = domain.MaxChannelTypingFanout + } + return s.channels.ListActiveChannelMemberIDs(ctx, userID, channelID, limit) +} + +// InviteAdminMemberIDs returns active admins that can manage join requests. +func (s *Service) InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) { + if s == nil || s.channels == nil || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + return s.channels.ListChannelInviteAdminMemberIDs(ctx, channelID, limit) +} + +// FilterActiveMemberIDs keeps only candidates that are active members of channelID. +// It is used by realtime fanout to intersect the online-user set with membership. +func (s *Service) FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) { + if s == nil || s.channels == nil || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + candidates := uniqueNonZero(userIDs) + if len(candidates) == 0 { + return nil, nil + } + return s.channels.FilterActiveChannelMemberIDs(ctx, channelID, candidates) +} + +// GetDifference returns channel-scoped pts difference. +func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 { + return domain.ChannelDifference{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ChannelDifference{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelDifferenceLimit) + return s.channels.ListChannelDifference(ctx, req) +} + +func capLimit(limit, max int) int { + if max <= 0 { + return limit + } + if limit <= 0 { + return max + } + if limit > max { + return max + } + return limit +} + +func uniqueNonZeroLimit(ids []int64, limit int) []int64 { + if limit <= 0 { + return nil + } + out := make([]int64, 0, minInt(len(ids), limit)) + seen := make(map[int64]struct{}, minInt(len(ids), limit)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + if len(out) >= limit { + break + } + } + return out +} + +func uniqueNonZero(ids []int64) []int64 { + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func normalizeChannelUsername(username string) string { + username = strings.TrimSpace(username) + username = strings.TrimPrefix(username, "@") + return strings.TrimSpace(username) +} + +func validChannelUsername(username string) bool { + if len(username) < 5 || len(username) > 32 { + return false + } + for i := 0; i < len(username); i++ { + c := username[i] + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + if i == 0 { + return false + } + case c == '_': + if i == 0 { + return false + } + default: + return false + } + } + return true +} diff --git a/internal/app/channels/service_test.go b/internal/app/channels/service_test.go new file mode 100644 index 00000000..73b10e5f --- /dev/null +++ b/internal/app/channels/service_test.go @@ -0,0 +1,1784 @@ +package channels + +import ( + "context" + "errors" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if !created.Channel.Megagroup || created.Channel.Broadcast { + t.Fatalf("channel flags = megagroup:%v broadcast:%v, want megagroup only", created.Channel.Megagroup, created.Channel.Broadcast) + } + if created.Channel.Pts != 1 || created.Message.ID != 1 || created.Event.PtsCount != 1 { + t.Fatalf("created pts/message/event = %+v/%+v/%+v, want initial pts=1 message id=1", created.Channel, created.Message, created.Event) + } + if created.Message.Action == nil || created.Message.Action.Type != domain.ChannelActionCreate { + t.Fatalf("create service action = %+v, want channel create", created.Message.Action) + } + + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 99, + Message: "hello", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if sent.Message.ID != 2 || sent.Message.Pts != 2 || sent.Event.Pts != 2 || sent.Event.PtsCount != 1 { + t.Fatalf("sent = %+v event=%+v, want message id/pts=2", sent.Message, sent.Event) + } + + duplicate, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 99, + Message: "hello again", + Date: 12, + }) + if err != nil { + t.Fatalf("duplicate SendMessage: %v", err) + } + if !duplicate.Duplicate || duplicate.Message.ID != sent.Message.ID || duplicate.Message.Body != "hello" { + t.Fatalf("duplicate = %+v, want original single-copy message", duplicate) + } + + history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history.Messages) != 2 || history.Messages[0].ID != 2 || history.Messages[1].ID != 1 { + t.Fatalf("history = %+v, want channel messages newest first", history.Messages) + } + + diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 1, Limit: 10}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if !diff.Final || diff.Pts != 2 || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "hello" { + t.Fatalf("diff = %+v, want single new channel message at pts=2", diff) + } + if _, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: sent.Event.Pts + 1, Limit: 10}); !errors.Is(err, domain.ErrPersistentTimestamp) { + t.Fatalf("future pts diff err = %v, want persistent timestamp invalid", err) + } + + read, err := service.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{ChannelID: created.Channel.ID, MaxID: 2}) + if err != nil { + t.Fatalf("ReadHistory: %v", err) + } + if !read.Changed || read.StillUnreadCount != 0 || read.Dialog.ReadInboxMaxID != 2 { + t.Fatalf("read = %+v, want read watermark at message 2", read) + } + if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != 1001 || read.OutboxUpdates[0].MaxID != sent.Message.ID { + t.Fatalf("read outbox updates = %+v, want owner read_outbox through sent message", read.OutboxUpdates) + } + ownerView, err := service.GetChannel(ctx, 1001, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel owner: %v", err) + } + if ownerView.Dialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("owner dialog read_outbox = %d, want %d", ownerView.Dialog.ReadOutboxMaxID, sent.Message.ID) + } +} + +func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Mentions", + MemberUserIDs: []int64{1002, 1003}, + Date: 1700000100, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 9101, + Message: "hello @friend", + MentionUserIDs: []int64{1002, 1002, 1001}, + Date: 1700000101, + }) + if err != nil { + t.Fatalf("SendMessage mention: %v", err) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel mentioned: %v", err) + } + if view.Dialog.UnreadMentions != 1 { + t.Fatalf("mentioned dialog unread mentions = %d, want 1", view.Dialog.UnreadMentions) + } + other, err := service.GetChannel(ctx, 1003, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel other: %v", err) + } + if other.Dialog.UnreadMentions != 0 { + t.Fatalf("unmentioned dialog unread mentions = %d, want 0", other.Dialog.UnreadMentions) + } + mentions, err := service.GetUnreadMentions(ctx, 1002, domain.ChannelUnreadMentionsFilter{ + ChannelID: created.Channel.ID, + OffsetID: 1, + AddOffset: -10, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetUnreadMentions: %v", err) + } + if mentions.Count != 1 || len(mentions.Messages) != 1 || mentions.Messages[0].ID != sent.Message.ID { + t.Fatalf("mentions = count %d messages %+v, want sent message", mentions.Count, mentions.Messages) + } + read, err := service.ReadMentions(ctx, 1002, domain.ReadChannelMentionsRequest{ChannelID: created.Channel.ID}) + if err != nil { + t.Fatalf("ReadMentions: %v", err) + } + if read.ChannelPts != sent.Event.Pts || read.Offset != 0 || read.Cleared != 1 { + t.Fatalf("read mentions = %+v, want pts %d cleared 1 no offset", read, sent.Event.Pts) + } + mentions, err = service.GetUnreadMentions(ctx, 1002, domain.ChannelUnreadMentionsFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("GetUnreadMentions after read: %v", err) + } + if mentions.Count != 0 || len(mentions.Messages) != 0 { + t.Fatalf("mentions after read = count %d messages %d, want empty", mentions.Count, len(mentions.Messages)) + } +} + +func TestServiceRejectsMismatchedUserContextForStateReads(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Context Guard", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 91, + Message: "guard", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if _, err := service.ReadHistory(ctx, 1001, domain.ReadChannelHistoryRequest{ + UserID: 1002, + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + }); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("ReadHistory mismatched user err = %v, want ErrChannelInvalid", err) + } + if _, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + UserID: 1002, + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + }); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetMessageReadParticipants mismatched user err = %v, want ErrChannelInvalid", err) + } + if _, err := service.GetDifference(ctx, 1001, domain.ChannelDifferenceRequest{ + UserID: 1002, + ChannelID: created.Channel.ID, + Pts: 0, + }); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetDifference mismatched user err = %v, want ErrChannelInvalid", err) + } +} + +func TestServiceRejectsHugeChannelDialogVector(t *testing.T) { + service := NewService(memory.NewChannelStore()) + ids := make([]int64, domain.MaxDialogFolderPeers+1) + for i := range ids { + ids[i] = int64(i + 1) + } + if _, err := service.GetDialogs(context.Background(), 1001, ids); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetDialogs huge channel vector err = %v, want ErrChannelInvalid", err) + } +} + +func TestChannelHistorySearchQueryIsBounded(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + CreatorUserID: 1001, + Title: "Bounded History", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + _, err = service.GetHistory(ctx, 1001, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + Query: strings.Repeat("x", domain.MaxChannelHistoryQueryLength+1), + Limit: 10, + }) + if !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetHistory long query err = %v, want channel invalid", err) + } +} + +func TestChannelHistorySupportsOffsetDateOnly(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + CreatorUserID: 1001, + Title: "Date Cursor", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "old", + Date: 20, + }); err != nil { + t.Fatalf("send old: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "new", + Date: 30, + }); err != nil { + t.Fatalf("send new: %v", err) + } + + history, err := service.GetHistory(ctx, 1001, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + OffsetDate: 30, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history.Messages) != 2 || history.Messages[0].Body != "old" || history.Messages[1].Action == nil { + t.Fatalf("history = %+v, want messages older than offset date including service message", history.Messages) + } +} + +func TestChannelDifferenceTooLongReturnsLatestSnapshot(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + CreatorUserID: 1001, + Title: "Long Difference", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + var lastPts int + for i := 0; i < 12; i++ { + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: int64(i + 1), + Message: "msg", + Date: 11 + i, + }) + if err != nil { + t.Fatalf("SendMessage %d: %v", i, err) + } + lastPts = sent.Event.Pts + } + diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ + ChannelID: created.Channel.ID, + Pts: 0, + Limit: 3, + }) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if !diff.TooLong || !diff.Final || diff.Pts != lastPts { + t.Fatalf("diff = %+v, want tooLong final snapshot at pts %d", diff, lastPts) + } + if len(diff.NewMessages) == 0 || len(diff.NewMessages) > domain.MaxChannelDifferenceTooLongMessages { + t.Fatalf("tooLong messages = %d, want bounded latest snapshot", len(diff.NewMessages)) + } +} + +func TestGetParticipantsCapsDeepOffset(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002, 1003}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + page, err := service.GetParticipants(ctx, 1001, created.Channel.ID, domain.ChannelParticipantsFilter{}, domain.MaxChannelParticipantsOffset+1_000_000, 10) + if err != nil { + t.Fatalf("GetParticipants deep offset: %v", err) + } + if len(page.Participants) != 0 || page.Count != 3 { + t.Fatalf("deep offset page = %+v, want bounded empty page with real count", page) + } +} + +func TestDefaultBannedRightsRestrictMemberSendAndInvite(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Permissions", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + updated, err := service.EditDefaultBannedRights(ctx, 1001, domain.EditChannelDefaultBannedRightsRequest{ + ChannelID: created.Channel.ID, + BannedRights: domain.ChannelBannedRights{ + SendMessages: true, + InviteUsers: true, + }, + Date: 11, + }) + if err != nil { + t.Fatalf("EditDefaultBannedRights: %v", err) + } + if !updated.DefaultBannedRights.SendMessages || !updated.DefaultBannedRights.InviteUsers { + t.Fatalf("default banned rights = %+v, want send+invite restricted", updated.DefaultBannedRights) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "blocked", + Date: 12, + }); !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member SendMessage err = %v, want ErrChannelWriteForbidden", err) + } + if _, err := service.InviteToChannel(ctx, 1002, created.Channel.ID, []int64{1003}, 12); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member InviteToChannel err = %v, want ErrChannelAdminRequired", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "owner ok", + Date: 13, + }); err != nil { + t.Fatalf("creator SendMessage under default rights: %v", err) + } + if _, err := service.EditDefaultBannedRights(ctx, 1001, domain.EditChannelDefaultBannedRightsRequest{ + ChannelID: created.Channel.ID, + BannedRights: domain.ChannelBannedRights{}, + Date: 14, + }); err != nil { + t.Fatalf("clear default banned rights: %v", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 3, + Message: "member ok", + Date: 15, + }); err != nil { + t.Fatalf("member SendMessage after clear: %v", err) + } +} + +func TestSendMessageResolvesChannelReplyTopID(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Replies", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + root, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "root", + Date: 11, + }) + if err != nil { + t.Fatalf("send root: %v", err) + } + reply, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "reply", + ReplyTo: &domain.MessageReply{ + MessageID: root.Message.ID, + QuoteText: "ro", + QuoteOffset: 0, + QuoteEntities: []domain.MessageEntity{{ + Type: domain.MessageEntityBold, + Offset: 0, + Length: 2, + }}, + }, + Date: 12, + }) + if err != nil { + t.Fatalf("send reply: %v", err) + } + if reply.Message.ReplyTo == nil { + t.Fatal("reply metadata is nil") + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + if reply.Message.ReplyTo.MessageID != root.Message.ID || reply.Message.ReplyTo.Peer != channelPeer || reply.Message.ReplyTo.TopMessageID != root.Message.ID { + t.Fatalf("reply metadata = %+v, want channel peer and root top id %d", reply.Message.ReplyTo, root.Message.ID) + } + if reply.Message.ReplyTo.QuoteText != "ro" || len(reply.Message.ReplyTo.QuoteEntities) != 1 { + t.Fatalf("reply quote = %+v, want preserved quote metadata", reply.Message.ReplyTo) + } + + nested, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 3, + Message: "nested", + ReplyTo: &domain.MessageReply{MessageID: reply.Message.ID}, + Date: 13, + }) + if err != nil { + t.Fatalf("send nested reply: %v", err) + } + if nested.Message.ReplyTo == nil || nested.Message.ReplyTo.TopMessageID != root.Message.ID { + t.Fatalf("nested reply = %+v, want inherited top id %d", nested.Message.ReplyTo, root.Message.ID) + } + _, err = service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 4, + Message: "bad reply", + ReplyTo: &domain.MessageReply{MessageID: 999}, + Date: 14, + }) + if !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("bad reply err = %v, want ErrReplyMessageIDInvalid", err) + } + _, err = service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 5, + Message: "bad quote offset", + ReplyTo: &domain.MessageReply{ + MessageID: root.Message.ID, + QuoteText: "ro", + QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1, + }, + Date: 15, + }) + if !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err) + } +} + +func TestGetMessageReadParticipantsUsesChannelReadWatermark(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Readers", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 100, + Message: "read me", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if _, err := service.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{ + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 20, + }); err != nil { + t.Fatalf("ReadHistory: %v", err) + } + + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Date: 21, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants: %v", err) + } + if len(readers.Participants) != 1 || readers.Participants[0].UserID != 1002 || readers.Participants[0].Date != 20 { + t.Fatalf("readers = %+v, want friend read at date 20", readers.Participants) + } +} + +func TestParticipantsHiddenHidesMemberListAndReadParticipants(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Hidden Members", + MemberUserIDs: []int64{1002, 1003}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 100, + Message: "read me", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if _, err := service.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{ + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 20, + }); err != nil { + t.Fatalf("ReadHistory: %v", err) + } + hidden, err := service.SetParticipantsHidden(ctx, 1001, created.Channel.ID, true) + if err != nil { + t.Fatalf("SetParticipantsHidden: %v", err) + } + if !hidden.ParticipantsHidden { + t.Fatalf("channel = %+v, want participants hidden", hidden) + } + if _, err := service.SetParticipantsHidden(ctx, 1002, created.Channel.ID, false); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member SetParticipantsHidden err = %v, want ErrChannelAdminRequired", err) + } + members, err := service.GetParticipants(ctx, 1002, created.Channel.ID, domain.ChannelParticipantsFilter{}, 0, 10) + if err != nil { + t.Fatalf("GetParticipants hidden member view: %v", err) + } + if len(members.Participants) != 0 || members.Count != hidden.ParticipantsCount { + t.Fatalf("hidden members page = %+v, want empty page with aggregate count", members) + } + admins, err := service.GetParticipants(ctx, 1002, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}, 0, 10) + if err != nil { + t.Fatalf("GetParticipants hidden admins: %v", err) + } + if len(admins.Participants) != 1 || admins.Participants[0].UserID != 1001 { + t.Fatalf("hidden admins page = %+v, want creator visible", admins.Participants) + } + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Date: 21, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants hidden: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("hidden readers = %+v, want none", readers.Participants) + } +} + +func TestBroadcastRejectsMemberPost(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + + created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "News", + Broadcast: true, + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if !created.Channel.Broadcast || created.Channel.Megagroup { + t.Fatalf("channel flags = broadcast:%v megagroup:%v, want broadcast only", created.Channel.Broadcast, created.Channel.Megagroup) + } + + _, err = service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "member post", + Date: 11, + }) + if !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member SendMessage error = %v, want ErrChannelWriteForbidden", err) + } + + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "owner post", + Date: 12, + }) + if err != nil { + t.Fatalf("creator SendMessage: %v", err) + } + if !sent.Message.Post { + t.Fatalf("broadcast message Post=false, want true") + } +} + +func TestChannelEditDeleteAndLocalClearUseChannelPts(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + first, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 1, Message: "one", Date: 11}) + if err != nil { + t.Fatalf("SendMessage first: %v", err) + } + second, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "two", Date: 12}) + if err != nil { + t.Fatalf("SendMessage second: %v", err) + } + + edited, err := service.EditMessage(ctx, 1002, domain.EditChannelMessageRequest{ + ChannelID: created.Channel.ID, + ID: second.Message.ID, + Message: "two edited", + EditDate: 13, + }) + if err != nil { + t.Fatalf("EditMessage: %v", err) + } + if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Event.Pts != 4 || edited.Event.PtsCount != 1 { + t.Fatalf("edit event = %+v, want channel edit pts=4 count=1", edited.Event) + } + duplicate, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "two retry", Date: 13}) + if err != nil { + t.Fatalf("duplicate SendMessage after edit: %v", err) + } + if !duplicate.Duplicate || duplicate.Event.Type != domain.ChannelUpdateNewMessage || duplicate.Message.Body != "two" || duplicate.Event.Message.Body != "two" { + t.Fatalf("duplicate after edit = %+v, want original new-message snapshot", duplicate) + } + + deleted, err := service.DeleteMessages(ctx, 1001, domain.DeleteChannelMessagesRequest{ + ChannelID: created.Channel.ID, + IDs: []int{first.Message.ID, second.Message.ID}, + Date: 14, + }) + if err != nil { + t.Fatalf("DeleteMessages: %v", err) + } + if deleted.Event.Type != domain.ChannelUpdateDeleteMessages || deleted.Event.Pts != 6 || deleted.Event.PtsCount != 2 { + t.Fatalf("delete event = %+v, want pts advanced by deleted id count", deleted.Event) + } + diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 3, Limit: 10}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.OtherUpdates) != 2 || diff.OtherUpdates[1].Type != domain.ChannelUpdateDeleteMessages || diff.Pts != 6 { + t.Fatalf("diff after edit/delete = %+v, want edit then delete through channel pts", diff) + } + + clear, err := service.DeleteHistory(ctx, 1002, domain.DeleteChannelHistoryRequest{ChannelID: created.Channel.ID, MaxID: 6}) + if err != nil { + t.Fatalf("DeleteHistory local: %v", err) + } + if clear.Event.Pts != 0 { + t.Fatalf("local clear event = %+v, want no channel pts event", clear.Event) + } + history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("GetHistory after local clear: %v", err) + } + if len(history.Messages) != 0 { + t.Fatalf("history after local clear = %+v, want hidden for current user", history.Messages) + } +} + +func TestDeleteParticipantHistoryDeletesOneBoundedSenderPage(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + ownerMsg, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 1, Message: "owner", Date: 11}) + if err != nil { + t.Fatalf("owner SendMessage: %v", err) + } + first, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "member one", Date: 12}) + if err != nil { + t.Fatalf("member first SendMessage: %v", err) + } + second, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 3, Message: "member two", Date: 13}) + if err != nil { + t.Fatalf("member second SendMessage: %v", err) + } + if _, err := service.DeleteParticipantHistory(ctx, 1002, domain.DeleteChannelParticipantHistoryRequest{ + ChannelID: created.Channel.ID, + ParticipantUserID: 1001, + Date: 14, + }); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member DeleteParticipantHistory err = %v, want ErrChannelAdminRequired", err) + } + + deleted, err := service.DeleteParticipantHistory(ctx, 1001, domain.DeleteChannelParticipantHistoryRequest{ + ChannelID: created.Channel.ID, + ParticipantUserID: 1002, + Date: 15, + }) + if err != nil { + t.Fatalf("DeleteParticipantHistory: %v", err) + } + if deleted.Event.Type != domain.ChannelUpdateDeleteMessages || deleted.Event.PtsCount != 2 || deleted.Offset != 0 { + t.Fatalf("deleted = %+v, want one delete update with pts_count=2", deleted) + } + wantDeleted := map[int]bool{first.Message.ID: true, second.Message.ID: true} + for _, id := range deleted.DeletedIDs { + delete(wantDeleted, id) + } + if len(wantDeleted) != 0 { + t.Fatalf("deleted IDs = %+v, missing member messages %+v", deleted.DeletedIDs, wantDeleted) + } + history, err := service.GetHistory(ctx, 1001, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history.Messages) != 2 || history.Messages[0].ID != ownerMsg.Message.ID { + t.Fatalf("history after participant delete = %+v, want owner message and create service only", history.Messages) + } +} + +func TestChannelAdminTitlePinAndInvite(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + + admin, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + InviteUsers: true, + PinMessages: true, + }, + Rank: "ops", + Date: 11, + }) + if err != nil { + t.Fatalf("EditAdmin: %v", err) + } + if admin.Participant.Role != domain.ChannelRoleAdmin || !admin.Participant.AdminRights.PinMessages || admin.Channel.AdminsCount != 2 { + t.Fatalf("admin result = %+v, want promoted admin with counts", admin) + } + if admin.Event.Type != domain.ChannelUpdateParticipant || admin.Event.PtsCount != 1 || admin.Event.Participant.UserID != 1002 || admin.Event.Previous.UserID != 1002 { + t.Fatalf("admin participant event = %+v, want durable participant transition", admin.Event) + } + diffAfterAdmin, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 1, Limit: 10}) + if err != nil { + t.Fatalf("GetDifference after admin: %v", err) + } + if len(diffAfterAdmin.OtherUpdates) != 1 || diffAfterAdmin.OtherUpdates[0].Type != domain.ChannelUpdateParticipant { + t.Fatalf("diff after admin = %+v, want participant update in channel difference", diffAfterAdmin) + } + admins, err := service.GetParticipants(ctx, 1001, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}, 0, 10) + if err != nil { + t.Fatalf("GetParticipants admins: %v", err) + } + if len(admins.Participants) != 2 || admins.Participants[1].UserID != 1002 { + t.Fatalf("admins participants = %+v, want creator and promoted admin", admins.Participants) + } + + renamed, err := service.EditTitle(ctx, 1002, domain.EditChannelTitleRequest{ChannelID: created.Channel.ID, Title: "Team 2", Date: 12}) + if err != nil { + t.Fatalf("EditTitle by promoted admin: %v", err) + } + if renamed.Channel.Title != "Team 2" || renamed.Event.Type != domain.ChannelUpdateNewMessage || renamed.Message.Action.Type != domain.ChannelActionEditTitle { + t.Fatalf("renamed = %+v message=%+v, want edit-title service message", renamed.Channel, renamed.Message) + } + + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 42, Message: "pin me", Date: 13}) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + pinned, err := service.UpdatePinnedMessage(ctx, 1002, domain.UpdateChannelPinnedMessageRequest{ + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Pinned: true, + Date: 14, + }) + if err != nil { + t.Fatalf("UpdatePinnedMessage: %v", err) + } + if pinned.Channel.PinnedMessageID != sent.Message.ID || pinned.Event.Type != domain.ChannelUpdatePinnedMessages || !pinned.Event.Pinned { + t.Fatalf("pinned = %+v, want pinned channel message event", pinned) + } + + invited, err := service.InviteToChannel(ctx, 1002, created.Channel.ID, []int64{1004}, 15) + if err != nil { + t.Fatalf("InviteToChannel: %v", err) + } + if len(invited.Members) != 1 || invited.Members[0].UserID != 1004 { + t.Fatalf("invited = %+v, want invited user", invited.Members) + } + + invite, err := service.ExportInvite(ctx, 1002, domain.ExportChannelInviteRequest{ChannelID: created.Channel.ID, Title: "join", Date: 15}) + if err != nil { + t.Fatalf("ExportInvite: %v", err) + } + checked, err := service.CheckInvite(ctx, 1003, invite.Invite.Hash, 16) + if err != nil { + t.Fatalf("CheckInvite: %v", err) + } + if checked.Already || checked.Channel.ID != created.Channel.ID { + t.Fatalf("checked invite = %+v, want preview for non-member", checked) + } + joined, err := service.ImportInvite(ctx, 1003, domain.ImportChannelInviteRequest{Hash: invite.Invite.Hash, Date: 17}) + if err != nil { + t.Fatalf("ImportInvite: %v", err) + } + if len(joined.Members) != 1 || joined.Members[0].UserID != 1003 || joined.Event.Pts == 0 { + t.Fatalf("joined = %+v, want imported member with megagroup join event", joined) + } + forum, err := service.SetForum(ctx, 1001, created.Channel.ID, true, true) + if err != nil { + t.Fatalf("SetForum: %v", err) + } + if !forum.Forum || !forum.ForumTabs { + t.Fatalf("forum = %+v, want enabled with tabs", forum) + } + antiSpam, err := service.SetAntiSpam(ctx, 1001, created.Channel.ID, true) + if err != nil { + t.Fatalf("SetAntiSpam: %v", err) + } + if !antiSpam.AntiSpam { + t.Fatalf("antiSpam = %+v, want enabled", antiSpam) + } + logs, err := service.ListAdminLog(ctx, 1001, domain.ChannelAdminLogRequest{ChannelID: created.Channel.ID, Limit: 20}) + if err != nil { + t.Fatalf("ListAdminLog: %v", err) + } + seen := map[domain.ChannelAdminLogEventType]bool{} + for _, event := range logs.Events { + seen[event.Type] = true + } + for _, typ := range []domain.ChannelAdminLogEventType{ + domain.ChannelAdminLogParticipantPromote, + domain.ChannelAdminLogChangeTitle, + domain.ChannelAdminLogUpdatePinned, + domain.ChannelAdminLogParticipantInvite, + domain.ChannelAdminLogParticipantJoin, + domain.ChannelAdminLogToggleForum, + domain.ChannelAdminLogToggleAntiSpam, + } { + if !seen[typ] { + t.Fatalf("admin logs missing %s in %+v", typ, logs.Events) + } + } + pinnedOnly, err := service.ListAdminLog(ctx, 1001, domain.ChannelAdminLogRequest{ + ChannelID: created.Channel.ID, + Limit: 10, + Filter: domain.ChannelAdminLogFilter{Pinned: true}, + }) + if err != nil { + t.Fatalf("ListAdminLog pinned: %v", err) + } + if len(pinnedOnly.Events) != 1 || pinnedOnly.Events[0].Type != domain.ChannelAdminLogUpdatePinned || pinnedOnly.Events[0].Message == nil { + t.Fatalf("pinned admin logs = %+v, want one update_pinned with message", pinnedOnly.Events) + } + if _, err := service.ListAdminLog(ctx, 1003, domain.ChannelAdminLogRequest{ChannelID: created.Channel.ID, Limit: 10}); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("non-admin ListAdminLog err = %v, want ErrChannelAdminRequired", err) + } +} + +func TestChannelAboutRequiresChangeInfo(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + + if _, err := service.EditAbout(ctx, 1002, domain.EditChannelAboutRequest{ + ChannelID: created.Channel.ID, + About: "member cannot edit", + Date: 11, + }); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("EditAbout by member err = %v, want ErrChannelAdminRequired", err) + } + + updated, err := service.EditAbout(ctx, 1001, domain.EditChannelAboutRequest{ + ChannelID: created.Channel.ID, + About: "owner about", + Date: 12, + }) + if err != nil { + t.Fatalf("EditAbout by owner: %v", err) + } + if updated.About != "owner about" { + t.Fatalf("updated about = %q, want owner about", updated.About) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel by member: %v", err) + } + if view.Channel.About != "owner about" { + t.Fatalf("member view about = %q, want owner about", view.Channel.About) + } + + if _, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 13, + }); err != nil { + t.Fatalf("EditAdmin: %v", err) + } + updated, err = service.EditAbout(ctx, 1002, domain.EditChannelAboutRequest{ + ChannelID: created.Channel.ID, + About: "admin about", + Date: 14, + }) + if err != nil { + t.Fatalf("EditAbout by change_info admin: %v", err) + } + if updated.About != "admin about" { + t.Fatalf("updated about = %q, want admin about", updated.About) + } +} + +func TestChannelBanAndDeletePermissions(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.DeleteChannel(ctx, 1002, domain.DeleteChannelRequest{ChannelID: created.Channel.ID, Date: 11}); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member DeleteChannel err = %v, want ErrChannelAdminRequired", err) + } + banned, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{ + ViewMessages: true, + UntilDate: 100, + }, + Date: 12, + }) + if err != nil { + t.Fatalf("EditBanned: %v", err) + } + if banned.Participant.Status != domain.ChannelMemberKicked || banned.Channel.ParticipantsCount != 1 || banned.Channel.KickedCount != 1 { + t.Fatalf("banned = %+v, want kicked participant and counts", banned) + } + if banned.Event.Type != domain.ChannelUpdateParticipant || banned.Event.Participant.Status != domain.ChannelMemberKicked || banned.Event.PtsCount != 1 { + t.Fatalf("ban participant event = %+v, want durable kicked transition", banned.Event) + } + kicked, err := service.GetParticipants(ctx, 1001, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsKicked}, 0, 10) + if err != nil { + t.Fatalf("GetParticipants kicked: %v", err) + } + if len(kicked.Participants) != 1 || kicked.Participants[0].UserID != 1002 || kicked.Participants[0].InviterUserID != 1001 { + t.Fatalf("kicked participants = %+v, want kicked user with actor as inviter/kicked_by", kicked.Participants) + } + hidden, err := service.GetParticipants(ctx, 1002, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsKicked}, 0, 10) + if !errors.Is(err, domain.ErrChannelUserBanned) && (err != nil || len(hidden.Participants) != 0) { + t.Fatalf("banned viewer kicked participants = %+v err=%v, want no access", hidden.Participants, err) + } + if _, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("banned GetHistory err = %v, want ErrChannelUserBanned", err) + } + if _, err := service.JoinChannel(ctx, 1002, created.Channel.ID, 13); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("kicked JoinChannel err = %v, want ErrChannelUserBanned", err) + } + deleted, err := service.DeleteChannel(ctx, 1001, domain.DeleteChannelRequest{ChannelID: created.Channel.ID, Date: 13}) + if err != nil { + t.Fatalf("creator DeleteChannel: %v", err) + } + if !deleted.Channel.Deleted { + t.Fatalf("deleted = %+v, want deleted channel", deleted) + } +} + +func TestChannelInviteCannotBypassKickedMemberWithoutBanRight(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Invite Kicked", + MemberUserIDs: []int64{1002, 1003}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{ + ViewMessages: true, + UntilDate: 100, + }, + Date: 11, + }); err != nil { + t.Fatalf("EditBanned: %v", err) + } + if _, err := service.InviteToChannel(ctx, 1003, created.Channel.ID, []int64{1002}, 12); !errors.Is(err, domain.ErrUserKicked) { + t.Fatalf("member InviteToChannel kicked err = %v, want ErrUserKicked", err) + } + restored, err := service.InviteToChannel(ctx, 1001, created.Channel.ID, []int64{1002}, 13) + if err != nil { + t.Fatalf("creator InviteToChannel kicked: %v", err) + } + if len(restored.Members) != 1 || restored.Members[0].Status != domain.ChannelMemberActive || restored.Members[0].BannedRights != (domain.ChannelBannedRights{}) { + t.Fatalf("restored members = %+v, want active unbanned member", restored.Members) + } + if restored.Channel.ParticipantsCount != 3 || restored.Channel.KickedCount != 0 { + t.Fatalf("restored counts = participants:%d kicked:%d, want 3/0", restored.Channel.ParticipantsCount, restored.Channel.KickedCount) + } + if _, err := service.InviteToChannel(ctx, 1001, created.Channel.ID, []int64{1002}, 14); !errors.Is(err, domain.ErrUserAlreadyParticipant) { + t.Fatalf("duplicate InviteToChannel err = %v, want ErrUserAlreadyParticipant", err) + } +} + +func TestChannelLeaveAndRejoinRestoresParticipantCountAndNotifiesLeaver(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Leave Rejoin", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + left, err := service.LeaveChannel(ctx, 1002, created.Channel.ID, 11) + if err != nil { + t.Fatalf("LeaveChannel: %v", err) + } + if left.Members[0].Status != domain.ChannelMemberLeft || left.Channel.ParticipantsCount != 1 { + t.Fatalf("left result = %+v, want left member and participants=1", left) + } + hasLeaverRecipient := false + for _, id := range left.Recipients { + if id == 1002 { + hasLeaverRecipient = true + break + } + } + if !hasLeaverRecipient { + t.Fatalf("leave recipients = %+v, want leaver included for other sessions", left.Recipients) + } + rejoined, err := service.JoinChannel(ctx, 1002, created.Channel.ID, 12) + if err != nil { + t.Fatalf("JoinChannel after leave: %v", err) + } + if rejoined.Members[0].Status != domain.ChannelMemberActive || rejoined.Channel.ParticipantsCount != 2 { + t.Fatalf("rejoined result = %+v, want active member and participants=2", rejoined) + } + if _, err := service.JoinChannel(ctx, 1002, created.Channel.ID, 13); !errors.Is(err, domain.ErrUserAlreadyParticipant) { + t.Fatalf("duplicate JoinChannel err = %v, want ErrUserAlreadyParticipant", err) + } +} + +func TestChannelUsernameAndSignatures(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if ok, err := service.CheckUsername(ctx, 1001, created.Channel.ID, "team_public"); err != nil || !ok { + t.Fatalf("CheckUsername free = ok %v err %v, want true", ok, err) + } + public, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: "@team_public", + }) + if err != nil { + t.Fatalf("UpdateUsername: %v", err) + } + if public.Username != "team_public" { + t.Fatalf("public username = %q, want team_public", public.Username) + } + if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ChannelID: created.Channel.ID, Username: "TEAM_PUBLIC"}); !errors.Is(err, domain.ErrChannelNotModified) { + t.Fatalf("UpdateUsername same username err = %v, want ErrChannelNotModified", err) + } + if _, err := service.UpdateUsername(ctx, 1002, domain.UpdateChannelUsernameRequest{ChannelID: created.Channel.ID, Username: "friend_try"}); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("non-owner UpdateUsername err = %v, want ErrChannelAdminRequired", err) + } + + other, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{Title: "Other", Date: 11}) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat other: %v", err) + } + if ok, err := service.CheckUsername(ctx, 1001, other.Channel.ID, "TEAM_PUBLIC"); err != nil || ok { + t.Fatalf("CheckUsername occupied = ok %v err %v, want false/nil", ok, err) + } + if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ChannelID: other.Channel.ID, Username: "team_public"}); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("UpdateUsername occupied err = %v, want ErrUsernameOccupied", err) + } + admined, err := service.ListAdminedPublicChannels(ctx, 1001) + if err != nil { + t.Fatalf("ListAdminedPublicChannels: %v", err) + } + if len(admined) != 1 || admined[0].ID != created.Channel.ID { + t.Fatalf("admined public = %+v, want first channel only", admined) + } + + if _, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 12, + }); err != nil { + t.Fatalf("EditAdmin: %v", err) + } + signed, err := service.SetSignatures(ctx, 1002, created.Channel.ID, true) + if err != nil { + t.Fatalf("SetSignatures by change-info admin: %v", err) + } + if !signed.Signatures { + t.Fatalf("signed channel = %+v, want signatures enabled", signed) + } +} + +func TestPublicChannelSearchAndResolveUsername(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "CU Public Lab", + MemberUserIDs: []int64{1002}, + Date: 20, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + public, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: "cu_public_lab", + }) + if err != nil { + t.Fatalf("UpdateUsername: %v", err) + } + if _, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "CU Private Lab", + Date: 21, + }); err != nil { + t.Fatalf("CreateMegagroupFromCreateChat private: %v", err) + } + + joined, err := service.SearchPublicChannels(ctx, 1002, "CU Public", 10) + if err != nil { + t.Fatalf("SearchPublicChannels joined: %v", err) + } + if len(joined.MyResults) != 1 || joined.MyResults[0].ID != public.ID || len(joined.Results) != 0 { + t.Fatalf("joined public search = %+v, want my public channel only", joined) + } + global, err := service.SearchPublicChannels(ctx, 1003, "public", 10) + if err != nil { + t.Fatalf("SearchPublicChannels global: %v", err) + } + if len(global.Results) != 1 || global.Results[0].ID != public.ID || len(global.MyResults) != 0 { + t.Fatalf("global public search = %+v, want public channel result", global) + } + resolved, found, err := service.ResolvePublicUsername(ctx, 1003, "@CU_PUBLIC_LAB") + if err != nil || !found || resolved.ID != public.ID { + t.Fatalf("ResolvePublicUsername = %+v found %v err %v, want public channel", resolved, found, err) + } +} + +func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + const ( + ownerID = 1001 + viewerID = 1002 + ) + created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Public Preview", + Broadcast: true, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + public, err := service.UpdateUsername(ctx, ownerID, domain.UpdateChannelUsernameRequest{ + UserID: ownerID, + ChannelID: created.Channel.ID, + Username: "public_preview", + }) + if err != nil { + t.Fatalf("UpdateUsername: %v", err) + } + sent, err := service.SendMessage(ctx, ownerID, domain.SendChannelMessageRequest{ + ChannelID: public.ID, + RandomID: 101, + Message: "public preview post", + Date: 20, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + + view, err := service.GetChannel(ctx, viewerID, public.ID) + if err != nil { + t.Fatalf("non-member GetChannel public preview: %v", err) + } + if view.Self.Status != domain.ChannelMemberLeft || view.Self.UserID != viewerID { + t.Fatalf("preview self = %+v, want synthetic left member for viewer", view.Self) + } + if view.Dialog.UnreadCount != 0 || view.Dialog.ReadInboxMaxID < public.TopMessageID { + t.Fatalf("preview dialog = %+v, want no unread count", view.Dialog) + } + history, err := service.GetHistory(ctx, viewerID, domain.ChannelHistoryFilter{ChannelID: public.ID, Limit: 10}) + if err != nil { + t.Fatalf("non-member GetHistory public preview: %v", err) + } + if history.Self.Status != domain.ChannelMemberLeft || history.Self.UserID != viewerID { + t.Fatalf("history self = %+v, want synthetic left member for viewer", history.Self) + } + foundPost := false + for _, msg := range history.Messages { + if msg.Body == "public preview post" { + foundPost = true + } + } + if !foundPost { + t.Fatalf("history messages = %+v, want public preview post", history.Messages) + } + diff, err := service.GetDifference(ctx, viewerID, domain.ChannelDifferenceRequest{ + ChannelID: public.ID, + Pts: created.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("non-member GetDifference public preview: %v", err) + } + if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "public preview post" { + t.Fatalf("preview diff = %+v, want public preview post at current pts", diff) + } + if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID { + t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog) + } + + private, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Private Preview", + Broadcast: true, + Date: 30, + }) + if err != nil { + t.Fatalf("CreateChannel private: %v", err) + } + if _, err := service.GetChannel(ctx, viewerID, private.Channel.ID); !errors.Is(err, domain.ErrChannelPrivate) { + t.Fatalf("non-member private GetChannel err = %v, want ErrChannelPrivate", err) + } + if _, err := service.EditBanned(ctx, ownerID, domain.EditChannelBannedRequest{ + UserID: ownerID, + ChannelID: public.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID}, + BannedRights: domain.ChannelBannedRights{ViewMessages: true}, + Date: 40, + }); err != nil { + t.Fatalf("EditBanned public viewer: %v", err) + } + if _, err := service.GetHistory(ctx, viewerID, domain.ChannelHistoryFilter{ChannelID: public.ID, Limit: 10}); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("banned public preview GetHistory err = %v, want ErrChannelUserBanned", err) + } + if _, err := service.GetDifference(ctx, viewerID, domain.ChannelDifferenceRequest{ChannelID: public.ID, Pts: created.Event.Pts, Limit: 10}); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("banned public preview GetDifference err = %v, want ErrChannelUserBanned", err) + } +} + +func TestChannelDifferenceStartsAtMemberAvailableMinPts(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Visible PTS", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + promoted, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + InviteUsers: true, + }, + Date: 11, + }) + if err != nil { + t.Fatalf("EditAdmin: %v", err) + } + joined, err := service.JoinChannel(ctx, 1003, created.Channel.ID, 12) + if err != nil { + t.Fatalf("JoinChannel: %v", err) + } + if joined.Members[0].AvailableMinPts != promoted.Event.Pts { + t.Fatalf("joined available_min_pts = %d, want pre-join channel pts %d", joined.Members[0].AvailableMinPts, promoted.Event.Pts) + } + diff, err := service.GetDifference(ctx, 1003, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 0, Limit: 100}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.Pts != joined.Channel.Pts { + t.Fatalf("diff pts = %d, want current channel pts %d", diff.Pts, joined.Channel.Pts) + } + for _, msg := range diff.NewMessages { + if msg.Pts <= promoted.Event.Pts { + t.Fatalf("diff leaks pre-join message %+v at or before available_min_pts %d", msg, promoted.Event.Pts) + } + } + for _, event := range diff.OtherUpdates { + if event.Pts <= promoted.Event.Pts { + t.Fatalf("diff leaks pre-join event %+v at or before available_min_pts %d", event, promoted.Event.Pts) + } + } +} + +func TestChannelPreHistoryAndSlowMode(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Settings Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.SetPreHistoryHidden(ctx, 1002, created.Channel.ID, true); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member SetPreHistoryHidden err = %v, want ErrChannelAdminRequired", err) + } + hidden, err := service.SetPreHistoryHidden(ctx, 1001, created.Channel.ID, true) + if err != nil { + t.Fatalf("SetPreHistoryHidden: %v", err) + } + if !hidden.PreHistoryHidden { + t.Fatalf("hidden channel = %+v, want prehistory hidden", hidden) + } + hiddenMsg, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 1, Message: "before new member", Date: 90}) + if err != nil { + t.Fatalf("owner send before new member: %v", err) + } + if _, err := service.JoinChannel(ctx, 1003, created.Channel.ID, 95); err != nil { + t.Fatalf("new member JoinChannel: %v", err) + } + visibleMsg, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 100, Message: "after new member", Date: 96}) + if err != nil { + t.Fatalf("owner send after new member: %v", err) + } + mixedDelete, err := service.DeleteMessages(ctx, 1001, domain.DeleteChannelMessagesRequest{ + ChannelID: created.Channel.ID, + IDs: []int{hiddenMsg.Message.ID, visibleMsg.Message.ID}, + Date: 97, + }) + if err != nil { + t.Fatalf("mixed DeleteMessages: %v", err) + } + if mixedDelete.Event.PtsCount != 2 { + t.Fatalf("mixed delete pts_count = %d, want original deleted id count 2", mixedDelete.Event.PtsCount) + } + history, err := service.GetHistory(ctx, 1003, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 20}) + if err != nil { + t.Fatalf("new member GetHistory: %v", err) + } + for _, msg := range history.Messages { + if msg.Body == "before new member" { + t.Fatalf("new member history includes hidden prehistory message: %+v", history.Messages) + } + } + view, err := service.GetChannel(ctx, 1003, created.Channel.ID) + if err != nil { + t.Fatalf("new member GetChannel: %v", err) + } + diff, err := service.GetDifference(ctx, 1003, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 0, Limit: 100}) + if err != nil { + t.Fatalf("new member GetDifference: %v", err) + } + if diff.TooLong { + t.Fatalf("new member diff unexpectedly too long: %+v", diff) + } + if diff.Pts != view.Channel.Pts { + t.Fatalf("new member diff pts = %d, want current channel pts %d", diff.Pts, view.Channel.Pts) + } + for _, msg := range diff.NewMessages { + if msg.ID <= view.Self.AvailableMinID { + t.Fatalf("new member diff includes hidden prehistory message id %d <= available_min_id %d", msg.ID, view.Self.AvailableMinID) + } + } + for _, event := range diff.OtherUpdates { + for _, id := range event.MessageIDs { + if id <= view.Self.AvailableMinID { + t.Fatalf("new member diff includes hidden prehistory message id %d in event %+v", id, event) + } + } + } + foundPartialDelete := false + for _, event := range diff.OtherUpdates { + if event.Type != domain.ChannelUpdateDeleteMessages || event.Pts != mixedDelete.Event.Pts { + continue + } + foundPartialDelete = true + if event.PtsCount != mixedDelete.Event.PtsCount || len(event.MessageIDs) != 1 || event.MessageIDs[0] != visibleMsg.Message.ID { + t.Fatalf("visible mixed delete event = %+v, want pts_count=%d and only visible id %d", event, mixedDelete.Event.PtsCount, visibleMsg.Message.ID) + } + } + if !foundPartialDelete { + t.Fatalf("new member diff missing partial mixed delete event at pts %d: %+v", mixedDelete.Event.Pts, diff.OtherUpdates) + } + if _, err := service.SetSlowMode(ctx, 1002, created.Channel.ID, 30); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member SetSlowMode err = %v, want ErrChannelAdminRequired", err) + } + slow, err := service.SetSlowMode(ctx, 1001, created.Channel.ID, 30) + if err != nil { + t.Fatalf("SetSlowMode: %v", err) + } + if slow.SlowmodeSeconds != 30 { + t.Fatalf("slow mode = %+v, want 30 seconds", slow) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "first", Date: 100}); err != nil { + t.Fatalf("first member send: %v", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 3, Message: "too soon", Date: 110}); err == nil { + t.Fatalf("second member send err = nil, want slow mode wait") + } else if seconds, ok := domain.SlowModeWaitSeconds(err); !ok || seconds != 20 { + t.Fatalf("second member send err = %v, want slow mode wait 20", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 4, Message: "after wait", Date: 130}); err != nil { + t.Fatalf("third member send after slow mode: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 5, Message: "owner one", Date: 131}); err != nil { + t.Fatalf("owner send with slow mode: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 6, Message: "owner two", Date: 132}); err != nil { + t.Fatalf("owner second send with slow mode: %v", err) + } +} + +func TestImportInviteRespectsPreHistoryHidden(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Private Invite", + Megagroup: true, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if _, err := service.SetPreHistoryHidden(ctx, 1001, created.Channel.ID, true); err != nil { + t.Fatalf("SetPreHistoryHidden: %v", err) + } + hiddenMsg, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 501, + Message: "hidden before invite link", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage hidden: %v", err) + } + invite, err := service.ExportInvite(ctx, 1001, domain.ExportChannelInviteRequest{ + ChannelID: created.Channel.ID, + Title: "join", + Date: 12, + }) + if err != nil { + t.Fatalf("ExportInvite: %v", err) + } + joined, err := service.ImportInvite(ctx, 1002, domain.ImportChannelInviteRequest{ + Hash: invite.Invite.Hash, + Date: 13, + }) + if err != nil { + t.Fatalf("ImportInvite: %v", err) + } + if joined.Members[0].AvailableMinID != hiddenMsg.Message.ID || joined.Members[0].ReadInboxMaxID != joined.Message.ID { + t.Fatalf("imported member watermarks = %+v, want hidden top %d and read at join service %d", joined.Members[0], hiddenMsg.Message.ID, joined.Message.ID) + } + diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ + ChannelID: created.Channel.ID, + Pts: 0, + Limit: 100, + }) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + for _, msg := range diff.NewMessages { + if msg.ID <= hiddenMsg.Message.ID { + t.Fatalf("diff includes hidden message id %d <= available_min_id %d", msg.ID, hiddenMsg.Message.ID) + } + } +} + +func TestImportInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Import Watermark", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + first, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "before import", + Date: 11, + }) + if err != nil { + t.Fatalf("send existing message: %v", err) + } + invite, err := service.ExportInvite(ctx, 1001, domain.ExportChannelInviteRequest{ + ChannelID: created.Channel.ID, + Title: "join", + Date: 12, + }) + if err != nil { + t.Fatalf("ExportInvite: %v", err) + } + joined, err := service.ImportInvite(ctx, 1002, domain.ImportChannelInviteRequest{ + Hash: invite.Invite.Hash, + Date: 13, + }) + if err != nil { + t.Fatalf("ImportInvite: %v", err) + } + if joined.Members[0].ReadInboxMaxID != joined.Message.ID || joined.Members[0].ReadOutboxMaxID != joined.Message.ID { + t.Fatalf("joined member read watermarks = %+v message=%+v, want self join service read/outbox", joined.Members[0], joined.Message) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel imported: %v", err) + } + if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { + t.Fatalf("imported dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) + } + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 14, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after import = %+v, want none from initial watermark", readers.Participants) + } + future, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "after import", + Date: 14, + }) + if err != nil { + t.Fatalf("send future message: %v", err) + } + after, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel after future: %v", err) + } + if after.Dialog.TopMessageID != future.Message.ID || after.Dialog.UnreadCount != 1 { + t.Fatalf("imported dialog after future = %+v, want top %d unread 1", after.Dialog, future.Message.ID) + } +} + +func TestImportInviteRequestNeededAndUsageLimitErrors(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Invite Errors", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + requested, err := service.ExportInvite(ctx, 1001, domain.ExportChannelInviteRequest{ + ChannelID: created.Channel.ID, + Title: "approval", + RequestNeeded: true, + Date: 11, + }) + if err != nil { + t.Fatalf("ExportInvite request needed: %v", err) + } + if _, err := service.ImportInvite(ctx, 1002, domain.ImportChannelInviteRequest{Hash: requested.Invite.Hash, Date: 12}); !errors.Is(err, domain.ErrInviteRequestSent) { + t.Fatalf("ImportInvite request-needed err = %v, want ErrInviteRequestSent", err) + } + limited, err := service.ExportInvite(ctx, 1001, domain.ExportChannelInviteRequest{ + ChannelID: created.Channel.ID, + Title: "one", + UsageLimit: 1, + Date: 13, + }) + if err != nil { + t.Fatalf("ExportInvite limited: %v", err) + } + if _, err := service.ImportInvite(ctx, 1002, domain.ImportChannelInviteRequest{Hash: limited.Invite.Hash, Date: 14}); err != nil { + t.Fatalf("ImportInvite first limited: %v", err) + } + if _, err := service.ImportInvite(ctx, 1003, domain.ImportChannelInviteRequest{Hash: limited.Invite.Hash, Date: 15}); !errors.Is(err, domain.ErrUsersTooMuch) { + t.Fatalf("ImportInvite usage-limit err = %v, want ErrUsersTooMuch", err) + } +} + +func TestInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Invite Watermark", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + first, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "already there", + Date: 11, + }) + if err != nil { + t.Fatalf("send existing message: %v", err) + } + if _, err := service.InviteToChannel(ctx, 1001, created.Channel.ID, []int64{1002}, 12); err != nil { + t.Fatalf("InviteToChannel: %v", err) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel invited: %v", err) + } + if view.Self.ReadInboxMaxID != first.Message.ID || view.Dialog.ReadInboxMaxID != first.Message.ID { + t.Fatalf("invited read watermark self/dialog = %d/%d, want existing top %d", view.Self.ReadInboxMaxID, view.Dialog.ReadInboxMaxID, first.Message.ID) + } + if view.Dialog.UnreadCount != 1 { + t.Fatalf("invited unread = %d, want only invite service message unread", view.Dialog.UnreadCount) + } + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 13, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after invite = %+v, want none from initial watermark", readers.Participants) + } +} + +func TestJoinChannelInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Join Watermark", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + first, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "before join", + Date: 11, + }) + if err != nil { + t.Fatalf("send existing message: %v", err) + } + joined, err := service.JoinChannel(ctx, 1002, created.Channel.ID, 12) + if err != nil { + t.Fatalf("JoinChannel: %v", err) + } + if joined.Members[0].ReadInboxMaxID != joined.Message.ID { + t.Fatalf("joined read watermark = %d, want self join service %d", joined.Members[0].ReadInboxMaxID, joined.Message.ID) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel joined: %v", err) + } + if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { + t.Fatalf("joined dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) + } + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 13, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after join = %+v, want none from initial watermark", readers.Participants) + } + future, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "after join", + Date: 13, + }) + if err != nil { + t.Fatalf("send future message: %v", err) + } + after, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel after future: %v", err) + } + if after.Dialog.TopMessageID != future.Message.ID || after.Dialog.UnreadCount != 1 { + t.Fatalf("joined dialog after future = %+v, want top %d unread 1", after.Dialog, future.Message.ID) + } +} diff --git a/internal/app/contacts/doc.go b/internal/app/contacts/doc.go new file mode 100644 index 00000000..56ef1c00 --- /dev/null +++ b/internal/app/contacts/doc.go @@ -0,0 +1,3 @@ +// Package contacts 是联系人应用服务:联系人、拉黑、搜索。 +// 第一阶段实现 PG-backed 空账号通讯录查询;联系人导入留后续业务迭代。 +package contacts diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go new file mode 100644 index 00000000..19b52967 --- /dev/null +++ b/internal/app/contacts/service.go @@ -0,0 +1,266 @@ +package contacts + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +var ( + ErrContactIDInvalid = errors.New("contact id invalid") + ErrContactNameEmpty = errors.New("contact name empty") +) + +const maxSearchLimit = 50 + +// Service 提供通讯录查询。 +type Service struct { + contacts store.ContactStore + users store.UserStore +} + +// NewService 创建 contacts 服务。 +func NewService(contacts store.ContactStore, users ...store.UserStore) *Service { + s := &Service{contacts: contacts} + if len(users) > 0 { + s.users = users[0] + } + return s +} + +// GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。 +func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) { + if s == nil || s.contacts == nil || userID == 0 { + return domain.ContactList{}, false, nil + } + list, err := s.contacts.ListByUser(ctx, userID) + if err != nil { + return domain.ContactList{}, false, err + } + if s.users != nil && len(list.Contacts) > 0 { + if err := s.attachCurrentLastSeen(ctx, &list); err != nil { + return domain.ContactList{}, false, err + } + } + if hash != 0 && hash == list.Hash { + return list, true, nil + } + return list, false, nil +} + +func (s *Service) attachCurrentLastSeen(ctx context.Context, list *domain.ContactList) error { + ids := make([]int64, 0, len(list.Contacts)) + seen := make(map[int64]struct{}, len(list.Contacts)) + for _, contact := range list.Contacts { + id := contact.User.ID + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return nil + } + users, err := s.users.ByIDs(ctx, ids) + if err != nil { + return err + } + current := make(map[int64]domain.User, len(users)) + for _, u := range users { + current[u.ID] = u + } + for i := range list.Contacts { + if u, ok := current[list.Contacts[i].User.ID]; ok { + list.Contacts[i].User.LastSeenAt = u.LastSeenAt + list.Contacts[i].User.Status = u.Status + } + } + return nil +} + +func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { + if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID { + return domain.Contact{}, ErrContactIDInvalid + } + if input.FirstName == "" && input.LastName == "" { + return domain.Contact{}, ErrContactNameEmpty + } + if s.users != nil { + target, found, err := s.users.ByID(ctx, input.ContactUserID) + if err != nil { + return domain.Contact{}, err + } + if !found { + return domain.Contact{}, ErrContactIDInvalid + } + if input.Phone == "" { + input.Phone = target.Phone + } + } + contact, err := s.contacts.Upsert(ctx, userID, input) + if err != nil { + return domain.Contact{}, err + } + return contact, nil +} + +func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) { + if s == nil || s.contacts == nil || s.users == nil || userID == 0 || len(inputs) == 0 { + return domain.ImportContactsResult{}, nil + } + out := domain.ImportContactsResult{ + Imported: make([]domain.ImportedContact, 0, len(inputs)), + Contacts: make([]domain.Contact, 0, len(inputs)), + } + normalized := make([]domain.ContactInput, 0, len(inputs)) + phones := make([]string, 0, len(inputs)) + seenPhones := make(map[string]struct{}, len(inputs)) + for _, input := range inputs { + phone := normalizePhone(input.Phone) + if phone == "" { + continue + } + input.Phone = phone + normalized = append(normalized, input) + if _, ok := seenPhones[phone]; ok { + continue + } + seenPhones[phone] = struct{}{} + phones = append(phones, phone) + } + if len(phones) == 0 { + return out, nil + } + targets, err := s.users.ByPhones(ctx, phones) + if err != nil { + return domain.ImportContactsResult{}, err + } + byPhone := make(map[string]domain.User, len(targets)) + for _, target := range targets { + if target.Phone != "" { + byPhone[target.Phone] = target + } + } + upsertsByTarget := make(map[int64]domain.ContactInput, len(targets)) + order := make([]int64, 0, len(targets)) + seenTargets := map[int64]struct{}{} + for _, input := range normalized { + target, found := byPhone[input.Phone] + if !found || target.ID == userID || target.ID == 0 { + continue + } + input.ContactUserID = target.ID + if input.FirstName == "" && input.LastName == "" { + input.FirstName = target.FirstName + input.LastName = target.LastName + } + if _, ok := seenTargets[target.ID]; !ok { + seenTargets[target.ID] = struct{}{} + order = append(order, target.ID) + } + out.Imported = append(out.Imported, domain.ImportedContact{UserID: target.ID, ClientID: input.ClientID}) + upsertsByTarget[target.ID] = input + } + if len(order) == 0 { + return out, nil + } + upserts := make([]domain.ContactInput, 0, len(order)) + for _, targetID := range order { + upserts = append(upserts, upsertsByTarget[targetID]) + } + contacts, err := s.contacts.UpsertMany(ctx, userID, upserts) + if err != nil { + return domain.ImportContactsResult{}, err + } + out.Contacts = append(out.Contacts, contacts...) + return out, nil +} + +func (s *Service) Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) { + if s == nil || s.users == nil || userID == 0 { + return domain.UserSearchResult{}, nil + } + query = strings.TrimSpace(query) + query = strings.TrimPrefix(query, "@") + query = strings.TrimSpace(query) + if query == "" { + return domain.UserSearchResult{}, nil + } + if limit <= 0 || limit > maxSearchLimit { + limit = maxSearchLimit + } + return s.users.Search(ctx, userID, query, normalizePhone(query), limit) +} + +func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) { + if s == nil || s.contacts == nil || userID == 0 { + return 0, nil + } + return s.contacts.Delete(ctx, userID, contactUserIDs) +} + +func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) { + if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID { + return domain.Contact{}, ErrContactIDInvalid + } + contact, found, err := s.contacts.UpdateNote(ctx, userID, contactUserID, note, entities) + if err != nil { + return domain.Contact{}, err + } + if !found { + return domain.Contact{}, ErrContactIDInvalid + } + return contact, nil +} + +func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) { + if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID { + return domain.PeerSettings{}, nil + } + _, found, err := s.contacts.Get(ctx, userID, peer.ID) + if err != nil { + return domain.PeerSettings{}, err + } + return domain.PeerSettings{ + AddContact: !found, + BlockContact: !found, + ShareContact: found, + }, nil +} + +func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) { + list, notModified, err := s.GetContacts(ctx, userID, hash) + if err != nil || notModified { + return nil, notModified, err + } + ids := make([]int, 0, len(list.Contacts)) + for _, contact := range list.Contacts { + ids = append(ids, int(contact.User.ID)) + } + return ids, false, nil +} + +func normalizePhone(phone string) string { + if !utf8.ValidString(phone) { + return "" + } + var b strings.Builder + b.Grow(len(phone)) + for _, r := range phone { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return phone + } + return b.String() +} diff --git a/internal/app/contacts/service_test.go b/internal/app/contacts/service_test.go new file mode 100644 index 00000000..dcca953b --- /dev/null +++ b/internal/app/contacts/service_test.go @@ -0,0 +1,44 @@ +package contacts + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + svc := NewService(contactsStore, users) + + res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{ + {ClientID: 11, Phone: "+1 (555) 123-4567", FirstName: "A"}, + {ClientID: 12, Phone: "15551234567", FirstName: "Alice Final"}, + }) + if err != nil { + t.Fatalf("ImportContacts: %v", err) + } + if len(res.Imported) != 2 { + t.Fatalf("imported = %d, want 2", len(res.Imported)) + } + if res.Imported[0].UserID != target.ID || res.Imported[1].UserID != target.ID { + t.Fatalf("imported user ids = %+v, want target %d", res.Imported, target.ID) + } + if len(res.Contacts) != 1 { + t.Fatalf("contacts = %d, want 1 deduped upsert", len(res.Contacts)) + } + if res.Contacts[0].FirstName != "Alice Final" { + t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName) + } +} diff --git a/internal/app/dialogs/doc.go b/internal/app/dialogs/doc.go new file mode 100644 index 00000000..dbcae9e3 --- /dev/null +++ b/internal/app/dialogs/doc.go @@ -0,0 +1,3 @@ +// Package dialogs 是会话应用服务:会话列表、未读数、置顶、草稿。 +// 第一阶段实现 PG-backed 空账号会话摘要查询;真实消息闭环留第二阶段。 +package dialogs diff --git a/internal/app/dialogs/service.go b/internal/app/dialogs/service.go new file mode 100644 index 00000000..2c4a4727 --- /dev/null +++ b/internal/app/dialogs/service.go @@ -0,0 +1,593 @@ +package dialogs + +import ( + "context" + "encoding/binary" + "errors" + "hash/fnv" + "sort" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供会话列表查询。 +type Service struct { + dialogs store.DialogStore + channels store.ChannelStore +} + +// NewService 创建 dialogs 服务。 +func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service { + s := &Service{dialogs: dialogs} + if len(channels) > 0 { + s.channels = channels[0] + } + return s +} + +// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。 +func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + if s == nil || userID == 0 { + return domain.DialogList{}, nil + } + if filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID && filter.Folder == nil { + if s.dialogs == nil { + return domain.DialogList{}, nil + } + folder, found, err := s.dialogs.GetFolder(ctx, userID, filter.FolderID) + if err != nil { + return domain.DialogList{}, err + } + if !found { + return domain.DialogList{}, nil + } + filter.Folder = &folder + } + var out domain.DialogList + if s.dialogs != nil { + list, err := s.dialogs.ListByUser(ctx, userID, filter) + if err != nil { + return domain.DialogList{}, err + } + out = mergeDialogLists(out, list) + } + if s.channels != nil { + list, err := s.channels.ListChannelDialogs(ctx, userID, filter) + if err != nil { + return domain.DialogList{}, err + } + out = mergeChannelDialogs(out, list) + } + sortDialogList(out.Dialogs) + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + if len(out.Dialogs) > limit { + keep := make(map[domain.Peer]struct{}, limit) + for _, d := range out.Dialogs[:limit] { + keep[d.Peer] = struct{}{} + } + out.Dialogs = out.Dialogs[:limit] + out.Messages = filterPrivateMessagesByPeer(out.Messages, keep) + out.ChannelMessages = filterChannelMessagesByPeer(out.ChannelMessages, keep) + out.Channels = filterChannelsByPeer(out.Channels, keep) + } + if out.Count == 0 { + out.Count = len(out.Dialogs) + } + if err := s.attachDrafts(ctx, userID, &out); err != nil { + return domain.DialogList{}, err + } + return out, nil +} + +// GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。 +func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + if s == nil || userID == 0 || len(peers) == 0 { + return domain.DialogList{}, nil + } + if len(peers) > domain.MaxDialogFolderPeers { + return domain.DialogList{}, domain.ErrChannelInvalid + } + userPeers := make([]domain.Peer, 0, len(peers)) + channelIDs := make([]int64, 0, len(peers)) + for _, peer := range peers { + switch peer.Type { + case domain.PeerTypeUser: + userPeers = append(userPeers, peer) + case domain.PeerTypeChannel: + channelIDs = append(channelIDs, peer.ID) + } + } + var out domain.DialogList + if len(userPeers) > 0 && s.dialogs != nil { + list, err := s.dialogs.ListByPeers(ctx, userID, userPeers) + if err != nil { + return domain.DialogList{}, err + } + out = mergeDialogLists(out, list) + } + if len(channelIDs) > 0 && s.channels != nil { + list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs) + if err != nil { + return domain.DialogList{}, err + } + out = mergeChannelDialogs(out, list) + out, err = s.appendMissingChannelPeerPreviews(ctx, userID, channelIDs, out) + if err != nil { + return domain.DialogList{}, err + } + } + if err := s.attachDrafts(ctx, userID, &out); err != nil { + return domain.DialogList{}, err + } + return out, nil +} + +func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID int64, channelIDs []int64, out domain.DialogList) (domain.DialogList, error) { + if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 { + return out, nil + } + present := make(map[int64]struct{}, len(out.Dialogs)) + for _, dialog := range out.Dialogs { + if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 { + present[dialog.Peer.ID] = struct{}{} + } + } + seen := make(map[int64]struct{}, len(channelIDs)) + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + if _, ok := seen[channelID]; ok { + continue + } + seen[channelID] = struct{}{} + if _, ok := present[channelID]; ok { + continue + } + + view, err := s.channels.GetChannel(ctx, userID, channelID) + if err != nil { + if isChannelPreviewAccessError(err) { + continue + } + return domain.DialogList{}, err + } + history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{ + ChannelID: channelID, + Limit: 1, + }) + if err != nil { + if isChannelPreviewAccessError(err) { + continue + } + return domain.DialogList{}, err + } + + dialog := dialogFromChannelView(view) + if len(history.Messages) > 0 { + top := history.Messages[0] + dialog.TopMessage = top.ID + dialog.TopMessageDate = top.Date + out.ChannelMessages = append(out.ChannelMessages, top) + } + out.Dialogs = append(out.Dialogs, dialog) + out.Channels = append(out.Channels, view.Channel) + out.Channels = append(out.Channels, history.Channels...) + out.Users = append(out.Users, history.Users...) + out.Count++ + present[channelID] = struct{}{} + } + return out, nil +} + +func isChannelPreviewAccessError(err error) bool { + return errors.Is(err, domain.ErrChannelPrivate) || + errors.Is(err, domain.ErrChannelUserBanned) || + errors.Is(err, domain.ErrChannelInvalid) +} + +func dialogFromChannelView(view domain.ChannelView) domain.Dialog { + dialog := view.Dialog + return domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, + ChannelLeft: view.Self.Status == domain.ChannelMemberLeft, + FolderID: dialog.FolderID, + TopMessage: dialog.TopMessageID, + TopMessageDate: dialog.TopMessageDate, + ReadInboxMaxID: dialog.ReadInboxMaxID, + ReadOutboxMaxID: dialog.ReadOutboxMaxID, + UnreadCount: dialog.UnreadCount, + UnreadMentions: dialog.UnreadMentions, + Pinned: dialog.Pinned, + PinnedOrder: dialog.PinnedOrder, + UnreadMark: dialog.UnreadMark, + ViewForumAsMessages: dialog.ViewForumAsMessages, + } +} + +// SaveDraft stores or clears a cloud draft for one peer/topic. +func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error { + if s == nil || s.dialogs == nil || userID == 0 { + return nil + } + if err := validateDraft(draft); err != nil { + return err + } + if draft.Empty() { + _, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID) + return err + } + return s.dialogs.SaveDraft(ctx, userID, draft) +} + +// DeleteDraft clears one cloud draft. +func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return false, nil + } + if err := validateDraftKey(peer, topMessageID); err != nil { + return false, err + } + return s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID) +} + +// ListDrafts returns bounded cloud drafts for messages.getAllDrafts. +func (s *Service) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return nil, nil + } + return s.dialogs.ListDrafts(ctx, userID, clampDraftLimit(limit)) +} + +// ClearDrafts deletes bounded cloud drafts for messages.clearAllDrafts. +func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return nil, nil + } + return s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit)) +} + +func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { + if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + switch peer.Type { + case domain.PeerTypeChannel: + if s.channels == nil { + return false, nil + } + return s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned) + default: + if s.dialogs == nil { + return false, nil + } + return s.dialogs.SetPinned(ctx, userID, peer, pinned) + } +} + +func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error { + if s == nil || userID == 0 { + return nil + } + if s.dialogs != nil { + if err := s.dialogs.ReorderPinned(ctx, userID, order, force); err != nil { + return err + } + } + if s.channels != nil { + if err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, order, force); err != nil { + return err + } + } + return nil +} + +func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { + if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + switch peer.Type { + case domain.PeerTypeChannel: + if s.channels == nil { + return false, nil + } + return s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread) + default: + if s.dialogs == nil { + return false, nil + } + return s.dialogs.SetUnreadMark(ctx, userID, peer, unread) + } +} + +func (s *Service) UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error) { + if s == nil || userID == 0 { + return nil, nil + } + var out []domain.Peer + if s.dialogs != nil { + peers, err := s.dialogs.ListUnreadMarked(ctx, userID) + if err != nil { + return nil, err + } + out = append(out, peers...) + } + if s.channels != nil { + peers, err := s.channels.ListChannelUnreadMarked(ctx, userID) + if err != nil { + return nil, err + } + out = append(out, peers...) + } + return out, nil +} + +func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { + if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + return s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer) +} + +func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { + if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + return s.dialogs.PeerSettingsBarHidden(ctx, userID, peer) +} + +func (s *Service) GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return domain.DialogFolderList{}, nil + } + return s.dialogs.ListFolders(ctx, userID) +} + +func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error { + if s == nil || s.dialogs == nil || userID == 0 { + return nil + } + return s.dialogs.UpsertFolder(ctx, userID, folder) +} + +func (s *Service) DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error { + if s == nil || s.dialogs == nil || userID == 0 { + return nil + } + return s.dialogs.DeleteFolder(ctx, userID, folderID) +} + +func (s *Service) ReorderDialogFolders(ctx context.Context, userID int64, order []int) error { + if s == nil || s.dialogs == nil || userID == 0 { + return nil + } + return s.dialogs.ReorderFolders(ctx, userID, order) +} + +func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error { + if s == nil || s.dialogs == nil || userID == 0 { + return nil + } + return s.dialogs.SetFolderTagsEnabled(ctx, userID, enabled) +} + +func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + if s == nil || userID == 0 || len(peers) == 0 { + return nil + } + privatePeers := make([]domain.FolderPeerUpdate, 0, len(peers)) + channelPeers := make([]domain.FolderPeerUpdate, 0, len(peers)) + for _, peer := range peers { + if peer.Peer.Type == domain.PeerTypeChannel { + channelPeers = append(channelPeers, peer) + } else { + privatePeers = append(privatePeers, peer) + } + } + if len(privatePeers) > 0 && s.dialogs != nil { + if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil { + return err + } + } + if len(channelPeers) > 0 && s.channels != nil { + if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil { + return err + } + } + return nil +} + +func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.DialogList) error { + if s == nil || s.dialogs == nil || userID == 0 || list == nil || len(list.Dialogs) == 0 { + return nil + } + drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser) + if err != nil { + return err + } + if len(drafts) == 0 { + return nil + } + byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts)) + for _, draft := range drafts { + if draft.TopMessageID != 0 { + continue + } + byPeer[draft.Peer] = cloneDraft(draft) + } + if len(byPeer) == 0 { + return nil + } + attached := false + for i := range list.Dialogs { + draft, ok := byPeer[list.Dialogs[i].Peer] + if !ok { + continue + } + d := cloneDraft(draft) + list.Dialogs[i].Draft = &d + attached = true + } + if attached { + list.Hash = dialogHashWithDrafts(list.Hash, list.Dialogs) + } + return nil +} + +func validateDraft(draft domain.DialogDraft) error { + if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil { + return err + } + if len(draft.Entities) > domain.MaxMessageEntityCount { + return domain.ErrChannelInvalid + } + if utf8.RuneCountInString(draft.Message) > domain.MaxMessageTextLength { + return domain.ErrChannelInvalid + } + if domain.ValidateMessageReplyBounds(draft.ReplyTo) != nil { + return domain.ErrReplyMessageIDInvalid + } + return nil +} + +func validateDraftKey(peer domain.Peer, topMessageID int) error { + if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return domain.ErrChannelInvalid + } + if topMessageID < 0 || topMessageID > domain.MaxMessageBoxID { + return domain.ErrReplyMessageIDInvalid + } + return nil +} + +func clampDraftLimit(limit int) int { + if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { + return domain.MaxDialogDraftsPerUser + } + return limit +} + +func cloneDraft(draft domain.DialogDraft) domain.DialogDraft { + draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...) + if draft.ReplyTo != nil { + reply := *draft.ReplyTo + reply.QuoteEntities = append([]domain.MessageEntity(nil), draft.ReplyTo.QuoteEntities...) + draft.ReplyTo = &reply + } + if draft.WebPage != nil { + webpage := *draft.WebPage + draft.WebPage = &webpage + } + return draft +} + +func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 { + if len(dialogs) == 0 { + return base + } + h := fnv.New64a() + var buf [48]byte + binary.LittleEndian.PutUint64(buf[:8], uint64(base)) + _, _ = h.Write(buf[:8]) + for _, d := range dialogs { + if d.Draft == nil { + continue + } + binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(d.Draft.TopMessageID)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(d.Draft.Date)) + binary.LittleEndian.PutUint64(buf[16:24], uint64(len(d.Draft.Message))) + if d.Draft.NoWebpage { + buf[24] = 1 + } else { + buf[24] = 0 + } + if d.Draft.InvertMedia { + buf[25] = 1 + } else { + buf[25] = 0 + } + binary.LittleEndian.PutUint64(buf[26:34], uint64(len(d.Draft.Entities))) + binary.LittleEndian.PutUint64(buf[34:42], uint64(d.Draft.Effect)) + _, _ = h.Write(buf[:]) + _, _ = h.Write([]byte(d.Draft.Message)) + if d.Draft.WebPage != nil { + _, _ = h.Write([]byte(d.Draft.WebPage.URL)) + } + } + return int64(h.Sum64()) +} + +func mergeDialogLists(out, in domain.DialogList) domain.DialogList { + out.Dialogs = append(out.Dialogs, in.Dialogs...) + out.Messages = append(out.Messages, in.Messages...) + out.Users = append(out.Users, in.Users...) + out.Count += in.Count + out.Hash ^= in.Hash + return out +} + +func mergeChannelDialogs(out domain.DialogList, in domain.ChannelDialogList) domain.DialogList { + out.Dialogs = append(out.Dialogs, in.Dialogs...) + out.ChannelMessages = append(out.ChannelMessages, in.Messages...) + out.Channels = append(out.Channels, in.Channels...) + out.Users = append(out.Users, in.Users...) + out.Count += in.Count + out.Hash ^= in.Hash + return out +} + +func sortDialogList(dialogs []domain.Dialog) { + sort.SliceStable(dialogs, func(i, j int) bool { + if dialogs[i].Pinned != dialogs[j].Pinned { + return dialogs[i].Pinned + } + if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder { + return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder + } + if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate { + return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate + } + if dialogs[i].TopMessage != dialogs[j].TopMessage { + return dialogs[i].TopMessage > dialogs[j].TopMessage + } + return dialogs[i].Peer.ID > dialogs[j].Peer.ID + }) +} + +func filterPrivateMessagesByPeer(messages []domain.Message, keep map[domain.Peer]struct{}) []domain.Message { + out := messages[:0] + for _, msg := range messages { + if _, ok := keep[msg.Peer]; ok { + out = append(out, msg) + } + } + return out +} + +func filterChannelMessagesByPeer(messages []domain.ChannelMessage, keep map[domain.Peer]struct{}) []domain.ChannelMessage { + out := messages[:0] + for _, msg := range messages { + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID} + if _, ok := keep[peer]; ok { + out = append(out, msg) + } + } + return out +} + +func filterChannelsByPeer(channels []domain.Channel, keep map[domain.Peer]struct{}) []domain.Channel { + out := channels[:0] + for _, ch := range channels { + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID} + if _, ok := keep[peer]; ok { + out = append(out, ch) + } + } + return out +} diff --git a/internal/app/dialogs/service_test.go b/internal/app/dialogs/service_test.go new file mode 100644 index 00000000..838cc0a9 --- /dev/null +++ b/internal/app/dialogs/service_test.go @@ -0,0 +1,282 @@ +package dialogs + +import ( + "context" + "errors" + "testing" + + appchannels "telesrv/internal/app/channels" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + created, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Offline Read", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 42, + Message: "restore read outbox", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if _, err := channels.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{ + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 12, + }); err != nil { + t.Fatalf("ReadHistory: %v", err) + } + + list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs: %v", err) + } + dialog := findChannelDialog(t, list, created.Channel.ID) + if dialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("getDialogs read_outbox = %d, want %d", dialog.ReadOutboxMaxID, sent.Message.ID) + } + + peerList, err := dialogs.GetPeerDialogs(ctx, 1001, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: created.Channel.ID}, + }) + if err != nil { + t.Fatalf("GetPeerDialogs: %v", err) + } + peerDialog := findChannelDialog(t, peerList, created.Channel.ID) + if peerDialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("getPeerDialogs read_outbox = %d, want %d", peerDialog.ReadOutboxMaxID, sent.Message.ID) + } +} + +func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + first, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Pinned One", + Date: 20, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat first: %v", err) + } + second, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Pinned Two", + Date: 21, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat second: %v", err) + } + firstPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID} + secondPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID} + + if changed, err := dialogs.TogglePinned(ctx, 1001, firstPeer, true); err != nil || !changed { + t.Fatalf("TogglePinned first = changed %v err %v, want changed", changed, err) + } + if changed, err := dialogs.TogglePinned(ctx, 1001, secondPeer, true); err != nil || !changed { + t.Fatalf("TogglePinned second = changed %v err %v, want changed", changed, err) + } + if err := dialogs.ReorderPinned(ctx, 1001, []domain.Peer{secondPeer, firstPeer}, true); err != nil { + t.Fatalf("ReorderPinned: %v", err) + } + if changed, err := dialogs.MarkUnread(ctx, 1001, firstPeer, true); err != nil || !changed { + t.Fatalf("MarkUnread = changed %v err %v, want changed", changed, err) + } + if err := dialogs.EditPeerFolders(ctx, 1001, []domain.FolderPeerUpdate{ + {Peer: firstPeer, FolderID: domain.DialogArchiveFolderID}, + }); err != nil { + t.Fatalf("EditPeerFolders: %v", err) + } + + list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs: %v", err) + } + firstDialog := findChannelDialog(t, list, first.Channel.ID) + if !firstDialog.Pinned || firstDialog.PinnedOrder != 1 || !firstDialog.UnreadMark || firstDialog.FolderID != domain.DialogArchiveFolderID { + t.Fatalf("first dialog = %+v, want pinned order 1, unread mark, archived", firstDialog) + } + secondDialog := findChannelDialog(t, list, second.Channel.ID) + if !secondDialog.Pinned || secondDialog.PinnedOrder != 2 { + t.Fatalf("second dialog = %+v, want pinned order 2", secondDialog) + } + marks, err := dialogs.UnreadMarks(ctx, 1001) + if err != nil { + t.Fatalf("UnreadMarks: %v", err) + } + if len(marks) != 1 || marks[0] != firstPeer { + t.Fatalf("unread marks = %+v, want first channel", marks) + } + archived, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ + HasFolderID: true, + FolderID: domain.DialogArchiveFolderID, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetDialogs archive: %v", err) + } + if got := findChannelDialog(t, archived, first.Channel.ID); got.FolderID != domain.DialogArchiveFolderID { + t.Fatalf("archived dialog = %+v, want archive folder", got) + } + + custom, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ + HasFolderID: true, + FolderID: domain.DialogCustomFolderMinID, + Folder: &domain.DialogFolder{ID: domain.DialogCustomFolderMinID, Groups: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetDialogs custom groups: %v", err) + } + if got := findChannelDialog(t, custom, first.Channel.ID); got.Peer.ID != first.Channel.ID { + t.Fatalf("custom group dialog = %+v, want first channel", got) + } +} + +func TestGetDialogsAppliesChannelDialogOffset(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + old, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Older Channel", + Date: 20, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat old: %v", err) + } + newer, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Newer Channel", + Date: 30, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat newer: %v", err) + } + + first, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 1}) + if err != nil { + t.Fatalf("GetDialogs first: %v", err) + } + if len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != newer.Channel.ID { + t.Fatalf("first page dialogs = %+v, want newer channel", first.Dialogs) + } + + next, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ + OffsetDate: first.Dialogs[0].TopMessageDate, + OffsetID: first.Dialogs[0].TopMessage, + HasOffsetPeer: true, + OffsetPeer: first.Dialogs[0].Peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetDialogs next: %v", err) + } + if len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != old.Channel.ID { + t.Fatalf("next page dialogs = %+v, want only older channel", next.Dialogs) + } +} + +func TestGetPeerDialogsRejectsHugeVector(t *testing.T) { + dialogs := NewService(nil, memory.NewChannelStore()) + peers := make([]domain.Peer, domain.MaxDialogFolderPeers+1) + for i := range peers { + peers[i] = domain.Peer{Type: domain.PeerTypeChannel, ID: int64(i + 1)} + } + if _, err := dialogs.GetPeerDialogs(context.Background(), 1001, peers); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetPeerDialogs huge vector err = %v, want ErrChannelInvalid", err) + } +} + +func TestGetPeerDialogsIncludesPublicChannelPreviewForNonMember(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + public, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Public Peer Dialog", + Broadcast: true, + Date: 1700002000, + }) + if err != nil { + t.Fatalf("CreateChannel public: %v", err) + } + if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + UserID: 1001, + ChannelID: public.Channel.ID, + Username: "public_peer_dialog", + }); err != nil { + t.Fatalf("UpdateUsername public: %v", err) + } + sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: public.Channel.ID, + RandomID: 99, + Message: "public peer dialog top", + Date: 1700002010, + }) + if err != nil { + t.Fatalf("SendMessage public: %v", err) + } + private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Private Peer Dialog", + Broadcast: true, + Date: 1700002020, + }) + if err != nil { + t.Fatalf("CreateChannel private: %v", err) + } + + list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: public.Channel.ID}, + {Type: domain.PeerTypeChannel, ID: private.Channel.ID}, + }) + if err != nil { + t.Fatalf("GetPeerDialogs public preview: %v", err) + } + if len(list.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want only public preview dialog", list.Dialogs) + } + dialog := findChannelDialog(t, list, public.Channel.ID) + if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date { + t.Fatalf("preview dialog top = id %d date %d, want %d/%d", dialog.TopMessage, dialog.TopMessageDate, sent.Message.ID, sent.Message.Date) + } + if !dialog.ChannelLeft { + t.Fatalf("preview dialog ChannelLeft = false, want read-only left preview") + } + if dialog.UnreadCount != 0 || dialog.ReadInboxMaxID < sent.Message.ID || dialog.ReadOutboxMaxID < sent.Message.ID { + t.Fatalf("preview dialog read/unread = %+v, want read through top and no unread", dialog) + } + if len(list.ChannelMessages) != 1 || list.ChannelMessages[0].Body != "public peer dialog top" { + t.Fatalf("channel messages = %+v, want public top message", list.ChannelMessages) + } + if len(list.Channels) != 1 || list.Channels[0].ID != public.Channel.ID { + t.Fatalf("channels = %+v, want public channel shell", list.Channels) + } +} + +func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) domain.Dialog { + t.Helper() + for _, dialog := range list.Dialogs { + if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID == channelID { + return dialog + } + } + t.Fatalf("channel dialog %d not found in %+v", channelID, list.Dialogs) + return domain.Dialog{} +} diff --git a/internal/app/files/blobcache.go b/internal/app/files/blobcache.go new file mode 100644 index 00000000..5d542282 --- /dev/null +++ b/internal/app/files/blobcache.go @@ -0,0 +1,236 @@ +package files + +import ( + "container/list" + "sync" + + "telesrv/internal/domain" +) + +// blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile +// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/ +// reaction/头像更被大量用户重复拉)。 +// +// FileBlob 元数据小(约百字节)且内容不可变:location_key 一旦写入即固定指向同一 object_key, +// 新建 blob 用随机 id 生成 location_key 不会与已缓存项冲突,故只读填充、无需失效。 +type blobMetaCache struct { + mu sync.Mutex + cap int + ll *list.List + m map[string]*list.Element +} + +type blobMetaEntry struct { + key string + blob domain.FileBlob +} + +func newBlobMetaCache(capacity int) *blobMetaCache { + if capacity <= 0 { + capacity = 1 + } + return &blobMetaCache{ + cap: capacity, + ll: list.New(), + m: make(map[string]*list.Element, capacity), + } +} + +// get 返回缓存的 FileBlob 并把其移到 LRU 头部;未命中返回 ok=false。 +func (c *blobMetaCache) get(key string) (domain.FileBlob, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + c.ll.MoveToFront(el) + return el.Value.(*blobMetaEntry).blob, true + } + return domain.FileBlob{}, false +} + +// put 写入/更新缓存,超出容量时淘汰最久未用项。 +func (c *blobMetaCache) put(key string, blob domain.FileBlob) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + el.Value.(*blobMetaEntry).blob = blob + c.ll.MoveToFront(el) + return + } + c.m[key] = c.ll.PushFront(&blobMetaEntry{key: key, blob: blob}) + if c.ll.Len() > c.cap { + if oldest := c.ll.Back(); oldest != nil { + c.ll.Remove(oldest) + delete(c.m, oldest.Value.(*blobMetaEntry).key) + } + } +} + +// blobBytesCache 是 object_key → 小 blob 全量字节的 LRU。Sticker / reaction / +// 缩略图通常只有几 KB 到几十 KB,缓存全量内容可以避开点击历史时的本地磁盘冷读抖动; +// 大媒体仍由 BlobBackend.GetRange 分段读取,避免把大文件放进内存。 +type blobBytesCache struct { + mu sync.Mutex + maxBytes int + used int + ll *list.List + m map[string]*list.Element +} + +type blobBytesEntry struct { + key string + bytes []byte + size int +} + +func newBlobBytesCache(maxBytes int) *blobBytesCache { + if maxBytes <= 0 { + maxBytes = 1 + } + return &blobBytesCache{ + maxBytes: maxBytes, + ll: list.New(), + m: make(map[string]*list.Element), + } +} + +func (c *blobBytesCache) get(key string) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + c.ll.MoveToFront(el) + entry := el.Value.(*blobBytesEntry) + return append([]byte(nil), entry.bytes...), true + } + return nil, false +} + +func (c *blobBytesCache) has(key string) bool { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + c.ll.MoveToFront(el) + return true + } + return false +} + +func (c *blobBytesCache) put(key string, bytes []byte) { + if len(bytes) > c.maxBytes { + return + } + copied := append([]byte(nil), bytes...) + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + entry := el.Value.(*blobBytesEntry) + c.used += len(copied) - entry.size + entry.bytes = copied + entry.size = len(copied) + c.ll.MoveToFront(el) + } else { + entry := &blobBytesEntry{key: key, bytes: copied, size: len(copied)} + c.m[key] = c.ll.PushFront(entry) + c.used += entry.size + } + for c.used > c.maxBytes { + oldest := c.ll.Back() + if oldest == nil { + break + } + c.ll.Remove(oldest) + entry := oldest.Value.(*blobBytesEntry) + delete(c.m, entry.key) + c.used -= entry.size + } +} + +type stickerSetFullCache struct { + mu sync.RWMutex + byID map[int64]stickerSetFullEntry + byShort map[string]int64 + bySystem map[string]int64 +} + +type stickerSetFullEntry struct { + set domain.StickerSet + docs []domain.Document +} + +func newStickerSetFullCache() *stickerSetFullCache { + return &stickerSetFullCache{ + byID: map[int64]stickerSetFullEntry{}, + byShort: map[string]int64{}, + bySystem: map[string]int64{}, + } +} + +func (c *stickerSetFullCache) get(ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + var id int64 + switch ref.Kind { + case domain.StickerSetRefByID: + id = ref.ID + case domain.StickerSetRefByShortName: + id = c.byShort[ref.ShortName] + case domain.StickerSetRefBySystem: + id = c.bySystem[ref.SystemKey] + default: + return domain.StickerSet{}, nil, false + } + entry, ok := c.byID[id] + if !ok { + return domain.StickerSet{}, nil, false + } + return copyStickerSet(entry.set), copyDocuments(entry.docs), true +} + +func (c *stickerSetFullCache) put(set domain.StickerSet, docs []domain.Document) { + if set.ID == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.byID[set.ID] = stickerSetFullEntry{ + set: copyStickerSet(set), + docs: copyDocuments(docs), + } + if set.ShortName != "" { + c.byShort[set.ShortName] = set.ID + } + if set.SystemKey != "" { + c.bySystem[set.SystemKey] = set.ID + } +} + +func copyStickerSet(set domain.StickerSet) domain.StickerSet { + set.DocumentIDs = append([]int64(nil), set.DocumentIDs...) + set.Packs = append([]domain.StickerPack(nil), set.Packs...) + for i := range set.Packs { + set.Packs[i].DocumentIDs = append([]int64(nil), set.Packs[i].DocumentIDs...) + } + set.Thumbs = copyPhotoSizes(set.Thumbs) + return set +} + +func copyDocuments(docs []domain.Document) []domain.Document { + out := append([]domain.Document(nil), docs...) + for i := range out { + out[i].FileReference = append([]byte(nil), out[i].FileReference...) + out[i].Attributes = append([]domain.DocumentAttribute(nil), out[i].Attributes...) + for j := range out[i].Attributes { + out[i].Attributes[j].Waveform = append([]byte(nil), out[i].Attributes[j].Waveform...) + } + out[i].Thumbs = copyPhotoSizes(out[i].Thumbs) + } + return out +} + +func copyPhotoSizes(sizes []domain.PhotoSize) []domain.PhotoSize { + out := append([]domain.PhotoSize(nil), sizes...) + for i := range out { + out[i].Bytes = append([]byte(nil), out[i].Bytes...) + out[i].Sizes = append([]int(nil), out[i].Sizes...) + } + return out +} diff --git a/internal/app/files/blobcache_test.go b/internal/app/files/blobcache_test.go new file mode 100644 index 00000000..7cc9ca5a --- /dev/null +++ b/internal/app/files/blobcache_test.go @@ -0,0 +1,233 @@ +package files + +import ( + "bytes" + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestBlobMetaCacheGetPutEvict(t *testing.T) { + c := newBlobMetaCache(2) + c.put("a", domain.FileBlob{LocationKey: "a", ObjectKey: "oa"}) + c.put("b", domain.FileBlob{LocationKey: "b", ObjectKey: "ob"}) + if b, ok := c.get("a"); !ok || b.ObjectKey != "oa" { + t.Fatalf("get a = %+v ok=%v", b, ok) + } + // 容量 2:刚 access 过 a,再 put c 应淘汰最久未用的 b。 + c.put("c", domain.FileBlob{LocationKey: "c", ObjectKey: "oc"}) + if _, ok := c.get("b"); ok { + t.Error("b should be evicted (least recently used)") + } + if _, ok := c.get("a"); !ok { + t.Error("a should remain (recently used)") + } + if _, ok := c.get("c"); !ok { + t.Error("c should be present") + } +} + +// countingMediaStore 统计 GetFileBlob 次数,验证元数据缓存命中后不再查 PG。 +type countingMediaStore struct { + *fakeMediaStore + getBlobCalls int + getSetByIDCalls int +} + +func (c *countingMediaStore) GetFileBlob(ctx context.Context, key string) (domain.FileBlob, bool, error) { + c.getBlobCalls++ + return c.fakeMediaStore.GetFileBlob(ctx, key) +} + +func (c *countingMediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) { + c.getSetByIDCalls++ + return c.fakeMediaStore.GetStickerSetByID(ctx, id) +} + +type countingBlobBackend struct { + BlobBackend + getRangeCalls int +} + +func (c *countingBlobBackend) GetRange(ctx context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) { + c.getRangeCalls++ + return c.BlobBackend.GetRange(ctx, objectKey, offset, limit) +} + +func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) { + ctx := context.Background() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + objectKey, err := local.Put(ctx, []byte("0123456789")) + if err != nil { + t.Fatalf("put: %v", err) + } + media := newFakeMediaStore() + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:42", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil { + t.Fatalf("put blob: %v", err) + } + counting := &countingMediaStore{fakeMediaStore: media} + blobs := &countingBlobBackend{BlobBackend: local} + svc := NewService(counting, blobs, 2) + + // 第一次:查 PG 一次并填充元数据缓存;小 blob 读整块进字节缓存后返回 [0,5)。 + c1, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:42", Offset: 0, Limit: 5}) + if err != nil || !ok { + t.Fatalf("getfile1 ok=%v err=%v", ok, err) + } + if string(c1.Bytes) != "01234" { + t.Errorf("chunk1 = %q, want 01234", c1.Bytes) + } + if c1.Total != 10 { + t.Errorf("total = %d, want 10", c1.Total) + } + if counting.getBlobCalls != 1 { + t.Errorf("getBlobCalls = %d, want 1", counting.getBlobCalls) + } + if blobs.getRangeCalls != 1 { + t.Errorf("getRangeCalls = %d, want 1", blobs.getRangeCalls) + } + + // 第二次:同 location 命中元数据与字节缓存;[5,10) 直接从内存切片。 + c2, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:42", Offset: 5, Limit: 5}) + if err != nil || !ok { + t.Fatalf("getfile2 ok=%v err=%v", ok, err) + } + if string(c2.Bytes) != "56789" { + t.Errorf("chunk2 = %q, want 56789", c2.Bytes) + } + if counting.getBlobCalls != 1 { + t.Errorf("getBlobCalls = %d, want 1 (cache hit)", counting.getBlobCalls) + } + if blobs.getRangeCalls != 1 { + t.Errorf("getRangeCalls = %d, want 1 (byte cache hit)", blobs.getRangeCalls) + } +} + +func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) { + ctx := context.Background() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + content := bytes.Repeat([]byte("x"), blobBytesCacheMaxEntryBytes+2) + objectKey, err := local.Put(ctx, content) + if err != nil { + t.Fatalf("put: %v", err) + } + media := newFakeMediaStore() + if err := media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: "doc:large", + ObjectKey: objectKey, + Size: int64(len(content)), + MimeType: "application/octet-stream", + }); err != nil { + t.Fatalf("put blob: %v", err) + } + blobs := &countingBlobBackend{BlobBackend: local} + svc := NewService(media, blobs, 2) + + for i := 0; i < 2; i++ { + chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:large", Offset: 1, Limit: 7}) + if err != nil || !ok { + t.Fatalf("getfile %d ok=%v err=%v", i, ok, err) + } + if string(chunk.Bytes) != "xxxxxxx" { + t.Fatalf("chunk %d = %q, want seven x bytes", i, chunk.Bytes) + } + } + if blobs.getRangeCalls != 2 { + t.Errorf("getRangeCalls = %d, want 2 (large blob is not byte cached)", blobs.getRangeCalls) + } +} + +func TestWarmCachesPreloadsStickerSetAndSmallBlobs(t *testing.T) { + ctx := context.Background() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + mainKey, err := local.Put(ctx, []byte("sticker")) + if err != nil { + t.Fatalf("put main: %v", err) + } + thumbKey, err := local.Put(ctx, []byte("thumb")) + if err != nil { + t.Fatalf("put thumb: %v", err) + } + media := newFakeMediaStore() + doc := domain.Document{ + ID: 100, + AccessHash: 1, + DCID: 2, + MimeType: "application/x-tgsticker", + Size: 7, + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 5}, + }, + } + if err := media.PutDocument(ctx, doc); err != nil { + t.Fatalf("put doc: %v", err) + } + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100", ObjectKey: mainKey, Size: 7, MimeType: doc.MimeType}); err != nil { + t.Fatalf("put main blob: %v", err) + } + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", ObjectKey: thumbKey, Size: 5, MimeType: "image/jpeg"}); err != nil { + t.Fatalf("put thumb blob: %v", err) + } + set := domain.StickerSet{ + ID: 200, + AccessHash: 2, + ShortName: "pack", + Title: "Pack", + Kind: domain.StickerSetKindStickers, + Count: 1, + DocumentIDs: []int64{ + doc.ID, + }, + } + if err := media.PutStickerSet(ctx, set); err != nil { + t.Fatalf("put set: %v", err) + } + counting := &countingMediaStore{fakeMediaStore: media} + blobs := &countingBlobBackend{BlobBackend: local} + svc := NewService(counting, blobs, 2) + + stats, err := svc.WarmCaches(ctx) + if err != nil { + t.Fatalf("warm caches: %v", err) + } + if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 { + t.Fatalf("warm stats = %+v, want 1 set, 1 doc, 2 blobs", stats) + } + blobs.getRangeCalls = 0 + chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:100", Offset: 0, Limit: 7}) + if err != nil || !ok { + t.Fatalf("getfile ok=%v err=%v", ok, err) + } + if string(chunk.Bytes) != "sticker" { + t.Fatalf("chunk = %q, want sticker", chunk.Bytes) + } + if blobs.getRangeCalls != 0 { + t.Fatalf("prewarmed blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls) + } + + gotSet, docs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID}) + if err != nil || !found { + t.Fatalf("resolve found=%v err=%v", found, err) + } + if gotSet.ID != set.ID || len(docs) != 1 || docs[0].ID != doc.ID { + t.Fatalf("resolve = set %+v docs %+v", gotSet, docs) + } + if counting.getSetByIDCalls != 0 { + t.Fatalf("ResolveStickerSet should hit full-set cache, GetStickerSetByID calls = %d", counting.getSetByIDCalls) + } + docs[0].ID = 999 + _, docsAgain, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID}) + if err != nil || !found || docsAgain[0].ID != doc.ID { + t.Fatalf("cached docs were mutated: found=%v err=%v docs=%+v", found, err, docsAgain) + } +} diff --git a/internal/app/files/blobfs.go b/internal/app/files/blobfs.go new file mode 100644 index 00000000..dbb17f04 --- /dev/null +++ b/internal/app/files/blobfs.go @@ -0,0 +1,105 @@ +package files + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" +) + +// BlobBackend 是 blob 字节内容的存储后端。第一阶段只有本地磁盘实现。 +// 内容寻址:Put 返回的 objectKey 是内容 sha256,相同内容自动去重。 +type BlobBackend interface { + Name() string + Put(ctx context.Context, data []byte) (objectKey string, err error) + Get(ctx context.Context, objectKey string) ([]byte, error) + // GetRange 只读 [offset, offset+limit) 段并返回该段字节与文件总大小(limit<=0 读到末尾), + // 避免大文件每个 chunk 都整文件读入内存(getFile 按 chunk 多次请求 ⇒ 否则 O(N²) 放大)。 + GetRange(ctx context.Context, objectKey string, offset, limit int64) (data []byte, total int64, err error) +} + +// LocalFS 把 blob 字节存到本地磁盘根目录下,路径按内容 hash 两级 fanout。 +type LocalFS struct { + root string +} + +// NewLocalFS 创建本地磁盘 blob backend,确保根目录存在。 +func NewLocalFS(root string) (*LocalFS, error) { + if root == "" { + return nil, fmt.Errorf("blob root dir is empty") + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("create blob root %q: %w", root, err) + } + return &LocalFS{root: root}, nil +} + +// Name 返回后端标识,与 file_blobs.backend 一致。 +func (l *LocalFS) Name() string { return "localfs" } + +func (l *LocalFS) pathFor(objectKey string) string { + if len(objectKey) < 4 { + return filepath.Join(l.root, "_", objectKey) + } + return filepath.Join(l.root, objectKey[:2], objectKey[2:4], objectKey) +} + +// Put 写入内容并返回 sha256 hex 作为 objectKey;同内容已存在则跳过写入(去重)。 +func (l *LocalFS) Put(_ context.Context, data []byte) (string, error) { + sum := sha256.Sum256(data) + key := hex.EncodeToString(sum[:]) + path := l.pathFor(key) + if _, err := os.Stat(path); err == nil { + return key, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("create blob dir: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return "", fmt.Errorf("write blob: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return "", fmt.Errorf("commit blob: %w", err) + } + return key, nil +} + +// Get 读取 objectKey 对应的全部字节。 +func (l *LocalFS) Get(_ context.Context, objectKey string) ([]byte, error) { + return os.ReadFile(l.pathFor(objectKey)) +} + +// GetRange 用 ReadAt 只读 [offset, offset+limit) 段,total 取自文件大小; +// n 受 total 约束,故即便客户端传超大 limit 也只分配文件实际大小,不会按客户端巨值分配。 +func (l *LocalFS) GetRange(_ context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) { + f, err := os.Open(l.pathFor(objectKey)) + if err != nil { + return nil, 0, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, 0, err + } + total := info.Size() + if offset < 0 { + offset = 0 + } + if offset >= total { + return []byte{}, total, nil + } + n := total - offset + if limit > 0 && limit < n { + n = limit + } + buf := make([]byte, n) + read, err := f.ReadAt(buf, offset) + if err != nil && err != io.EOF { + return nil, 0, err + } + return buf[:read], total, nil +} diff --git a/internal/app/files/blobfs_test.go b/internal/app/files/blobfs_test.go new file mode 100644 index 00000000..b5a3ce69 --- /dev/null +++ b/internal/app/files/blobfs_test.go @@ -0,0 +1,92 @@ +package files + +import ( + "bytes" + "context" + "testing" +) + +func TestLocalFSPutGetRoundTrip(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + data := []byte("hello telesrv media blob 你好") + + key, err := fs.Put(ctx, data) + if err != nil { + t.Fatalf("put: %v", err) + } + if key == "" { + t.Fatal("empty object key") + } + + // 内容寻址:相同内容应得到相同 key(去重)。 + key2, err := fs.Put(ctx, data) + if err != nil { + t.Fatalf("put again: %v", err) + } + if key != key2 { + t.Fatalf("expected dedup key %q == %q", key, key2) + } + + got, err := fs.Get(ctx, key) + if err != nil { + t.Fatalf("get: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatalf("roundtrip mismatch: got %q want %q", got, data) + } +} + +func TestLocalFSDistinctContent(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + k1, _ := fs.Put(ctx, []byte("aaa")) + k2, _ := fs.Put(ctx, []byte("bbb")) + if k1 == k2 { + t.Fatal("distinct content must yield distinct keys") + } +} + +func TestLocalFSGetRange(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + key, err := fs.Put(ctx, []byte("0123456789")) + if err != nil { + t.Fatalf("put: %v", err) + } + cases := []struct { + name string + offset, limit int64 + want string + }{ + {"head", 0, 4, "0123"}, + {"middle", 3, 4, "3456"}, + {"limit-exceeds-remaining", 7, 100, "789"}, + {"zero-limit-reads-to-end", 2, 0, "23456789"}, + {"offset-at-end", 10, 5, ""}, + {"offset-past-end", 20, 5, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data, total, err := fs.GetRange(ctx, key, tc.offset, tc.limit) + if err != nil { + t.Fatalf("getrange: %v", err) + } + if string(data) != tc.want { + t.Errorf("data = %q, want %q", data, tc.want) + } + if total != 10 { + t.Errorf("total = %d, want 10", total) + } + }) + } +} diff --git a/internal/app/files/doc.go b/internal/app/files/doc.go new file mode 100644 index 00000000..3f8edd6f --- /dev/null +++ b/internal/app/files/doc.go @@ -0,0 +1,6 @@ +// Package files 是文件应用服务:upload 分片累积、blob 落盘、getFile 下载, +// 以及把上传文件组装成 Photo / Document(头像、图片/文件/贴纸消息)。 +// +// 类型边界:本包只用 domain / store 类型,不依赖 tg.*; +// rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 的转换。 +package files diff --git a/internal/app/files/photos.go b/internal/app/files/photos.go new file mode 100644 index 00000000..bd6e5b5b --- /dev/null +++ b/internal/app/files/photos.go @@ -0,0 +1,278 @@ +package files + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "image" + _ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸 + _ "image/png" // 注册 png DecodeConfig + "time" + + "telesrv/internal/domain" +) + +// 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。 +// 同一份上传字节在多个 location_key 下建 blob(不做实际缩放,dev 主路径足够)。 + +// UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。 +func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) { + data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) + if err != nil { + return domain.Photo{}, err + } + if len(data) == 0 { + return domain.Photo{}, domain.ErrPhotoInvalid + } + if date == 0 { + date = int(time.Now().Unix()) + } + photo, err := s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data)) + if err != nil { + return domain.Photo{}, err + } + if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photo.ID, date); err != nil { + return domain.Photo{}, err + } + return photo, nil +} + +// CreatePhotoFromUpload 把已上传文件组装成 Photo(不绑定 profile_photos),用于频道头像 / 图片消息。 +func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) { + data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) + if err != nil { + return domain.Photo{}, err + } + if len(data) == 0 { + return domain.Photo{}, domain.ErrPhotoInvalid + } + return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data)) +} + +// GetPhoto 按 id 返回已存储照片。 +func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) { + return s.media.GetPhoto(ctx, id) +} + +// GetDocument 按 id 返回已存储文档(贴纸 / 文件)。 +func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { + return s.media.GetDocument(ctx, id) +} + +// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('a'/'c' 尺寸,匹配 InputPeerPhotoFileLocation +// big/small 与 channelFull 合成尺寸的下载路径),不绑定 profile_photos。用于频道 editPhoto。 +func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) { + data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) + if err != nil { + return domain.Photo{}, err + } + if len(data) == 0 { + return domain.Photo{}, domain.ErrPhotoInvalid + } + return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data)) +} + +// CreateDocumentFromUpload 把已上传文件组装成 Document(文件/视频/音频/gif/贴纸消息),落 blob + documents。 +func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) { + data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) + if err != nil { + return domain.Document{}, err + } + if len(data) == 0 { + return domain.Document{}, domain.ErrDocumentInvalid + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Document{}, err + } + docID := randomID() + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d", docID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + MimeType: spec.MimeType, + }); err != nil { + return domain.Document{}, err + } + doc := domain.Document{ + ID: docID, + AccessHash: randomID(), + FileReference: randomFileReference(), + Date: int(time.Now().Unix()), + MimeType: spec.MimeType, + Size: int64(len(data)), + DCID: s.dc, + Attributes: spec.Attributes, + } + if spec.Thumb != nil { + thumbData, err := s.assembleUpload(ctx, spec.Thumb.OwnerUserID, spec.Thumb.FileID, spec.Thumb.Parts) + if err == nil && len(thumbData) > 0 { + thumbKey, err := s.blobs.Put(ctx, thumbData) + if err == nil { + w, h := imageDimensions(thumbData, 0, 0) + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d:m", docID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: thumbKey, + Size: int64(len(thumbData)), + MimeType: "image/jpeg", + }); err == nil { + doc.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}} + } + } + } + } + if err := s.media.PutDocument(ctx, doc); err != nil { + return domain.Document{}, err + } + return doc, nil +} + +// SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。 +func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) { + photo, ok, err := s.media.GetPhoto(ctx, photoID) + if err != nil || !ok { + return domain.Photo{}, ok, err + } + if date == 0 { + date = int(time.Now().Unix()) + } + if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photoID, date); err != nil { + return domain.Photo{}, false, err + } + return photo, true, nil +} + +// CurrentProfilePhoto 返回某 owner 的当前头像 Photo。 +func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) { + id, ok, err := s.media.CurrentProfilePhoto(ctx, ownerType, ownerID) + if err != nil || !ok { + return domain.Photo{}, ok, err + } + return s.media.GetPhoto(ctx, id) +} + +// GetProfilePhotos 返回 owner 的头像历史(最新在前)。 +func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) { + ids, total, err := s.media.ListProfilePhotos(ctx, ownerType, ownerID, offset, limit, maxID) + if err != nil { + return nil, 0, err + } + photos := make([]domain.Photo, 0, len(ids)) + for _, id := range ids { + if p, ok, err := s.media.GetPhoto(ctx, id); err != nil { + return nil, 0, err + } else if ok { + photos = append(photos, p) + } + } + return photos, total, nil +} + +// DeleteProfilePhotos 停用指定头像,返回成功停用数量。 +func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) { + deleted, err := s.media.DeleteProfilePhotos(ctx, ownerType, ownerID, photoIDs) + if err != nil { + return 0, err + } + return len(deleted), nil +} + +// createPhoto 把字节落 blob(每个尺寸一个 location_key,指向同一内容)并写 photos 表。 +func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSizeSpec) (domain.Photo, error) { + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Photo{}, err + } + photoID := randomID() + sizes := make([]domain.PhotoSize, 0, len(specs)) + for _, spec := range specs { + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + MimeType: "image/jpeg", + }); err != nil { + return domain.Photo{}, err + } + sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)}) + } + photo := domain.Photo{ + ID: photoID, + AccessHash: randomID(), + FileReference: randomFileReference(), + Date: int(time.Now().Unix()), + DCID: s.dc, + Sizes: sizes, + } + if err := s.media.PutPhoto(ctx, photo); err != nil { + return domain.Photo{}, err + } + return photo, nil +} + +type photoSizeSpec struct { + Type string + W int + H int +} + +func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec { + w, h := imageDimensions(data, 640, 640) + small := 160 + if w < small { + small = w + } + return []photoSizeSpec{ + {Type: "a", W: small, H: small}, + {Type: "c", W: w, H: h}, + } +} + +// photoSizeSpecsForMessage 给图片消息生成下载尺寸('m' 缩略 + 'x'/'y' 大图)。 +func photoSizeSpecsForMessage(data []byte) []photoSizeSpec { + w, h := imageDimensions(data, 1280, 1280) + thumbW, thumbH := scaleDown(w, h, 320) + return []photoSizeSpec{ + {Type: "m", W: thumbW, H: thumbH}, + {Type: "x", W: w, H: h}, + } +} + +func imageDimensions(data []byte, defW, defH int) (int, int) { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width <= 0 || cfg.Height <= 0 { + return defW, defH + } + return cfg.Width, cfg.Height +} + +func scaleDown(w, h, max int) (int, int) { + if w <= max && h <= max { + return w, h + } + if w >= h { + return max, max * h / w + } + return max * w / h, max +} + +func randomID() int64 { + var b [8]byte + _, _ = rand.Read(b[:]) + v := int64(binary.BigEndian.Uint64(b[:]) >> 1) + if v == 0 { + v = 1 + } + return v +} + +func randomFileReference() []byte { + b := make([]byte, 16) + _, _ = rand.Read(b) + return b +} diff --git a/internal/app/files/seed.go b/internal/app/files/seed.go new file mode 100644 index 00000000..58165dff --- /dev/null +++ b/internal/app/files/seed.go @@ -0,0 +1,860 @@ +package files + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "telesrv/internal/domain" +) + +// 本文件实现从外部导出的 reaction / sticker 资源目录导入媒体种子: +// JSON 元数据(外部导出 document id/access_hash/file_reference/attributes/thumbs)落 documents / +// sticker_sets / available_reactions 表,二进制 .tgs/.webp/缩略图落 blob backend。 +// dc_id 统一重写为本 server 的 DC,使客户端从本 DC 下载。导入幂等(已存在则跳过)。 + +// SeedStats 汇报一次种子导入结果。 +type SeedStats struct { + Reactions int + StickerSets int + Documents int + Blobs int + Skipped bool +} + +// SeedMedia 从导出根目录导入 reaction 与 sticker 资源。maxRegularSets<=0 表示不限。 +func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int) (SeedStats, error) { + var stats SeedStats + if root == "" { + stats.Skipped = true + return stats, nil + } + if _, err := os.Stat(root); err != nil { + // 目录不存在:跳过而非失败(开发机可能未放资源)。 + stats.Skipped = true + return stats, nil + } + + // reactions + if n, err := s.media.CountAvailableReactions(ctx); err != nil { + return stats, err + } else if n == 0 { + if err := s.seedReactions(ctx, root, &stats); err != nil { + return stats, fmt.Errorf("seed reactions: %w", err) + } + } else if incomplete, err := s.availableReactionSeedNeedsRepair(ctx); err != nil { + return stats, err + } else if incomplete { + if err := s.seedReactions(ctx, root, &stats); err != nil { + return stats, fmt.Errorf("repair reactions: %w", err) + } + } + + // sticker sets(default 系统集 + 常规集) + if n, err := s.media.CountStickerSets(ctx); err != nil { + return stats, err + } else if n == 0 { + if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil { + return stats, fmt.Errorf("seed sticker sets: %w", err) + } + } else if stale, err := s.stickerSetDocumentThumbsNeedInlineCache(ctx); err != nil { + return stats, err + } else if stale { + if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil { + return stats, fmt.Errorf("repair sticker set thumbs: %w", err) + } + } + + if stats.Reactions == 0 && stats.StickerSets == 0 { + stats.Skipped = true + } + return stats, nil +} + +// ---- reactions ---- + +func (s *Service) seedReactions(ctx context.Context, root string, stats *SeedStats) error { + reactionsDir := filepath.Join(root, "telegram_reactions_export", "reactions") + rawPath := filepath.Join(root, "telegram_reactions_export", "global_json", "available_reactions_raw.json") + raw, err := os.ReadFile(rawPath) + if err != nil { + return nil // 没有 reaction 资源就跳过 + } + var parsed struct { + Result struct { + Reactions []seedReactionJSON `json:"reactions"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return fmt.Errorf("parse available_reactions_raw.json: %w", err) + } + index, err := scanSeedDir(reactionsDir) + if err != nil { + return err + } + for i, rj := range parsed.Result.Reactions { + ar := domain.AvailableReaction{ + Reaction: rj.Reaction, + Title: rj.Title, + Inactive: rj.Inactive, + Premium: rj.Premium, + Order: i, + } + set := func(dst *int64, d *seedDocumentJSON) error { + if d == nil || d.ID == 0 { + return nil + } + doc, err := s.importDocument(ctx, *d, reactionsDir, index, stats) + if err != nil { + return err + } + *dst = doc.ID + return nil + } + if err := set(&ar.StaticIconID, rj.StaticIcon); err != nil { + return err + } + if err := set(&ar.AppearAnimationID, rj.AppearAnimation); err != nil { + return err + } + if err := set(&ar.SelectAnimationID, rj.SelectAnimation); err != nil { + return err + } + if err := set(&ar.ActivateAnimationID, rj.ActivateAnimation); err != nil { + return err + } + if err := set(&ar.EffectAnimationID, rj.EffectAnimation); err != nil { + return err + } + if err := set(&ar.AroundAnimationID, rj.AroundAnimation); err != nil { + return err + } + if err := set(&ar.CenterIconID, rj.CenterIcon); err != nil { + return err + } + if err := s.media.PutAvailableReaction(ctx, ar); err != nil { + return err + } + stats.Reactions++ + } + return nil +} + +func (s *Service) availableReactionSeedNeedsRepair(ctx context.Context) (bool, error) { + reactions, err := s.media.ListAvailableReactions(ctx) + if err != nil { + return false, err + } + var docIDs []int64 + for _, r := range reactions { + for _, id := range r.DocumentIDs() { + if id == 0 { + continue + } + docIDs = append(docIDs, id) + if _, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", id)); err != nil { + return false, err + } else if !ok { + return true, nil + } + } + } + if stale, err := s.documentsNeedInlineCachedThumbs(ctx, docIDs); err != nil || stale { + return stale, err + } + return false, nil +} + +// ---- sticker sets ---- + +func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, stats *SeedStats) error { + // default 系统集:目录名 → system_key。 + defaultDir := filepath.Join(root, "telegram_default_stickers_export") + order := 0 + if entries, err := os.ReadDir(defaultDir); err == nil { + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + for _, name := range names { + systemKey := systemKeyForDefaultSet(name) + setDir := filepath.Join(defaultDir, name) + if err := s.importStickerSetDir(ctx, setDir, systemKey, order, stats); err != nil { + return fmt.Errorf("import default set %s: %w", name, err) + } + order++ + } + } + + // 常规贴纸集。 + regularDir := filepath.Join(root, "telegram_stickers_export") + if entries, err := os.ReadDir(regularDir); err == nil { + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + imported := 0 + for _, name := range names { + if maxRegular > 0 && imported >= maxRegular { + break + } + setDir := filepath.Join(regularDir, name) + if err := s.importStickerSetDir(ctx, setDir, "", order, stats); err != nil { + return fmt.Errorf("import sticker set %s: %w", name, err) + } + order++ + imported++ + } + } + return nil +} + +func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, stats *SeedStats) error { + infoPath := filepath.Join(setDir, "set_info.json") + raw, err := os.ReadFile(infoPath) + if err != nil { + return nil // 该目录无 set_info.json → 跳过 + } + var info struct { + Result seedStickerSetResultJSON `json:"result"` + } + if err := json.Unmarshal(raw, &info); err != nil { + return fmt.Errorf("parse %s: %w", infoPath, err) + } + sj := info.Result.Set + if sj.ID == 0 { + return nil + } + stickersDir := filepath.Join(setDir, "stickers") + index, err := scanSeedDir(stickersDir) + if err != nil { + return err + } + + docIDs := make([]int64, 0, len(info.Result.Documents)) + docs := make([]domain.Document, 0, len(info.Result.Documents)) + docIDBySource := make(map[int64]int64, len(info.Result.Documents)) + for _, dj := range info.Result.Documents { + sourceID := dj.ID + doc, err := s.importDocument(ctx, dj, stickersDir, index, stats) + if err != nil { + return err + } + if doc.ID != 0 { + docIDBySource[sourceID] = doc.ID + docIDs = append(docIDs, doc.ID) + docs = append(docs, doc) + } + } + + kind := stickerSetKind(sj, systemKey) + set := domain.StickerSet{ + ID: sj.ID, + AccessHash: sj.AccessHash, + ShortName: sj.ShortName, + Title: sj.Title, + Count: sj.Count, + Hash: sj.Hash, + Kind: kind, + Official: sj.Official, + Animated: true, // 导出资源均为 .tgs 动画贴纸 + Emojis: sj.Emojis, + Masks: sj.Masks, + Archived: sj.Archived, + Installed: seedStickerSetInstalled(kind), + ThumbDocumentID: seedDocumentStorageID(derefInt64(sj.ThumbDocumentID)), + Thumbs: seedStickerSetPhotoSizes(sj.Thumbs), + ThumbDCID: s.dc, + ThumbVersion: sj.ThumbVersion, + DocumentIDs: docIDs, + Packs: seedStickerPacks(sj.Packs, info.Result.Packs, docIDBySource), + SortOrder: order, + SystemKey: systemKey, + } + if set.Count == 0 { + set.Count = len(docIDs) + } + if err := s.media.PutStickerSet(ctx, set); err != nil { + return err + } + s.stickerSetCache.put(set, docs) + stats.StickerSets++ + return nil +} + +// ---- 单个 document 导入 ---- + +func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDir string, index seedDirIndex, stats *SeedStats) (domain.Document, error) { + if dj.ID == 0 { + return domain.Document{}, nil + } + storageID := seedDocumentStorageID(dj.ID) + ref, _ := hex.DecodeString(dj.FileReference) + doc := domain.Document{ + ID: storageID, + AccessHash: dj.AccessHash, + FileReference: ref, + Date: parseSeedDate(dj.Date), + MimeType: dj.MimeType, + Size: dj.Size, + DCID: s.dc, + Attributes: seedDocumentAttributes(dj.Attributes), + } + + // 主体 blob:doc: + if mainPath, ok := index.main[dj.ID]; ok { + data, err := os.ReadFile(mainPath) + if err != nil { + return domain.Document{}, err + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Document{}, err + } + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d", storageID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + MimeType: dj.MimeType, + }); err != nil { + return domain.Document{}, err + } + s.prewarmSmallBlob(objectKey, data) + stats.Blobs++ + } + + // 缩略图:PhotoPathSize 内联;小的 PhotoSize 静态图同时写 blob 并作为 + // PhotoCachedSize 返回,让 TDesktop 处理 document 元数据时即可填本地 image cache。 + thumbs := make([]domain.PhotoSize, 0, len(dj.Thumbs)) + for _, tj := range dj.Thumbs { + ps, downloadable := seedPhotoSize(tj) + if ps.Kind == "" { + continue + } + if downloadable { + thumbPath, ok := index.thumb[dj.ID][ps.Type] + if !ok { + continue // 无可服务的缩略图文件,丢弃该尺寸(保留 PhotoPathSize 占位) + } + data, err := os.ReadFile(thumbPath) + if err != nil { + return domain.Document{}, err + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Document{}, err + } + ps.Size = len(data) + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d:%s", storageID, ps.Type), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + MimeType: seedThumbMimeType(data), + }); err != nil { + return domain.Document{}, err + } + ps = seedInlineCachedDocumentThumb(ps, data) + s.prewarmSmallBlob(objectKey, data) + stats.Blobs++ + } + thumbs = append(thumbs, ps) + } + doc.Thumbs = seedPreferRasterDocumentThumbs(thumbs) + + if err := s.media.PutDocument(ctx, doc); err != nil { + return domain.Document{}, err + } + stats.Documents++ + return doc, nil +} + +func (s *Service) prewarmSmallBlob(objectKey string, data []byte) { + if len(data) > 0 && len(data) <= blobBytesCacheMaxEntryBytes { + s.byteCache.put(objectKey, data) + } +} + +// ---- 目录扫描:docID → 主体文件 / 可下载缩略图 ---- + +type seedDirIndex struct { + main map[int64]string // docID -> 主体文件路径 + thumb map[int64]map[string]string // docID -> thumbType -> 缩略图文件路径 +} + +var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`) +var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`) + +const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024 + +// Exported Telegram resources keep their original id in filenames/JSON, but +// telesrv owns the document catalog it serves. Imported high source ids are +// normalized once at seed time so RPC/storage use one server-owned id. +const seedExternalDocumentIDOffset int64 = 4_000_000_000_000_000_000 + +var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`) + +func seedDocumentStorageID(sourceID int64) int64 { + if sourceID <= 0 { + return 0 + } + if sourceID > seedExternalDocumentIDOffset { + return sourceID - seedExternalDocumentIDOffset + } + return sourceID +} + +func scanSeedDir(dir string) (seedDirIndex, error) { + idx := seedDirIndex{main: map[int64]string{}, thumb: map[int64]map[string]string{}} + entries, err := os.ReadDir(dir) + if err != nil { + return idx, nil // 目录不存在 → 空 index + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + ext := strings.ToLower(filepath.Ext(name)) + full := filepath.Join(dir, name) + if marker := seedThumbMarker.FindStringIndex(name); marker != nil { + // 只收可下载的 PhotoSize 缩略图(jpg);PhotoPathSize(svg) 内联在 JSON。 + if ext != ".jpg" && ext != ".jpeg" { + continue + } + m := seedThumbType.FindStringSubmatch(name) + if m == nil { + continue + } + docID := docIDFromName(name[:marker[0]]) + if docID == 0 { + continue + } + if idx.thumb[docID] == nil { + idx.thumb[docID] = map[string]string{} + } + idx.thumb[docID][m[1]] = full + continue + } + if ext == ".svg" || ext == ".json" { + continue + } + docID := docIDFromName(strings.TrimSuffix(name, filepath.Ext(name))) + if docID == 0 { + continue + } + idx.main[docID] = full + } + return idx, nil +} + +// docIDFromName 取 base name 中末尾最长的数字串作为 document id。 +func docIDFromName(base string) int64 { + matches := seedTrailingDigits.FindAllString(base, -1) + if len(matches) == 0 { + return 0 + } + last := matches[len(matches)-1] + id, err := strconv.ParseInt(last, 10, 64) + if err != nil { + return 0 + } + return id +} + +func systemKeyForDefaultSet(dirName string) string { + switch dirName { + case "DefaultSet_AnimatedEmoji": + return "animated_emoji" + case "DefaultSet_AnimatedEmojiAnimations": + return "animated_emoji_animations" + case "DefaultSet_EmojiGenericAnimations": + return "emoji_generic_animations" + case "DefaultSet_Dice_Normal": + return "dice:\U0001f3b2" + case "DefaultSet_Dice_Dart": + return "dice:\U0001f3af" + case "DefaultSet_Dice_Basketball": + return "dice:\U0001f3c0" + case "DefaultSet_Dice_Football": + return "dice:⚽" + case "DefaultSet_Dice_Bowling": + return "dice:\U0001f3b3" + case "DefaultSet_Dice_Casino": + return "dice:\U0001f3b0" + default: + return "" + } +} + +func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKind { + switch { + case systemKey != "": + return domain.StickerSetKindSystem + case sj.Emojis: + return domain.StickerSetKindEmoji + case sj.Masks: + return domain.StickerSetKindMasks + default: + return domain.StickerSetKindStickers + } +} + +func seedStickerSetInstalled(kind domain.StickerSetKind) bool { + return kind != domain.StickerSetKindSystem +} + +// ---- JSON → domain 转换 ---- + +func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute { + out := make([]domain.DocumentAttribute, 0, len(attrs)) + for _, a := range attrs { + switch a.Type { + case "DocumentAttributeImageSize": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: a.W, H: a.H}) + case "DocumentAttributeAnimated": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}) + case "DocumentAttributeSticker": + attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: a.Alt, Mask: a.Mask} + if a.Stickerset != nil { + attr.StickerSetID = a.Stickerset.ID + attr.StickerSetAccessHash = a.Stickerset.AccessHash + } + out = append(out, attr) + case "DocumentAttributeCustomEmoji": + attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: a.Alt, Free: a.Free, TextColor: a.TextColor} + if a.Stickerset != nil { + attr.StickerSetID = a.Stickerset.ID + attr.StickerSetAccessHash = a.Stickerset.AccessHash + } + out = append(out, attr) + case "DocumentAttributeVideo": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming}) + case "DocumentAttributeAudio": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: int(a.Duration), Voice: a.Voice, Title: a.Title, Performer: a.Performer}) + case "DocumentAttributeFilename": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: a.FileName}) + } + } + return out +} + +func seedPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize { + out := make([]domain.PhotoSize, 0, len(thumbs)) + for _, t := range thumbs { + ps, _ := seedPhotoSize(t) + if ps.Kind != "" { + out = append(out, ps) + } + } + return out +} + +func seedStickerSetPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize { + out := make([]domain.PhotoSize, 0, len(thumbs)) + for _, t := range thumbs { + ps, downloadable := seedPhotoSize(t) + if ps.Kind == "" || downloadable { + continue + } + out = append(out, ps) + } + return out +} + +func seedPhotoSize(t seedThumbJSON) (domain.PhotoSize, bool) { + switch t.Type { + case "PhotoSize": + return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: t.SizeType, W: t.W, H: t.H, Size: t.Size}, true + case "PhotoStrippedSize": + b, _ := hex.DecodeString(t.Bytes) + return domain.PhotoSize{Kind: domain.PhotoSizeKindStripped, Type: t.SizeType, Bytes: b}, false + case "PhotoCachedSize": + b, _ := hex.DecodeString(t.Bytes) + return domain.PhotoSize{Kind: domain.PhotoSizeKindCached, Type: t.SizeType, W: t.W, H: t.H, Bytes: b}, false + case "PhotoPathSize": + b, _ := hex.DecodeString(t.Bytes) + return domain.PhotoSize{Kind: domain.PhotoSizeKindPath, Type: t.SizeType, Bytes: b}, false + case "PhotoSizeProgressive": + return domain.PhotoSize{Kind: domain.PhotoSizeKindProgressive, Type: t.SizeType, W: t.W, H: t.H, Sizes: t.Sizes}, true + default: + return domain.PhotoSize{}, false + } +} + +func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.PhotoSize { + if ps.Kind != domain.PhotoSizeKindDefault || len(data) == 0 || len(data) > seedInlineCachedDocumentThumbMaxBytes { + return ps + } + ps.Kind = domain.PhotoSizeKindCached + ps.Size = 0 + ps.Bytes = append([]byte(nil), data...) + return ps +} + +func seedPreferRasterDocumentThumbs(sizes []domain.PhotoSize) []domain.PhotoSize { + if !documentThumbsHaveRaster(sizes) { + return sizes + } + out := sizes[:0] + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindPath { + continue + } + out = append(out, size) + } + return out +} + +func seedThumbMimeType(data []byte) string { + switch { + case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && + data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P': + return "image/webp" + case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: + return "image/jpeg" + case len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G': + return "image/png" + case len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F': + return "image/gif" + default: + return "application/octet-stream" + } +} + +func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) (bool, error) { + var ids []int64 + for _, kind := range []domain.StickerSetKind{ + domain.StickerSetKindStickers, + domain.StickerSetKindEmoji, + domain.StickerSetKindMasks, + domain.StickerSetKindSystem, + } { + sets, err := s.media.ListStickerSets(ctx, kind) + if err != nil { + return false, err + } + for _, set := range sets { + ids = append(ids, set.DocumentIDs...) + } + } + return s.documentsNeedInlineCachedThumbs(ctx, ids) +} + +func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int64) (bool, error) { + if len(ids) == 0 { + return false, nil + } + seen := make(map[int64]struct{}, len(ids)) + unique := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + docs, err := s.media.GetDocuments(ctx, unique) + if err != nil { + return false, err + } + for _, doc := range docs { + if documentThumbsHaveRaster(doc.Thumbs) && documentThumbsHavePath(doc.Thumbs) { + return true, nil + } + for _, thumb := range doc.Thumbs { + if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes { + return true, nil + } + if thumb.Kind == domain.PhotoSizeKindCached && len(thumb.Bytes) > 0 { + blob, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)) + if err != nil { + return false, err + } + if ok { + want := seedThumbMimeType(thumb.Bytes) + if want != "application/octet-stream" && blob.MimeType != want { + return true, nil + } + } + } + } + } + return false, nil +} + +func documentThumbsHaveRaster(sizes []domain.PhotoSize) bool { + for _, size := range sizes { + switch size.Kind { + case domain.PhotoSizeKindCached: + if len(size.Bytes) > 0 { + return true + } + case domain.PhotoSizeKindDefault: + if size.Type != "" && size.Size > 0 { + return true + } + case domain.PhotoSizeKindProgressive: + if size.Type != "" && len(size.Sizes) > 0 { + return true + } + } + } + return false +} + +func documentThumbsHavePath(sizes []domain.PhotoSize) bool { + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 { + return true + } + } + return false +} + +func seedStickerPacks(setPacks, resultPacks []seedStickerPackJSON, docIDBySource map[int64]int64) []domain.StickerPack { + packs := setPacks + if len(packs) == 0 { + packs = resultPacks + } + out := make([]domain.StickerPack, 0, len(packs)) + for _, p := range packs { + documents := make([]int64, 0, len(p.Documents)) + for _, sourceID := range p.Documents { + if id, ok := docIDBySource[sourceID]; ok { + documents = append(documents, id) + continue + } + if id := seedDocumentStorageID(sourceID); id != 0 { + documents = append(documents, id) + } + } + out = append(out, domain.StickerPack{Emoticon: p.Emoticon, DocumentIDs: documents}) + } + return out +} + +func parseSeedDate(s string) int { + if s == "" { + return 0 + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return int(t.Unix()) + } + return 0 +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +// ---- seed JSON 结构 ---- + +type seedInputStickerSetJSON struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` +} + +type seedAttrJSON struct { + Type string `json:"_"` + W int `json:"w"` + H int `json:"h"` + Alt string `json:"alt"` + Mask bool `json:"mask"` + Duration float64 `json:"duration"` + RoundMessage bool `json:"round_message"` + SupportsStreaming bool `json:"supports_streaming"` + Voice bool `json:"voice"` + Title string `json:"title"` + Performer string `json:"performer"` + FileName string `json:"file_name"` + Free bool `json:"free"` + TextColor bool `json:"text_color"` + Stickerset *seedInputStickerSetJSON `json:"stickerset"` +} + +type seedThumbJSON struct { + Type string `json:"_"` + SizeType string `json:"type"` + W int `json:"w"` + H int `json:"h"` + Size int `json:"size"` + Bytes string `json:"bytes"` + Sizes []int `json:"sizes"` +} + +type seedDocumentJSON struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + FileReference string `json:"file_reference"` + Date string `json:"date"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + DCID int `json:"dc_id"` + Attributes []seedAttrJSON `json:"attributes"` + Thumbs []seedThumbJSON `json:"thumbs"` +} + +type seedStickerPackJSON struct { + Emoticon string `json:"emoticon"` + Documents []int64 `json:"documents"` +} + +type seedStickerSetJSON struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + Title string `json:"title"` + ShortName string `json:"short_name"` + Count int `json:"count"` + Hash int `json:"hash"` + Archived bool `json:"archived"` + Official bool `json:"official"` + Masks bool `json:"masks"` + Emojis bool `json:"emojis"` + Thumbs []seedThumbJSON `json:"thumbs"` + ThumbDCID int `json:"thumb_dc_id"` + ThumbVersion int `json:"thumb_version"` + ThumbDocumentID *int64 `json:"thumb_document_id"` + Packs []seedStickerPackJSON `json:"packs"` +} + +type seedStickerSetResultJSON struct { + Set seedStickerSetJSON `json:"set"` + Packs []seedStickerPackJSON `json:"packs"` + Documents []seedDocumentJSON `json:"documents"` +} + +type seedReactionJSON struct { + Reaction string `json:"reaction"` + Title string `json:"title"` + Inactive bool `json:"inactive"` + Premium bool `json:"premium"` + StaticIcon *seedDocumentJSON `json:"static_icon"` + AppearAnimation *seedDocumentJSON `json:"appear_animation"` + SelectAnimation *seedDocumentJSON `json:"select_animation"` + ActivateAnimation *seedDocumentJSON `json:"activate_animation"` + EffectAnimation *seedDocumentJSON `json:"effect_animation"` + AroundAnimation *seedDocumentJSON `json:"around_animation"` + CenterIcon *seedDocumentJSON `json:"center_icon"` +} diff --git a/internal/app/files/seed_test.go b/internal/app/files/seed_test.go new file mode 100644 index 00000000..236d27bc --- /dev/null +++ b/internal/app/files/seed_test.go @@ -0,0 +1,526 @@ +package files + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + + "telesrv/internal/domain" +) + +// fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。 +type fakeMediaStore struct { + mu sync.Mutex + blobs map[string]domain.FileBlob + docs map[int64]domain.Document + photos map[int64]domain.Photo + sets map[int64]domain.StickerSet + reactions []domain.AvailableReaction + parts map[string][]domain.UploadPart +} + +func newFakeMediaStore() *fakeMediaStore { + return &fakeMediaStore{ + blobs: map[string]domain.FileBlob{}, + docs: map[int64]domain.Document{}, + photos: map[int64]domain.Photo{}, + sets: map[int64]domain.StickerSet{}, + parts: map[string][]domain.UploadPart{}, + } +} + +func (f *fakeMediaStore) SaveFilePart(_ context.Context, _ domain.UploadPart) error { return nil } +func (f *fakeMediaStore) LoadFileParts(_ context.Context, _, _ int64) ([]domain.UploadPart, error) { + return nil, nil +} +func (f *fakeMediaStore) DeleteFileParts(_ context.Context, _, _ int64) error { return nil } + +func (f *fakeMediaStore) PutFileBlob(_ context.Context, blob domain.FileBlob) error { + f.mu.Lock() + defer f.mu.Unlock() + f.blobs[blob.LocationKey] = blob + return nil +} +func (f *fakeMediaStore) GetFileBlob(_ context.Context, key string) (domain.FileBlob, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + b, ok := f.blobs[key] + return b, ok, nil +} + +func (f *fakeMediaStore) PutDocument(_ context.Context, doc domain.Document) error { + f.mu.Lock() + defer f.mu.Unlock() + f.docs[doc.ID] = doc + return nil +} +func (f *fakeMediaStore) GetDocument(_ context.Context, id int64) (domain.Document, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + d, ok := f.docs[id] + return d, ok, nil +} +func (f *fakeMediaStore) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]domain.Document, 0, len(ids)) + for _, id := range ids { + if d, ok := f.docs[id]; ok { + out = append(out, d) + } + } + return out, nil +} +func (f *fakeMediaStore) PutPhoto(_ context.Context, p domain.Photo) error { + f.mu.Lock() + defer f.mu.Unlock() + f.photos[p.ID] = p + return nil +} +func (f *fakeMediaStore) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + p, ok := f.photos[id] + return p, ok, nil +} + +func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sets[set.ID] = set + return nil +} +func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + s, ok := f.sets[id] + return s, ok, nil +} +func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, s := range f.sets { + if s.ShortName == name { + return s, true, nil + } + } + return domain.StickerSet{}, false, nil +} +func (f *fakeMediaStore) GetStickerSetBySystemKey(_ context.Context, key string) (domain.StickerSet, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, s := range f.sets { + if s.SystemKey == key { + return s, true, nil + } + } + return domain.StickerSet{}, false, nil +} +func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []domain.StickerSet + for _, s := range f.sets { + if s.Kind == kind { + out = append(out, s) + } + } + return out, nil +} +func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.sets), nil +} +func (f *fakeMediaStore) PutAvailableReaction(_ context.Context, r domain.AvailableReaction) error { + f.mu.Lock() + defer f.mu.Unlock() + for i, existing := range f.reactions { + if existing.Reaction == r.Reaction { + f.reactions[i] = r + return nil + } + } + f.reactions = append(f.reactions, r) + return nil +} +func (f *fakeMediaStore) ListAvailableReactions(_ context.Context) ([]domain.AvailableReaction, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]domain.AvailableReaction(nil), f.reactions...), nil +} +func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.reactions), nil +} +func (f *fakeMediaStore) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error { + return nil +} +func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) { + return 0, false, nil +} +func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) { + return map[int64]domain.ProfilePhotoRef{}, nil +} +func (f *fakeMediaStore) ListProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _, _ int, _ int64) ([]int64, int, error) { + return nil, 0, nil +} +func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) { + return nil, nil +} + +func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) { + seedDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(seedDir, "telegram_reactions_export", "global_json"), 0o755); err != nil { + t.Fatal(err) + } + reactionsDir := filepath.Join(seedDir, "telegram_reactions_export", "reactions") + if err := os.MkdirAll(reactionsDir, 0o755); err != nil { + t.Fatal(err) + } + raw := `{"result":{"reactions":[{"reaction":"👍","title":"Like","static_icon":{"id":1111111,"access_hash":1,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"image/webp","size":4,"attributes":[],"thumbs":[]},"select_animation":{"id":2222222,"access_hash":2,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"attributes":[],"thumbs":[]}}]}}` + if err := os.WriteFile(filepath.Join(seedDir, "telegram_reactions_export", "global_json", "available_reactions_raw.json"), []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111.webp"), []byte("webp"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111_thumb1_PhotoSize_types_72x72.jpg"), []byte("jpeg"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_select_2222222.tgs"), []byte("tgs!"), 0o644); err != nil { + t.Fatal(err) + } + + media := newFakeMediaStore() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + blobs := &countingBlobBackend{BlobBackend: local} + svc := NewService(media, blobs, 2) + if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil { + t.Fatalf("initial seed: %v", err) + } else if stats.Reactions != 1 || stats.Blobs != 2 { + t.Fatalf("initial stats = %+v, want one reaction and two blobs", stats) + } + chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4}) + if err != nil || !ok { + t.Fatalf("prewarmed getfile ok=%v err=%v", ok, err) + } + if string(chunk.Bytes) != "tgs!" { + t.Fatalf("prewarmed chunk = %q, want tgs!", chunk.Bytes) + } + if blobs.getRangeCalls != 0 { + t.Fatalf("seeded small blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls) + } + + media.mu.Lock() + delete(media.blobs, "doc:2222222") + media.mu.Unlock() + + stats, err := svc.SeedMedia(context.Background(), seedDir, 0) + if err != nil { + t.Fatalf("repair seed: %v", err) + } + if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped { + t.Fatalf("repair stats = %+v, want repair import", stats) + } + if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok { + t.Fatal("missing reaction blob was not repaired") + } + if reactions, _ := media.ListAvailableReactions(context.Background()); len(reactions) != 1 { + t.Fatalf("reaction upsert duplicated rows: got %d", len(reactions)) + } +} + +func TestSeedMediaFromRealExport(t *testing.T) { + seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR") + if seedDir == "" { + t.Skip("TELESRV_REAL_STICKER_SEED_DIR not set") + } + if _, err := os.Stat(seedDir); err != nil { + t.Skipf("seed dir %s not present: %v", seedDir, err) + } + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + svc := NewService(media, blobs, 2) + stats, err := svc.SeedMedia(context.Background(), seedDir, 2) + if err != nil { + t.Fatalf("seed media: %v", err) + } + t.Logf("seed stats: reactions=%d sets=%d docs=%d blobs=%d", stats.Reactions, stats.StickerSets, stats.Documents, stats.Blobs) + if stats.Reactions == 0 { + t.Error("expected reactions imported") + } + if stats.StickerSets == 0 { + t.Error("expected sticker sets imported") + } + if stats.Documents == 0 { + t.Error("expected documents imported") + } + if stats.Blobs == 0 { + t.Error("expected blobs imported") + } + + // reaction 引用的文档应能被解析回真实 document(带 sticker 属性 + 主体 blob)。 + reactions, _ := media.ListAvailableReactions(context.Background()) + if len(reactions) == 0 { + t.Fatal("no reactions stored") + } + first := reactions[0] + if first.Reaction == "" { + t.Error("reaction emoticon empty") + } + if first.StaticIconID == 0 || first.SelectAnimationID == 0 { + t.Error("reaction missing document ids") + } + if d, ok, _ := media.GetDocument(context.Background(), first.SelectAnimationID); !ok { + t.Error("reaction select animation document missing") + } else { + if d.ID > seedExternalDocumentIDOffset { + t.Errorf("reaction document kept external source id: %d", d.ID) + } + if d.DCID != 2 { + t.Errorf("document dc_id not rewritten: %d", d.DCID) + } + if _, ok, _ := media.GetFileBlob(context.Background(), blobKeyDoc(d.ID)); !ok { + t.Errorf("reaction document %d main blob missing", d.ID) + } + } + + // 一个常规贴纸集应有 documents 且能按 short_name 解析。 + for _, s := range media.sets { + for _, thumb := range s.Thumbs { + if thumb.Downloadable() { + t.Fatalf("sticker set %s exposes downloadable cover thumb %q without a serviceable blob", s.ShortName, thumb.Type) + } + } + } + + var sample domain.StickerSet + for _, s := range media.sets { + if s.Kind == domain.StickerSetKindStickers && len(s.DocumentIDs) > 0 { + sample = s + break + } + } + if sample.ID == 0 { + t.Fatal("no regular sticker set with documents imported") + } + if got, ok, _ := media.GetStickerSetByShortName(context.Background(), sample.ShortName); !ok || got.ID != sample.ID { + t.Error("sticker set not resolvable by short name") + } + if doc, ok, _ := media.GetDocument(context.Background(), sample.DocumentIDs[0]); !ok { + t.Fatalf("sample sticker document %d missing", sample.DocumentIDs[0]) + } else { + if doc.ID > seedExternalDocumentIDOffset { + t.Fatalf("sample sticker kept external source id: %d", doc.ID) + } + thumb, ok := findCachedThumb(doc.Thumbs) + if !ok { + t.Fatalf("sample sticker document thumbs are not inline cached: %+v", doc.Thumbs) + } + blob, ok, err := media.GetFileBlob(context.Background(), blobKeyDoc(doc.ID)+":"+thumb.Type) + if err != nil || !ok { + t.Fatalf("sample sticker thumb blob ok=%v err=%v", ok, err) + } + if want := seedThumbMimeType(thumb.Bytes); blob.MimeType != want { + t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want) + } + if hasPathThumb(doc.Thumbs) { + t.Fatalf("sample sticker document still exposes path thumb together with raster: %+v", doc.Thumbs) + } + } +} + +func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) { + const sourceID int64 = 5382305375846410902 + const want int64 = 1382305375846410902 + if got := seedDocumentStorageID(sourceID); got != want { + t.Fatalf("seedDocumentStorageID(%d) = %d, want %d", sourceID, got, want) + } + if got := seedDocumentStorageID(2222222); got != 2222222 { + t.Fatalf("small server id changed: %d", got) + } +} + +func TestSeedStickerSetInstalledFlagExcludesSystemSets(t *testing.T) { + cases := []struct { + name string + kind domain.StickerSetKind + want bool + }{ + {name: "regular stickers", kind: domain.StickerSetKindStickers, want: true}, + {name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true}, + {name: "masks", kind: domain.StickerSetKindMasks, want: true}, + {name: "system resources", kind: domain.StickerSetKindSystem, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := seedStickerSetInstalled(tc.kind); got != tc.want { + t.Fatalf("seedStickerSetInstalled(%q) = %v, want %v", tc.kind, got, tc.want) + } + }) + } +} + +func TestSeedInlineCachedDocumentThumb(t *testing.T) { + input := domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 6400} + got := seedInlineCachedDocumentThumb(input, []byte("jpeg")) + if got.Kind != domain.PhotoSizeKindCached { + t.Fatalf("kind = %q, want cached", got.Kind) + } + if got.Size != 0 || string(got.Bytes) != "jpeg" { + t.Fatalf("cached thumb = %+v, want inline bytes without downloadable size", got) + } + large := make([]byte, seedInlineCachedDocumentThumbMaxBytes+1) + if got := seedInlineCachedDocumentThumb(input, large); got.Kind != domain.PhotoSizeKindDefault || got.Size != input.Size || len(got.Bytes) != 0 { + t.Fatalf("large thumb = %+v, want unchanged downloadable thumb", got) + } +} + +func TestSeedThumbMimeType(t *testing.T) { + webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'} + if got := seedThumbMimeType(webp); got != "image/webp" { + t.Fatalf("webp mime = %q, want image/webp", got) + } + jpeg := []byte{0xFF, 0xD8, 0xFF} + if got := seedThumbMimeType(jpeg); got != "image/jpeg" { + t.Fatalf("jpeg mime = %q, want image/jpeg", got) + } +} + +func TestSeedPreferRasterDocumentThumbsDropsPathWhenRasterExists(t *testing.T) { + sizes := []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")}, + {Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: []byte("webp")}, + } + got := seedPreferRasterDocumentThumbs(sizes) + if hasPathThumb(got) { + t.Fatalf("path thumb should be dropped when raster exists: %+v", got) + } + if !hasCachedThumb(got) { + t.Fatalf("cached thumb should be kept: %+v", got) + } + + onlyPath := []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")}} + if got := seedPreferRasterDocumentThumbs(onlyPath); !hasPathThumb(got) { + t.Fatalf("path-only thumbs should be kept: %+v", got) + } +} + +func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'} + doc := domain.Document{ + ID: 100, + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: webp}, + }, + } + if err := media.PutDocument(ctx, doc); err != nil { + t.Fatalf("put doc: %v", err) + } + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/jpeg"}); err != nil { + t.Fatalf("put blob: %v", err) + } + svc := NewService(media, nil, 2) + stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID}) + if err != nil { + t.Fatalf("documentsNeedInlineCachedThumbs: %v", err) + } + if !stale { + t.Fatal("expected stale mime to require repair") + } + + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/webp"}); err != nil { + t.Fatalf("put repaired blob: %v", err) + } + stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID}) + if err != nil { + t.Fatalf("documentsNeedInlineCachedThumbs after repair: %v", err) + } + if stale { + t.Fatal("repaired mime should not require repair") + } +} + +func TestDocumentsNeedInlineCachedThumbsDetectsPathWithRaster(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + doc := domain.Document{ + ID: 100, + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")}, + {Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: []byte("webp")}, + }, + } + if err := media.PutDocument(ctx, doc); err != nil { + t.Fatalf("put doc: %v", err) + } + svc := NewService(media, nil, 2) + stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID}) + if err != nil { + t.Fatalf("documentsNeedInlineCachedThumbs: %v", err) + } + if !stale { + t.Fatal("path thumb with raster should require repair") + } +} + +func hasCachedThumb(sizes []domain.PhotoSize) bool { + _, ok := findCachedThumb(sizes) + return ok +} + +func findCachedThumb(sizes []domain.PhotoSize) (domain.PhotoSize, bool) { + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindCached && len(size.Bytes) > 0 { + return size, true + } + } + return domain.PhotoSize{}, false +} + +func hasPathThumb(sizes []domain.PhotoSize) bool { + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 { + return true + } + } + return false +} + +func blobKeyDoc(id int64) string { + return "doc:" + itoa(id) +} + +func itoa(v int64) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/internal/app/files/service.go b/internal/app/files/service.go new file mode 100644 index 00000000..181fd161 --- /dev/null +++ b/internal/app/files/service.go @@ -0,0 +1,256 @@ +package files + +import ( + "context" + "fmt" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// 上传分片上限:与 Telegram 客户端约定一致(单片 ≤512KB;分片总数有上限防止 OOM)。 +const ( + MaxUploadPartBytes = 524288 // 512KB + MaxUploadParts = 8000 // 512KB * 8000 ≈ 4GB 理论上限,足够主路径媒体 +) + +// blobMetaCacheCapacity 是 location_key→FileBlob 元数据 LRU 容量(每项约百字节,约 13MB)。 +const blobMetaCacheCapacity = 1 << 16 + +// 小文件热缓存只覆盖 sticker/reaction/thumbnail 一类不可变小 blob;大媒体继续分段读。 +const ( + blobBytesCacheMaxEntryBytes = 256 << 10 // 256KB + blobBytesCacheMaxBytes = 64 << 20 // 64MB +) + +// Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。 +type Service struct { + media store.MediaStore + blobs BlobBackend + dc int + blobCache *blobMetaCache + byteCache *blobBytesCache + stickerSetCache *stickerSetFullCache +} + +// NewService 创建 files 服务。dc 是本 server 的 DC id,写入新建 document/photo 的 dc_id。 +func NewService(media store.MediaStore, blobs BlobBackend, dc int) *Service { + return &Service{ + media: media, + blobs: blobs, + dc: dc, + blobCache: newBlobMetaCache(blobMetaCacheCapacity), + byteCache: newBlobBytesCache(blobBytesCacheMaxBytes), + stickerSetCache: newStickerSetFullCache(), + } +} + +// SaveFilePart 累积一个 small file 分片。 +func (s *Service) SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error) { + if err := validatePart(part, len(bytes)); err != nil { + return false, err + } + if err := s.media.SaveFilePart(ctx, domain.UploadPart{ + OwnerUserID: ownerUserID, + FileID: fileID, + Part: part, + Bytes: bytes, + }); err != nil { + return false, err + } + return true, nil +} + +// SaveBigFilePart 累积一个 big file 分片(带已知总分片数)。 +func (s *Service) SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64, part, totalParts int, bytes []byte) (bool, error) { + if err := validatePart(part, len(bytes)); err != nil { + return false, err + } + if totalParts <= 0 || totalParts > MaxUploadParts { + return false, domain.ErrFilePartsInvalid + } + if err := s.media.SaveFilePart(ctx, domain.UploadPart{ + OwnerUserID: ownerUserID, + FileID: fileID, + Part: part, + TotalParts: totalParts, + Big: true, + Bytes: bytes, + }); err != nil { + return false, err + } + return true, nil +} + +// GetFile 按 location_key 取一段 blob 内容。found=false 表示该 location 无对应 blob。 +// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker / +// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。 +func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) { + blob, ok := s.blobCache.get(req.LocationKey) + if !ok { + var ( + found bool + err error + ) + blob, found, err = s.media.GetFileBlob(ctx, req.LocationKey) + if err != nil { + return domain.FileChunk{}, false, err + } + if !found { + return domain.FileChunk{}, false, nil + } + s.blobCache.put(req.LocationKey, blob) + } + if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes { + if data, ok := s.byteCache.get(blob.ObjectKey); ok { + return domain.FileChunk{ + Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)), + MimeType: blob.MimeType, + Total: int64(len(data)), + }, true, nil + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1) + if err != nil { + return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err) + } + if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total { + s.byteCache.put(blob.ObjectKey, data) + return domain.FileChunk{ + Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)), + MimeType: blob.MimeType, + Total: total, + }, true, nil + } + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit)) + if err != nil { + return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err) + } + return domain.FileChunk{ + Bytes: data, + MimeType: blob.MimeType, + Total: total, + }, true, nil +} + +func sliceBlobBytes(data []byte, offset, limit int64) []byte { + total := int64(len(data)) + if offset < 0 { + offset = 0 + } + if offset >= total { + return []byte{} + } + end := total + if limit > 0 && offset+limit < end { + end = offset + limit + } + return append([]byte(nil), data[offset:end]...) +} + +// ---- 资源读取(reaction / sticker / document)---- + +// ListAvailableReactions 返回可用 reaction 目录(带真实文档 id)。 +func (s *Service) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) { + return s.media.ListAvailableReactions(ctx) +} + +// GetDocuments 按 id 批量加载文档(自定义 emoji / 贴纸)。 +func (s *Service) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) { + return s.media.GetDocuments(ctx, ids) +} + +// ListStickerSets 列出某类贴纸集(用于 getAllStickers 等)。 +func (s *Service) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + return s.media.ListStickerSets(ctx, kind) +} + +// ResolveStickerSet 按 ref 解析贴纸集,并按 DocumentIDs 顺序加载其文档。 +func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + if set, docs, ok := s.stickerSetCache.get(ref); ok { + return set, docs, true, nil + } + var ( + set domain.StickerSet + found bool + err error + ) + switch ref.Kind { + case domain.StickerSetRefByID: + set, found, err = s.media.GetStickerSetByID(ctx, ref.ID) + case domain.StickerSetRefByShortName: + set, found, err = s.media.GetStickerSetByShortName(ctx, ref.ShortName) + case domain.StickerSetRefBySystem: + set, found, err = s.media.GetStickerSetBySystemKey(ctx, ref.SystemKey) + default: + return domain.StickerSet{}, nil, false, nil + } + if err != nil || !found { + return domain.StickerSet{}, nil, found, err + } + docs, err := s.media.GetDocuments(ctx, set.DocumentIDs) + if err != nil { + return domain.StickerSet{}, nil, false, err + } + ordered := orderDocuments(docs, set.DocumentIDs) + s.stickerSetCache.put(set, ordered) + return set, ordered, true, nil +} + +// orderDocuments 把无序的文档按 ids 顺序重排(GetDocuments 用 ANY 查询不保证顺序)。 +func orderDocuments(docs []domain.Document, ids []int64) []domain.Document { + byID := make(map[int64]domain.Document, len(docs)) + for _, d := range docs { + byID[d.ID] = d + } + out := make([]domain.Document, 0, len(ids)) + for _, id := range ids { + if d, ok := byID[id]; ok { + out = append(out, d) + } + } + return out +} + +// assembleUpload 把已上传分片按 part 顺序拼成完整字节,并清理分片。 +// expectedParts>0 时校验分片连续且齐全。 +func (s *Service) assembleUpload(ctx context.Context, ownerUserID, fileID int64, expectedParts int) ([]byte, error) { + parts, err := s.media.LoadFileParts(ctx, ownerUserID, fileID) + if err != nil { + return nil, err + } + if len(parts) == 0 { + return nil, domain.ErrFilePartsInvalid + } + if expectedParts > 0 && len(parts) != expectedParts { + return nil, domain.ErrFilePartsInvalid + } + total := 0 + for i, p := range parts { + if p.Part != i { + return nil, domain.ErrFilePartsInvalid // 缺片或乱序 + } + total += len(p.Bytes) + } + buf := make([]byte, 0, total) + for _, p := range parts { + buf = append(buf, p.Bytes...) + } + if err := s.media.DeleteFileParts(ctx, ownerUserID, fileID); err != nil { + return nil, err + } + return buf, nil +} + +func validatePart(part, size int) error { + if part < 0 || part >= MaxUploadParts { + return domain.ErrFilePartInvalid + } + if size == 0 { + return domain.ErrFilePartInvalid + } + if size > MaxUploadPartBytes { + return domain.ErrFilePartTooBig + } + return nil +} diff --git a/internal/app/files/warm.go b/internal/app/files/warm.go new file mode 100644 index 00000000..8682f637 --- /dev/null +++ b/internal/app/files/warm.go @@ -0,0 +1,136 @@ +package files + +import ( + "context" + "fmt" + + "telesrv/internal/domain" +) + +// WarmStats 汇报一次启动资源缓存预热结果。 +type WarmStats struct { + StickerSets int + Documents int + Blobs int +} + +// WarmCaches 从已持久化的 sticker/reaction 元数据预热小 blob 字节缓存与完整 sticker set 缓存。 +// SeedMedia 在已有数据时会跳过导入;该方法保证普通 server 重启后历史 sticker 首次渲染也不是冷缓存。 +func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) { + var stats WarmStats + seenDocs := make(map[int64]struct{}) + for _, kind := range []domain.StickerSetKind{ + domain.StickerSetKindStickers, + domain.StickerSetKindEmoji, + domain.StickerSetKindMasks, + domain.StickerSetKindSystem, + } { + sets, err := s.media.ListStickerSets(ctx, kind) + if err != nil { + return stats, err + } + for _, set := range sets { + docs, err := s.media.GetDocuments(ctx, set.DocumentIDs) + if err != nil { + return stats, err + } + ordered := orderDocuments(docs, set.DocumentIDs) + s.stickerSetCache.put(set, ordered) + stats.StickerSets++ + for _, doc := range ordered { + if _, ok := seenDocs[doc.ID]; ok { + continue + } + seenDocs[doc.ID] = struct{}{} + stats.Documents++ + warmed, err := s.prewarmDocumentBlobs(ctx, doc) + if err != nil { + return stats, err + } + stats.Blobs += warmed + } + } + } + reactions, err := s.media.ListAvailableReactions(ctx) + if err != nil { + return stats, err + } + reactionIDs := make([]int64, 0, len(reactions)*4) + for _, reaction := range reactions { + reactionIDs = append(reactionIDs, reaction.DocumentIDs()...) + } + docs, err := s.media.GetDocuments(ctx, reactionIDs) + if err != nil { + return stats, err + } + for _, doc := range docs { + if _, ok := seenDocs[doc.ID]; ok { + continue + } + seenDocs[doc.ID] = struct{}{} + stats.Documents++ + warmed, err := s.prewarmDocumentBlobs(ctx, doc) + if err != nil { + return stats, err + } + stats.Blobs += warmed + } + return stats, nil +} + +func (s *Service) prewarmDocumentBlobs(ctx context.Context, doc domain.Document) (int, error) { + if doc.ID == 0 { + return 0, nil + } + warmed := 0 + ok, err := s.prewarmLocationKey(ctx, fmt.Sprintf("doc:%d", doc.ID)) + if err != nil { + return 0, err + } + if ok { + warmed++ + } + for _, thumb := range doc.Thumbs { + if !thumb.Downloadable() { + continue + } + ok, err := s.prewarmLocationKey(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)) + if err != nil { + return 0, err + } + if ok { + warmed++ + } + } + return warmed, nil +} + +func (s *Service) prewarmLocationKey(ctx context.Context, locationKey string) (bool, error) { + blob, ok := s.blobCache.get(locationKey) + if !ok { + var ( + found bool + err error + ) + blob, found, err = s.media.GetFileBlob(ctx, locationKey) + if err != nil { + return false, err + } + if !found { + return false, nil + } + s.blobCache.put(locationKey, blob) + } + if blob.Size <= 0 || blob.Size > blobBytesCacheMaxEntryBytes || s.byteCache.has(blob.ObjectKey) { + return false, nil + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1) + if err != nil { + return false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err) + } + if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total { + s.byteCache.put(blob.ObjectKey, data) + return true, nil + } + return false, nil +} diff --git a/internal/app/help/doc.go b/internal/app/help/doc.go new file mode 100644 index 00000000..edee7ddf --- /dev/null +++ b/internal/app/help/doc.go @@ -0,0 +1,2 @@ +// Package help 提供 help.* RPC 背后的数据目录服务。 +package help diff --git a/internal/app/help/service.go b/internal/app/help/service.go new file mode 100644 index 00000000..26b5822d --- /dev/null +++ b/internal/app/help/service.go @@ -0,0 +1,76 @@ +package help + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const tdesktopClient = "tdesktop" +const tdesktopDefaultAppConfig = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_in_chat_max":3}` + +// Service 提供客户端启动配置与国家区号目录。 +type Service struct { + appConfigs store.AppConfigStore + countries store.CountryStore +} + +// NewService 创建 help 服务。 +func NewService(appConfigs store.AppConfigStore, countries store.CountryStore) *Service { + return &Service{appConfigs: appConfigs, countries: countries} +} + +// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。 +func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) { + if s == nil || s.appConfigs == nil { + cfg := domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)} + return cfg, hash == cfg.Hash, nil + } + cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient) + if err != nil { + return domain.AppConfig{}, false, err + } + if !found { + cfg = domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)} + } + return cfg, hash != 0 && hash == cfg.Hash, nil +} + +// GetCountries 返回国家区号目录,hash 命中时返回 notModified。 +func (s *Service) GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error) { + if s == nil || s.countries == nil { + list := defaultCountries() + return list, hash != 0 && hash == list.Hash, nil + } + list, err := s.countries.ListCountries(ctx, langCode) + if err != nil { + return domain.CountriesList{}, false, err + } + if len(list.Countries) == 0 { + list = defaultCountries() + } + return list, hash != 0 && hash == list.Hash, nil +} + +func defaultCountries() domain.CountriesList { + return domain.CountriesList{ + Hash: 1, + Countries: []domain.Country{ + { + ISO2: "US", + DefaultName: "United States", + CountryCodes: []domain.CountryCode{ + {CountryCode: "1", Prefixes: []string{"1"}}, + }, + }, + { + ISO2: "CN", + DefaultName: "China", + CountryCodes: []domain.CountryCode{ + {CountryCode: "86", Prefixes: []string{"86"}}, + }, + }, + }, + } +} diff --git a/internal/app/langpack/parser.go b/internal/app/langpack/parser.go new file mode 100644 index 00000000..f83c0b87 --- /dev/null +++ b/internal/app/langpack/parser.go @@ -0,0 +1,109 @@ +package langpack + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "telesrv/internal/domain" +) + +var tdesktopStringRE = regexp.MustCompile(`(?s)"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)";`) + +// ParseTDesktopFile 解析 TDesktop .strings 文件为 domain 语言包。 +func ParseTDesktopFile(path string) (domain.LangPack, error) { + pack, err := packFromFilename(path) + if err != nil { + return domain.LangPack{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return domain.LangPack{}, fmt.Errorf("read tdesktop langpack %q: %w", path, err) + } + + plain := make([]domain.LangPackString, 0) + plurals := make(map[string]*domain.LangPackString) + pluralOrder := make([]string, 0) + for _, match := range tdesktopStringRE.FindAllStringSubmatch(string(data), -1) { + key := unquoteTDesktop(match[1]) + value := unquoteTDesktop(match[2]) + base, plural := splitPluralKey(key) + if plural == "" { + plain = append(plain, domain.LangPackString{Key: key, Value: value}) + continue + } + item, ok := plurals[base] + if !ok { + plurals[base] = &domain.LangPackString{Key: base, Pluralized: true} + item = plurals[base] + pluralOrder = append(pluralOrder, base) + } + setPluralValue(item, plural, value) + } + + pack.Strings = make([]domain.LangPackString, 0, len(plain)+len(pluralOrder)) + pack.Strings = append(pack.Strings, plain...) + for _, key := range pluralOrder { + pack.Strings = append(pack.Strings, *plurals[key]) + } + return pack, nil +} + +func packFromFilename(path string) (domain.LangPack, error) { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + const prefix = "tdesktop_" + if !strings.HasPrefix(name, prefix) { + return domain.LangPack{}, fmt.Errorf("invalid tdesktop langpack filename %q", filepath.Base(path)) + } + rest := strings.TrimPrefix(name, prefix) + idx := strings.LastIndex(rest, "_v") + if idx <= 0 || idx+2 >= len(rest) { + return domain.LangPack{}, fmt.Errorf("invalid tdesktop langpack filename %q", filepath.Base(path)) + } + version, err := strconv.Atoi(rest[idx+2:]) + if err != nil { + return domain.LangPack{}, fmt.Errorf("parse langpack version %q: %w", rest[idx+2:], err) + } + return domain.LangPack{ + LangPack: "tdesktop", + LangCode: rest[:idx], + Version: version, + }, nil +} + +func splitPluralKey(key string) (base, plural string) { + for _, suffix := range []string{"#zero", "#one", "#two", "#few", "#many", "#other"} { + if strings.HasSuffix(key, suffix) { + return strings.TrimSuffix(key, suffix), strings.TrimPrefix(suffix, "#") + } + } + return key, "" +} + +func setPluralValue(item *domain.LangPackString, plural, value string) { + switch plural { + case "zero": + item.ZeroValue = value + case "one": + item.OneValue = value + case "two": + item.TwoValue = value + case "few": + item.FewValue = value + case "many": + item.ManyValue = value + case "other": + item.OtherValue = value + } +} + +func unquoteTDesktop(s string) string { + v, err := strconv.Unquote(`"` + s + `"`) + if err != nil { + return s + } + return v +} diff --git a/internal/app/langpack/parser_test.go b/internal/app/langpack/parser_test.go new file mode 100644 index 00000000..b1d1c88d --- /dev/null +++ b/internal/app/langpack/parser_test.go @@ -0,0 +1,37 @@ +package langpack + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseTDesktopFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "tdesktop_en_v42.strings") + if err := os.WriteFile(path, []byte(` +"lng_plain" = "Plain value"; +"lng_escape" = "Line\nTwo"; +"lng_items#one" = "{count} item"; +"lng_items#other" = "{count} items"; +`), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + pack, err := ParseTDesktopFile(path) + if err != nil { + t.Fatalf("parse: %v", err) + } + if pack.LangPack != "tdesktop" || pack.LangCode != "en" || pack.Version != 42 { + t.Fatalf("pack meta = %+v", pack) + } + if len(pack.Strings) != 3 { + t.Fatalf("strings count = %d, want 3", len(pack.Strings)) + } + if got := pack.Strings[1].Value; got != "Line\nTwo" { + t.Fatalf("escape value = %q", got) + } + plural := pack.Strings[2] + if !plural.Pluralized || plural.Key != "lng_items" || plural.OneValue == "" || plural.OtherValue == "" { + t.Fatalf("plural string = %+v", plural) + } +} diff --git a/internal/app/langpack/seed.go b/internal/app/langpack/seed.go new file mode 100644 index 00000000..5ab4eadc --- /dev/null +++ b/internal/app/langpack/seed.go @@ -0,0 +1,56 @@ +package langpack + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// SeedDirectory 将导出的 .strings 文件导入 LangPackStore。 +// root 可直接指向 data/langpack,也可指向包含 .strings 的具体平台目录。 +func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) { + if s == nil || s.packs == nil || root == "" { + return 0, nil + } + dir := filepath.Clean(root) + if _, err := os.Stat(dir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + return 0, fmt.Errorf("stat langpack seed dir: %w", err) + } + tdesktopDir := filepath.Join(dir, "tdesktop") + if info, err := os.Stat(tdesktopDir); err == nil && info.IsDir() { + dir = tdesktopDir + } + + entries, err := os.ReadDir(dir) + if err != nil { + return 0, fmt.Errorf("read langpack seed dir: %w", err) + } + seeded := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") { + continue + } + pack, err := ParseTDesktopFile(filepath.Join(dir, entry.Name())) + if err != nil { + return seeded, err + } + existing, err := s.packs.GetPack(ctx, pack.LangPack, pack.LangCode, pack.Version) + if err != nil { + return seeded, err + } + if existing.Version >= pack.Version { + continue + } + if err := s.packs.UpsertPack(ctx, pack); err != nil { + return seeded, err + } + seeded += len(pack.Strings) + } + return seeded, nil +} diff --git a/internal/app/langpack/service.go b/internal/app/langpack/service.go new file mode 100644 index 00000000..1161ed35 --- /dev/null +++ b/internal/app/langpack/service.go @@ -0,0 +1,53 @@ +package langpack + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供客户端语言包查询。 +type Service struct { + packs store.LangPackStore +} + +// NewService 创建 langpack 服务。 +func NewService(packs store.LangPackStore) *Service { + return &Service{packs: packs} +} + +// GetLangPack 返回完整语言包。 +func (s *Service) GetLangPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) { + return s.GetDifference(ctx, langPack, langCode, 0) +} + +// GetDifference 返回从 fromVersion 到当前版本的语言包差异。 +func (s *Service) GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + if s == nil || s.packs == nil { + return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil + } + return s.packs.GetPack(ctx, normalizePack(langPack), normalizeCode(langCode), fromVersion) +} + +// GetStrings 返回指定 key 的语言包字符串。 +func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { + if s == nil || s.packs == nil { + return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil + } + return s.packs.GetStrings(ctx, normalizePack(langPack), normalizeCode(langCode), keys) +} + +func normalizePack(langPack string) string { + if langPack == "" { + return "tdesktop" + } + return langPack +} + +func normalizeCode(langCode string) string { + if langCode == "" { + return "en" + } + return langCode +} diff --git a/internal/app/maintenance/retention.go b/internal/app/maintenance/retention.go new file mode 100644 index 00000000..9c13087c --- /dev/null +++ b/internal/app/maintenance/retention.go @@ -0,0 +1,74 @@ +package maintenance + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +// DispatchOutboxRetentionStore 清理彻底失败(已放弃重试)的 outbox 死任务。 +type DispatchOutboxRetentionStore interface { + DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) +} + +// RetentionWorker 周期性回收存储中的死数据。 +// +// 注意:本 worker 刻意不清理 user_update_events —— pts log 永久保留。原因:TDesktop 不支持 +// 账号级 updates.differenceTooLong(api_updates.cpp 收到该响应只打一行日志,且漏掉 +// setRequesting(false),会永久锁死整个 update 引擎),服务端因此无法让"落后超过保留期"的 +// 客户端整库重置;一旦裁剪 events,落后客户端的 getDifference 会拿到不完整的事件链而静默 +// 丢消息。详见 docs/performance-audit.md 与 docs/compatibility-matrix.md。user_update_events +// 长期膨胀作为已知 todo。 +type RetentionWorker struct { + outbox DispatchOutboxRetentionStore + logger *zap.Logger + retention time.Duration + interval time.Duration + batch int +} + +func NewRetentionWorker(outbox DispatchOutboxRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker { + if logger == nil { + logger = zap.NewNop() + } + if retention <= 0 { + retention = 168 * time.Hour + } + if interval <= 0 { + interval = time.Hour + } + if batch <= 0 { + batch = 10000 + } + return &RetentionWorker{ + outbox: outbox, + logger: logger, + retention: retention, + interval: interval, + batch: batch, + } +} + +func (w *RetentionWorker) Run(ctx context.Context) { + w.runOnce(ctx) + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.runOnce(ctx) + } + } +} + +func (w *RetentionWorker) runOnce(ctx context.Context) { + outboxDeleted, err := w.outbox.DeleteFailed(ctx, w.retention, w.batch) + if err != nil { + w.logger.Warn("清理 failed dispatch_outbox 失败", zap.Error(err)) + } else if outboxDeleted > 0 { + w.logger.Info("清理 failed dispatch_outbox 完成", zap.Int("deleted", outboxDeleted)) + } +} diff --git a/internal/app/messages/doc.go b/internal/app/messages/doc.go new file mode 100644 index 00000000..462e45f2 --- /dev/null +++ b/internal/app/messages/doc.go @@ -0,0 +1,3 @@ +// Package messages 是消息应用服务:发消息、编辑、删除、已读、历史记录。 +// 第一阶段先实现官方系统会话所需的历史、搜索和已读最小闭环;第二阶段扩展私聊发送与推送。 +package messages diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go new file mode 100644 index 00000000..0193d2ef --- /dev/null +++ b/internal/app/messages/service.go @@ -0,0 +1,180 @@ +package messages + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供消息历史、搜索与已读业务。 +type Service struct { + messages store.MessageStore + dialogs store.DialogStore +} + +// NewService 创建 messages 服务。 +func NewService(messages store.MessageStore, dialogs store.DialogStore) *Service { + return &Service{messages: messages, dialogs: dialogs} +} + +// SendPrivateText 发送一条私聊文本消息。 +func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.SendPrivateTextResult{}, nil + } + if req.SenderUserID == 0 { + req.SenderUserID = userID + } + return s.messages.SendPrivateText(ctx, req) +} + +// ForwardPrivateMessages 转发当前账号可见的私聊文本消息。 +func (s *Service) ForwardPrivateMessages(ctx context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.ForwardPrivateMessagesResult{OwnerUserID: userID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.ForwardPrivateMessages(ctx, req) +} + +// GetMessages returns exact owner-visible message boxes in request order. +func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) { + if s == nil || s.messages == nil || userID == 0 || len(ids) == 0 { + return domain.MessageList{}, nil + } + return s.messages.GetByIDs(ctx, userID, ids) +} + +// GetHistory 返回当前账号某个 peer 的历史消息。 +func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + return s.list(ctx, userID, filter) +} + +// Search 返回当前账号消息搜索结果。Query 为空时等价于历史列表过滤。 +func (s *Service) Search(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + return s.list(ctx, userID, filter) +} + +// ReadHistory 将当前账号某个 peer 的 inbox 标记为已读,并为发送方生成 outbox 已读回执。 +func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { + if s == nil || userID == 0 { + return domain.ReadHistoryResult{Peer: req.Peer, MaxID: req.MaxID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if s.messages != nil { + return s.messages.ReadHistory(ctx, req) + } + if s.dialogs != nil { + return s.dialogs.MarkRead(ctx, userID, req.Peer, req.MaxID) + } + return domain.ReadHistoryResult{OwnerUserID: userID, Peer: req.Peer, MaxID: req.MaxID}, nil +} + +// ReadMessageContents checks exact owner-visible private message IDs for content-read sync. +func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { + res := domain.ReadMessageContentsResult{OwnerUserID: userID} + if s == nil || s.messages == nil || userID == 0 { + return res, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if req.OwnerUserID != userID || len(req.IDs) > domain.MaxGetMessageIDs { + return res, domain.ErrMessageIDInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + } + return s.messages.ReadMessageContents(ctx, req) +} + +// GetOutboxReadDate 返回当前账号某条 outgoing 私聊消息被对端读到的时间。 +func (s *Service) GetOutboxReadDate(ctx context.Context, userID int64, req domain.OutboxReadDateRequest) (int, error) { + if s == nil || s.messages == nil || userID == 0 { + return 0, domain.ErrMessageIDInvalid + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.GetOutboxReadDate(ctx, req) +} + +// SetMessageReactions replaces the current user's reactions on a visible private message. +func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + return s.messages.SetMessageReactions(ctx, req) +} + +// GetMessageReactions returns reaction summaries for visible private messages. +func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.PrivateMessageReactionsResult{}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if req.OwnerUserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + } + return s.messages.GetMessageReactions(ctx, req) +} + +// EditMessage 编辑当前账号发出的私聊文本消息。 +func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.EditMessageResult{OwnerUserID: userID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.EditMessage(ctx, req) +} + +// DeleteMessages 删除当前账号视角下的一组消息;revoke 时同步删除对端私聊盒子。 +func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.DeleteMessagesResult{OwnerUserID: userID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.DeleteMessages(ctx, req) +} + +// DeleteHistory 清空当前账号与某个 peer 的历史;revoke 时同步删除对端私聊盒子。 +func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.DeleteMessagesResult{OwnerUserID: userID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.DeleteHistory(ctx, req) +} + +func (s *Service) list(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.MessageList{}, nil + } + return s.messages.ListByUser(ctx, userID, filter) +} diff --git a/internal/app/updates/contiguous_test.go b/internal/app/updates/contiguous_test.go new file mode 100644 index 00000000..0cb82824 --- /dev/null +++ b/internal/app/updates/contiguous_test.go @@ -0,0 +1,154 @@ +package updates + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func seedEvent(t *testing.T, events *memory.UpdateEventStore, userID int64, pts int) { + t.Helper() + if err := events.Append(context.Background(), userID, domain.UpdateEvent{ + Type: domain.UpdateEventNewMessage, + Pts: pts, + PtsCount: 1, + Date: 1700000000 + pts, + Message: domain.Message{ + ID: pts, + OwnerUserID: userID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + }, + }); err != nil { + t.Fatalf("seed event pts=%d: %v", pts, err) + } +} + +// TestGetDifferenceStopsAtHole:getDifference 只返回从 from 起连续的事件,遇在途空洞即截断, +// State.Pts 取最后连续值;补洞后下次拉取可继续,绝不跳过空洞。 +func TestGetDifferenceStopsAtHole(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + userID := int64(1000000001) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + + // pts 1,2,3 已提交,4 在途(缺),5,6 已提交 → 连续只到 3。 + for _, p := range []int{1, 2, 3, 5, 6} { + seedEvent(t, events, userID, p) + } + diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != 3 || diff.State.Pts != 3 { + t.Fatalf("diff = %d events, state.pts %d; want 3 连续事件、止于空洞(pts=3)", len(diff.Events), diff.State.Pts) + } + if diff.Partial { + t.Fatalf("Partial=true,want false(被空洞而非 limit 截断)") + } + + // 补上 pts=4(在途事务提交)后,从 3 继续应拿到 4,5,6。 + seedEvent(t, events, userID, 4) + diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 3}) + if err != nil { + t.Fatalf("GetDifference after fill: %v", err) + } + if len(diff.Events) != 3 || diff.State.Pts != 6 { + t.Fatalf("补洞后 diff = %d events, state.pts %d; want 4,5,6 到 pts=6", len(diff.Events), diff.State.Pts) + } +} + +// TestGetDifferenceSliceOnLimit:连续事件填满 limit 时返回 Partial(=differenceSlice), +// State.Pts 为中间态;客户端据此续拉,最终一页 Partial=false。 +func TestGetDifferenceSliceOnLimit(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + userID := int64(1000000002) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + + total := getDifferenceLimit + 25 + for p := 1; p <= total; p++ { + seedEvent(t, events, userID, p) + } + diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != getDifferenceLimit || !diff.Partial || diff.State.Pts != getDifferenceLimit { + t.Fatalf("第一页 = %d events partial %v state.pts %d; want %d/true/%d", + len(diff.Events), diff.Partial, diff.State.Pts, getDifferenceLimit, getDifferenceLimit) + } + + diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: getDifferenceLimit}) + if err != nil { + t.Fatalf("GetDifference page2: %v", err) + } + if len(diff.Events) != 25 || diff.Partial || diff.State.Pts != total { + t.Fatalf("第二页 = %d events partial %v state.pts %d; want 25/false/%d", + len(diff.Events), diff.Partial, diff.State.Pts, total) + } +} + +// TestGetStateReportsContiguousNotMax:getState 报告最大连续 pts,而非最大已提交 pts, +// 避免首次登录基线越过在途空洞而丢消息。 +func TestGetStateReportsContiguousNotMax(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + userID := int64(1000000003) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + + for _, p := range []int{1, 2, 3, 5, 6} { // 4 在途空洞,最大已提交=6 + seedEvent(t, events, userID, p) + } + st, err := svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState: %v", err) + } + if st.Pts != 3 { + t.Fatalf("GetState.Pts=%d, want 3(最大连续,而非最大已提交 6)", st.Pts) + } +} + +func TestGetStateDoesNotConfirmUnfetchedEvents(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 7 + userID := int64(1000000004) + events := memory.NewUpdateEventStore() + states := memory.NewUpdateStateStore() + svc := NewService(states, events) + + seedEvent(t, events, userID, 1) + if err := states.Save(ctx, authKeyID, userID, domain.UpdateState{Pts: 1, Date: 1700000001}); err != nil { + t.Fatalf("Save state: %v", err) + } + seedEvent(t, events, userID, 2) + + st, err := svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState: %v", err) + } + if st.Pts != 1 { + t.Fatalf("GetState.Pts=%d, want existing confirmed pts=1", st.Pts) + } + + diff, err := svc.GetDifference(ctx, authKeyID, userID, st) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != 1 || diff.Events[0].Pts != 2 || diff.State.Pts != 2 { + t.Fatalf("diff = %+v, want one event at pts=2 and confirmed state pts=2", diff) + } + st, err = svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState after difference: %v", err) + } + if st.Pts != 2 { + t.Fatalf("GetState after difference pts=%d, want confirmed pts=2", st.Pts) + } +} diff --git a/internal/app/updates/doc.go b/internal/app/updates/doc.go new file mode 100644 index 00000000..8c509642 --- /dev/null +++ b/internal/app/updates/doc.go @@ -0,0 +1,3 @@ +// Package updates 是更新状态机与投递:user 级 pts/qts/seq/date、离线差量(updates.getDifference)、 +// 在线推送。第一阶段持久化 auth_key 维度的初始空状态,消息事件队列留第二阶段。 +package updates diff --git a/internal/app/updates/service.go b/internal/app/updates/service.go new file mode 100644 index 00000000..7ef72763 --- /dev/null +++ b/internal/app/updates/service.go @@ -0,0 +1,465 @@ +package updates + +import ( + "context" + "sort" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供 update 状态查询。 +type Service struct { + states store.UpdateStateStore + events store.UpdateEventStore + pts store.PtsAllocator +} + +type dispatchingEventAppender interface { + AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error +} + +// ServiceOption 调整 updates 服务的运行时依赖。 +type ServiceOption func(*Service) + +// WithPtsAllocator 使用外部 pts 分配器推进账号级 pts。 +func WithPtsAllocator(pts store.PtsAllocator) ServiceOption { + return func(s *Service) { + s.pts = pts + } +} + +// NewService 创建 updates 服务。 +func NewService(states store.UpdateStateStore, events store.UpdateEventStore, opts ...ServiceOption) *Service { + s := &Service{states: states} + s.events = events + for _, opt := range opts { + opt(s) + } + return s +} + +// UsesReliableDispatch 表示设置类 update 已写入 transactional outbox,由 outbox worker 投递在线 session。 +func (s *Service) UsesReliableDispatch() bool { + if s == nil || s.events == nil { + return false + } + _, ok := s.events.(dispatchingEventAppender) + return ok +} + +// GetState 返回当前 auth_key + user 维度已确认的 update 状态。 +// user_update_events 是账号级 durable log;auth_key 维度只保存设备已经通过 +// getDifference 确认到的状态,不能在 getState 中直接推进到账号最新水位。 +func (s *Service) GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) { + now := int(time.Now().Unix()) + // 私聊阶段不维护账号级 seq:对外 UpdateState.Seq 恒为 0,客户端仅靠 pts 同步、 + // 跳过 seq gap 检测(推送信封 seq 同样恒 0)。 + if s.states == nil { + current, err := s.currentPts(ctx, userID) + if err != nil { + return domain.UpdateState{}, err + } + return domain.UpdateState{Pts: current, Date: now, Seq: 0}, nil + } + st, found, err := s.states.Get(ctx, authKeyID, userID) + if err != nil { + return domain.UpdateState{}, err + } + if found { + st.Seq = 0 + if st.Date == 0 { + st.Date = now + } + return st, nil + } + current, err := s.currentPts(ctx, userID) + if err != nil { + return domain.UpdateState{}, err + } + st = domain.UpdateState{Pts: current, Date: now, Seq: 0} + if err := s.states.Save(ctx, authKeyID, userID, st); err != nil { + return domain.UpdateState{}, err + } + return st, nil +} + +// CurrentState 返回账号当前最大连续 update 状态,不修改任何设备已确认水位。 +func (s *Service) CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error) { + return s.currentState(ctx, userID) +} + +// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。 +const getDifferenceLimit = 100 + +// GetDifference 返回当前 user 从 from 状态之后的增量事件。 +// +// 对齐 MTProto:只返回从 from.Pts 起「连续」的事件(遇空洞即截断),State.Pts 取最后连续值, +// 绝不让客户端跳过在途空洞而丢消息——空洞由并发发送的在途事务造成,提交/补洞后客户端下次拉取即可补齐。 +// 连续事件填满 limit 时置 Partial(映射 differenceSlice),客户端据返回 State 继续翻页。 +func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error) { + st, err := s.currentState(ctx, userID) + if err != nil { + return domain.UpdateDifference{}, err + } + if s.events == nil || from.Pts >= st.Pts { + if from.Date != 0 { + st.Date = from.Date + } + if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil { + return domain.UpdateDifference{}, err + } + return domain.UpdateDifference{State: st}, nil + } + events, err := s.events.ListAfter(ctx, userID, from.Pts, getDifferenceLimit) + if err != nil { + return domain.UpdateDifference{}, err + } + contiguous := contiguousPrefix(events, from.Pts) + last := from.Pts + if len(contiguous) > 0 { + last = contiguous[len(contiguous)-1].Pts + } + out := st + out.Pts = last + out.Seq = 0 // seq 恒 0,见 GetState 注释 + if len(contiguous) > 0 { + out.Date = contiguous[len(contiguous)-1].Date + } + if err := s.saveConfirmedState(ctx, authKeyID, userID, out); err != nil { + return domain.UpdateDifference{}, err + } + return domain.UpdateDifference{ + State: out, + Events: contiguous, + Partial: len(contiguous) == getDifferenceLimit, + }, nil +} + +func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) { + current, err := s.currentPts(ctx, userID) + if err != nil { + return domain.UpdateState{}, err + } + return domain.UpdateState{ + Pts: current, + Date: int(time.Now().Unix()), + Seq: 0, + }, nil +} + +func (s *Service) saveConfirmedState(ctx context.Context, authKeyID [8]byte, userID int64, st domain.UpdateState) error { + if s.states == nil { + return nil + } + st.Seq = 0 + return s.states.Save(ctx, authKeyID, userID, st) +} + +// contiguousPrefix 返回从 from 起 pts 严格连续(from+1, from+2, ...)的事件前缀。 +// 先按 pts 升序排序以兼容存储返回顺序,遇到空洞即停。 +func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEvent { + if len(events) == 0 { + return nil + } + sorted := make([]domain.UpdateEvent, len(events)) + copy(sorted, events) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Pts < sorted[j].Pts }) + cursor := from + out := make([]domain.UpdateEvent, 0, len(sorted)) + for _, event := range sorted { + ptsCount := event.PtsCount + if ptsCount <= 0 { + ptsCount = 1 + } + if event.Pts != cursor+ptsCount { + break + } + out = append(out, event) + cursor = event.Pts + } + return out +} + +// ClearAuthKey 清理某 auth_key 的设备状态。 +// user_update_events 是账号级 durable log,不能因设备退出登录被删除。 +func (s *Service) ClearAuthKey(ctx context.Context, authKeyID [8]byte) error { + if s.states != nil { + if err := s.states.DeleteAuthKey(ctx, authKeyID); err != nil { + return err + } + } + return nil +} + +// RecordNewMessage 推进 update 状态并追加一条 new_message 事件。 +func (s *Service) RecordNewMessage(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 { + userID = msg.OwnerUserID + } + date := msg.Date + if date == 0 { + date = int(time.Now().Unix()) + } + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventNewMessage, + Date: date, + Message: msg, + PtsCount: 1, + }, false, 0) +} + +// RecordMessageReactions records a durable marker for message reaction changes. +// +// updateMessageReactions has no pts fields in Layer 225, but TDesktop still +// needs getDifference to advance account pts and carry the latest reaction +// aggregate for offline devices. +func (s *Service) RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 { + userID = msg.OwnerUserID + } + date := msg.Date + if date == 0 { + date = int(time.Now().Unix()) + } + return s.recordEventWithoutState(ctx, userID, domain.UpdateEvent{ + Type: domain.UpdateEventMessageReactions, + Date: date, + Message: msg, + Peer: msg.Peer, + PtsCount: 1, + }) +} + +// RecordReadHistory 推进 update 状态并追加一条 read_history_inbox 事件。 +func (s *Service) RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 { + userID = read.OwnerUserID + } + date := int(time.Now().Unix()) + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventReadHistoryInbox, + Date: date, + Peer: read.Peer, + MaxID: read.MaxID, + StillUnreadCount: read.StillUnreadCount, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordContactsReset 记录通讯录视角变化,供离线设备通过 updates.getDifference 触发重拉。 +func (s *Service) RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventContactsReset, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogPinned 记录单个会话置顶状态变化。 +func (s *Service) RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogPinned, + Peer: peer, + Bool: pinned, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordPinnedDialogs 记录置顶会话顺序变化,并把新顺序持久化给 getDifference/outbox。 +func (s *Service) RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventPinnedDialogs, + Peers: append([]domain.Peer(nil), order...), + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogUnreadMark 记录手动未读标记变化。 +func (s *Service) RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogUnreadMark, + Peer: peer, + Bool: unread, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordChannelViewForumAsMessages records a per-account forum presentation state change. +func (s *Service) RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelViewForum, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + Bool: enabled, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordPeerSettings 记录 peer settings 变化。 +func (s *Service) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventPeerSettings, + Peer: peer, + Settings: settings, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogFilter 记录单个 filter 的创建、更新或删除;folder 为 nil 表示删除。 +func (s *Service) RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + var copyFolder *domain.DialogFolder + if folder != nil { + f := *folder + copyFolder = &f + } + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilter, + FilterID: folderID, + DialogFilter: copyFolder, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogFilterOrder 记录 filter 顺序变化。 +func (s *Service) RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilterOrder, + FilterOrder: append([]int(nil), order...), + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogFiltersReload 通知其他设备重新拉取 filter 列表。 +func (s *Service) RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilters, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordFolderPeers 记录归档/还原会话的 folder_id 变化。 +func (s *Service) RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventFolderPeers, + FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...), + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordChannelAvailableMessages records a local channel history clear for multi-device sync. +func (s *Service) RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelAvailable, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + MaxID: availableMinID, + PtsCount: 1, + }, true, excludeSessionID) +} + +func (s *Service) recordEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEventCore(ctx, authKeyID, userID, event, dispatch, excludeSessionID, true) +} + +func (s *Service) recordEventWithoutState(ctx context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEventCore(ctx, [8]byte{}, userID, event, false, 0, false) +} + +func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64, saveState bool) (domain.UpdateEvent, domain.UpdateState, error) { + date := event.Date + if date == 0 { + date = int(time.Now().Unix()) + } + if event.PtsCount == 0 { + event.PtsCount = 1 + } + pts, err := s.nextPtsN(ctx, userID, event.PtsCount) + if err != nil { + return domain.UpdateEvent{}, domain.UpdateState{}, err + } + st := domain.UpdateState{Pts: pts, Date: date, Seq: 0} + event.UserID = userID + event.Pts = st.Pts + event.Date = date + if s.events != nil { + var err error + if dispatch { + if appender, ok := s.events.(dispatchingEventAppender); ok { + err = appender.AppendWithDispatch(ctx, userID, event, authKeyID, excludeSessionID) + } else { + err = s.events.Append(ctx, userID, event) + } + } else { + err = s.events.Append(ctx, userID, event) + } + if err != nil { + if !dispatch { + _ = s.events.Append(ctx, userID, domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventNoop, + Pts: pts, + PtsCount: event.PtsCount, + Date: date, + }) + } + return domain.UpdateEvent{}, domain.UpdateState{}, err + } + } + if saveState && s.states != nil { + if err := s.states.Save(ctx, authKeyID, userID, st); err != nil { + return domain.UpdateEvent{}, domain.UpdateState{}, err + } + } + return event, st, nil +} + +// currentPts 供 GetState 报告「当前 pts」。对齐 MTProto:报告最大连续已提交 pts, +// 而非 Redis allocator 的最大已分配值——后者在并发发送在途时会超前于已提交事件, +// 会让首次登录基线越过在途空洞而丢消息。allocator 仅在无 events 存储时兜底。 +func (s *Service) currentPts(ctx context.Context, userID int64) (int, error) { + if s.events != nil { + return s.events.MaxContiguousPts(ctx, userID) + } + if s.pts != nil { + return s.pts.CurrentPts(ctx, userID) + } + return 0, nil +} + +func (s *Service) nextPts(ctx context.Context, userID int64) (int, error) { + if s.pts != nil { + return s.pts.NextPts(ctx, userID) + } + current, err := s.currentPts(ctx, userID) + if err != nil { + return 0, err + } + return current + 1, nil +} + +func (s *Service) nextPtsN(ctx context.Context, userID int64, count int) (int, error) { + if count <= 0 { + count = 1 + } + if count == 1 { + return s.nextPts(ctx, userID) + } + if s.pts != nil { + if ranges, ok := s.pts.(store.PtsRangeAllocator); ok { + return ranges.NextPtsN(ctx, userID, count) + } + var pts int + var err error + for i := 0; i < count; i++ { + pts, err = s.pts.NextPts(ctx, userID) + if err != nil { + return 0, err + } + } + return pts, nil + } + current, err := s.currentPts(ctx, userID) + if err != nil { + return 0, err + } + return current + count, nil +} diff --git a/internal/app/updates/service_test.go b/internal/app/updates/service_test.go new file mode 100644 index 00000000..051f37a5 --- /dev/null +++ b/internal/app/updates/service_test.go @@ -0,0 +1,252 @@ +package updates + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestRecordNewMessageFeedsGetDifference(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 1 + svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000000, + Body: "Login code: 12345", + } + + event, state, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg) + if err != nil { + t.Fatalf("RecordNewMessage: %v", err) + } + if event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 || state.Seq != 0 { + t.Fatalf("event/state = %+v / %+v, want first pts event with seq=0", event, state) + } + + diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Message.ID != msg.ID { + t.Fatalf("diff = %+v, want recorded login message event and state %+v", diff, state) + } + + diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, state) + if err != nil { + t.Fatalf("GetDifference current: %v", err) + } + if len(diff.Events) != 0 || diff.State != state { + t.Fatalf("current diff = %+v, want empty events and same state", diff) + } +} + +func TestRecordReadHistoryFeedsGetDifference(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 2 + svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} + + ownerUserID := int64(1000000001) + event, state, err := svc.RecordReadHistory(ctx, authKeyID, ownerUserID, domain.ReadHistoryResult{ + OwnerUserID: ownerUserID, + Peer: peer, + MaxID: 10, + Changed: true, + }, 0) + if err != nil { + t.Fatalf("RecordReadHistory: %v", err) + } + if event.Type != domain.UpdateEventReadHistoryInbox || event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 { + t.Fatalf("event/state = %+v / %+v, want read history event with first pts", event, state) + } + + diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Peer != peer || diff.Events[0].MaxID != 10 { + t.Fatalf("diff = %+v, want recorded read history event and state %+v", diff, state) + } +} + +func TestRecordSettingsEventsFeedGetDifference(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 3 + svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + ownerUserID := int64(1000000001) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + + if _, _, err := svc.RecordContactsReset(ctx, authKeyID, ownerUserID, 0); err != nil { + t.Fatalf("RecordContactsReset: %v", err) + } + if _, _, err := svc.RecordDialogPinned(ctx, authKeyID, ownerUserID, peer, true, 0); err != nil { + t.Fatalf("RecordDialogPinned: %v", err) + } + order := []domain.Peer{peer} + if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, order, 0); err != nil { + t.Fatalf("RecordPinnedDialogs: %v", err) + } + if _, _, err := svc.RecordDialogUnreadMark(ctx, authKeyID, ownerUserID, peer, false, 0); err != nil { + t.Fatalf("RecordDialogUnreadMark: %v", err) + } + settings := domain.PeerSettings{ShareContact: true} + stateEvent, state, err := svc.RecordPeerSettings(ctx, authKeyID, ownerUserID, peer, settings, 0) + if err != nil { + t.Fatalf("RecordPeerSettings: %v", err) + } + if stateEvent.Pts != 5 || state.Pts != 5 { + t.Fatalf("last event/state = %+v / %+v, want pts=5", stateEvent, state) + } + + diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State.Pts != 5 || len(diff.Events) != 5 { + t.Fatalf("diff = %+v, want five settings events", diff) + } + wantTypes := []domain.UpdateEventType{ + domain.UpdateEventContactsReset, + domain.UpdateEventDialogPinned, + domain.UpdateEventPinnedDialogs, + domain.UpdateEventDialogUnreadMark, + domain.UpdateEventPeerSettings, + } + for i, typ := range wantTypes { + if diff.Events[i].Type != typ || diff.Events[i].Pts != i+1 || diff.Events[i].PtsCount != 1 { + t.Fatalf("event[%d] = %+v, want type=%s pts=%d pts_count=1", i, diff.Events[i], typ, i+1) + } + } + if diff.Events[1].Peer != peer || !diff.Events[1].Bool { + t.Fatalf("dialog pinned event = %+v, want peer and pinned=true", diff.Events[1]) + } + if diff.Events[3].Peer != peer || diff.Events[3].Bool { + t.Fatalf("unread mark event = %+v, want peer and unread=false", diff.Events[3]) + } + if len(diff.Events[2].Peers) != 1 || diff.Events[2].Peers[0] != peer { + t.Fatalf("pinned dialogs event = %+v, want order peer", diff.Events[2]) + } + if diff.Events[4].Peer != peer || !diff.Events[4].Settings.ShareContact { + t.Fatalf("peer settings event = %+v, want peer and settings", diff.Events[4]) + } +} + +func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 4 + events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()} + svc := NewService(memory.NewUpdateStateStore(), events) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + + event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 42) + if err != nil { + t.Fatalf("RecordDialogPinned: %v", err) + } + if event.Pts != 1 || state.Pts != 1 { + t.Fatalf("event/state = %+v / %+v, want first pts", event, state) + } + if !events.dispatched || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 42 || events.event.Type != domain.UpdateEventDialogPinned || events.event.Peer != peer { + t.Fatalf("dispatch capture = %+v exclude_auth=%v exclude_session=%d dispatched=%v, want dialog_pinned outbox", events.event, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched) + } +} + +func TestClearAuthKeyDropsStateAndEvents(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 8 + states := memory.NewUpdateStateStore() + events := memory.NewUpdateEventStore() + svc := NewService(states, events) + msg := domain.Message{ + ID: 1, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000000, + } + if _, _, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg); err != nil { + t.Fatalf("RecordNewMessage: %v", err) + } + if err := svc.ClearAuthKey(ctx, authKeyID); err != nil { + t.Fatalf("ClearAuthKey: %v", err) + } + diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State.Pts != 1 || len(diff.Events) != 1 { + t.Fatalf("difference after clear = %+v, want durable user events to remain", diff) + } + diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID+1, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference other user: %v", err) + } + if diff.State.Pts != 0 || len(diff.Events) != 0 { + t.Fatalf("difference for other user after clear = %+v, want no cross-account events", diff) + } +} + +func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 9 + userID := int64(1000000001) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + for _, event := range []domain.UpdateEvent{ + {UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, Date: 1700000001, Message: domain.Message{ID: 1, OwnerUserID: userID}}, + {UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 2, PtsCount: 1, Date: 1700000002, Message: domain.Message{ID: 2, OwnerUserID: userID}}, + {UserID: userID, Type: domain.UpdateEventDeleteMessages, Pts: 4, PtsCount: 2, Date: 1700000003, MessageIDs: []int{1, 2}}, + } { + if err := events.Append(ctx, userID, event); err != nil { + t.Fatalf("append event pts=%d: %v", event.Pts, err) + } + } + + state, err := svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState: %v", err) + } + if state.Pts != 4 { + t.Fatalf("state = %+v, want contiguous pts=4 across delete range", state) + } + diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 2}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State.Pts != 4 || len(diff.Events) != 1 { + t.Fatalf("diff = %+v, want one delete event ending at pts=4", diff) + } + got := diff.Events[0] + if got.Type != domain.UpdateEventDeleteMessages || got.Pts != 4 || got.PtsCount != 2 || len(got.MessageIDs) != 2 { + t.Fatalf("delete event = %+v, want pts=4 pts_count=2 ids", got) + } +} + +type captureDispatchAppender struct { + *memory.UpdateEventStore + dispatched bool + userID int64 + event domain.UpdateEvent + excludeAuthKeyID [8]byte + excludeSessionID int64 +} + +func (s *captureDispatchAppender) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error { + s.dispatched = true + s.userID = userID + s.event = event + s.excludeAuthKeyID = excludeAuthKeyID + s.excludeSessionID = excludeSessionID + return s.UpdateEventStore.Append(ctx, userID, event) +} diff --git a/internal/app/users/doc.go b/internal/app/users/doc.go new file mode 100644 index 00000000..d546a099 --- /dev/null +++ b/internal/app/users/doc.go @@ -0,0 +1,3 @@ +// Package users 是用户应用服务:用户资料、用户名、头像、在线状态。 +// 第一阶段先支持 users.getUsers 返回自身。 +package users diff --git a/internal/app/users/service.go b/internal/app/users/service.go new file mode 100644 index 00000000..06f2f7ca --- /dev/null +++ b/internal/app/users/service.go @@ -0,0 +1,313 @@ +package users + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// ErrNotAuthorized 表示当前 auth_key 尚未登录。 +var ErrNotAuthorized = errors.New("not authorized") + +// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。 +type ProfilePhotoProvider interface { + CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) +} + +// Service 提供用户查询。 +type Service struct { + users store.UserStore + photos ProfilePhotoProvider +} + +// Option 调整用户服务可选依赖。 +type Option func(*Service) + +// WithPhotoProvider 注入头像富化能力(缺省则用户不带头像)。 +func WithPhotoProvider(p ProfilePhotoProvider) Option { + return func(s *Service) { s.photos = p } +} + +const ( + minUsernameLen = 5 + maxUsernameLen = 32 + maxProfileNameRunes = 64 + maxProfileAboutRunes = 70 + maxBatchUsers = 1000 +) + +// NewService 创建用户服务。 +func NewService(users store.UserStore, opts ...Option) *Service { + s := &Service{users: users} + for _, opt := range opts { + opt(s) + } + return s +} + +// loadSelf 加载当前用户但不富化头像(供内部校验路径使用,避免无谓的头像查询)。 +func (s *Service) loadSelf(ctx context.Context, userID int64) (domain.User, error) { + if userID == 0 { + return domain.User{}, ErrNotAuthorized + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return domain.User{}, err + } + if !found { + return domain.User{}, ErrNotAuthorized + } + return u, nil +} + +// Self 返回当前登录的用户(带头像)。未登录返回 ErrNotAuthorized。 +func (s *Service) Self(ctx context.Context, userID int64) (domain.User, error) { + u, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + return s.enrichOne(ctx, u), nil +} + +// ByID 返回指定用户。调用方必须已登录;access_hash 校验在 RPC 边界完成。 +func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) { + if currentUserID == 0 { + return domain.User{}, false, ErrNotAuthorized + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return domain.User{}, false, err + } + if !found { + return u, false, nil + } + return s.enrichOne(ctx, u), true, nil +} + +// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。 +func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) { + if currentUserID == 0 { + return nil, ErrNotAuthorized + } + if len(userIDs) == 0 { + return nil, nil + } + ids := make([]int64, 0, len(userIDs)) + seen := make(map[int64]struct{}, len(userIDs)) + for _, id := range userIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + if len(ids) >= maxBatchUsers { + break + } + } + users, err := s.users.ByIDs(ctx, ids) + if err != nil { + return nil, err + } + return s.enrich(ctx, users), nil +} + +// enrich 批量把当前头像富化到用户列表(best-effort:失败不影响用户查询)。 +func (s *Service) enrich(ctx context.Context, users []domain.User) []domain.User { + if s.photos == nil || len(users) == 0 { + return users + } + ids := make([]int64, 0, len(users)) + for _, u := range users { + if u.ID != 0 { + ids = append(ids, u.ID) + } + } + refs, err := s.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids) + if err != nil { + return users + } + for i := range users { + if ref, ok := refs[users[i].ID]; ok { + users[i].PhotoID = ref.PhotoID + users[i].PhotoDCID = ref.DCID + users[i].PhotoStripped = ref.Stripped + } + } + return users +} + +func (s *Service) enrichOne(ctx context.Context, u domain.User) domain.User { + enriched := s.enrich(ctx, []domain.User{u}) + return enriched[0] +} + +// CheckUsername 校验当前用户是否可以占用 username。 +func (s *Service) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return false, err + } + username = normalizeUsername(username) + if !validUsername(username) { + return false, domain.ErrUsernameInvalid + } + u, found, err := s.users.ByUsername(ctx, username) + if err != nil { + return false, err + } + return !found || u.ID == self.ID, nil +} + +// UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。 +func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + username = normalizeUsername(username) + if username != "" { + if !validUsername(username) { + return domain.User{}, domain.ErrUsernameInvalid + } + u, found, err := s.users.ByUsername(ctx, username) + if err != nil { + return domain.User{}, err + } + if found && u.ID != self.ID { + return domain.User{}, domain.ErrUsernameOccupied + } + } + if self.Username == username { + return self, nil + } + u, err := s.users.UpdateUsername(ctx, self.ID, username) + if err != nil { + return domain.User{}, err + } + return u, nil +} + +// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。 +func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + firstName := self.FirstName + lastName := self.LastName + about := self.About + if update.HasFirstName { + firstName = strings.TrimSpace(update.FirstName) + } + if update.HasLastName { + lastName = strings.TrimSpace(update.LastName) + } + if update.HasAbout { + about = strings.TrimSpace(update.About) + } + if firstName == "" || utf8.RuneCountInString(firstName) > maxProfileNameRunes || utf8.RuneCountInString(lastName) > maxProfileNameRunes { + return domain.User{}, domain.ErrFirstNameInvalid + } + if utf8.RuneCountInString(about) > maxProfileAboutRunes { + return domain.User{}, domain.ErrAboutTooLong + } + if firstName == self.FirstName && lastName == self.LastName && about == self.About { + return self, nil + } + return s.users.UpdateProfile(ctx, self.ID, firstName, lastName, about) +} + +// UpdateLastSeen records the latest visible account activity time. +func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error { + if userID == 0 { + return ErrNotAuthorized + } + if lastSeenAt <= 0 { + return nil + } + return s.users.UpdateLastSeen(ctx, userID, lastSeenAt) +} + +// ResolveUsername 解析 username 到用户;调用方必须已登录。 +func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error) { + if _, err := s.loadSelf(ctx, currentUserID); err != nil { + return domain.User{}, false, err + } + username = normalizeUsername(username) + if !validUsername(username) { + return domain.User{}, false, domain.ErrUsernameInvalid + } + u, found, err := s.users.ByUsername(ctx, username) + if err != nil || !found { + return u, found, err + } + return s.enrichOne(ctx, u), true, nil +} + +// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。 +func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) { + if _, err := s.loadSelf(ctx, currentUserID); err != nil { + return domain.User{}, false, err + } + phone = normalizePhone(phone) + if phone == "" { + return domain.User{}, false, domain.ErrPhoneNotOccupied + } + u, found, err := s.users.ByPhone(ctx, phone) + if err != nil || !found { + return u, found, err + } + return s.enrichOne(ctx, u), true, nil +} + +func normalizeUsername(username string) string { + username = strings.TrimSpace(username) + username = strings.TrimPrefix(username, "@") + return strings.TrimSpace(username) +} + +func validUsername(username string) bool { + if len(username) < minUsernameLen || len(username) > maxUsernameLen { + return false + } + for i := 0; i < len(username); i++ { + c := username[i] + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + if i == 0 { + return false + } + case c == '_': + if i == 0 { + return false + } + default: + return false + } + } + return true +} + +func normalizePhone(phone string) string { + phone = strings.TrimSpace(phone) + if phone == "" { + return "" + } + var b strings.Builder + b.Grow(len(phone)) + for _, r := range phone { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/app/users/service_test.go b/internal/app/users/service_test.go new file mode 100644 index 00000000..17a69ae6 --- /dev/null +++ b/internal/app/users/service_test.go @@ -0,0 +1,130 @@ +package users + +import ( + "context" + "errors" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestServiceUsernameLifecycle(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + svc := NewService(store) + + if ok, err := svc.CheckUsername(ctx, owner.ID, "123bad"); err == nil || ok || !errors.Is(err, domain.ErrUsernameInvalid) { + t.Fatalf("CheckUsername invalid = ok %v err %v, want username invalid", ok, err) + } + if ok, err := svc.CheckUsername(ctx, owner.ID, "taken_name"); err != nil || ok { + t.Fatalf("CheckUsername occupied = ok %v err %v, want false/nil", ok, err) + } + if ok, err := svc.CheckUsername(ctx, owner.ID, "owner_name"); err != nil || !ok { + t.Fatalf("CheckUsername available = ok %v err %v, want true/nil", ok, err) + } + + updated, err := svc.UpdateUsername(ctx, owner.ID, "@Owner_Name") + if err != nil { + t.Fatalf("UpdateUsername: %v", err) + } + if updated.Username != "Owner_Name" { + t.Fatalf("updated username = %q, want Owner_Name", updated.Username) + } + resolved, found, err := svc.ResolveUsername(ctx, other.ID, "owner_name") + if err != nil || !found || resolved.ID != owner.ID { + t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err) + } + if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err) + } + phoneUser, found, err := svc.ResolvePhone(ctx, owner.ID, "+1 (555) 000-0002") + if err != nil || !found || phoneUser.ID != other.ID { + t.Fatalf("ResolvePhone = user %+v found %v err %v, want other", phoneUser, found, err) + } + cleared, err := svc.UpdateUsername(ctx, owner.ID, "") + if err != nil { + t.Fatalf("clear username: %v", err) + } + if cleared.Username != "" { + t.Fatalf("cleared username = %q, want empty", cleared.Username) + } +} + +func TestServiceUpdateProfile(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner", LastName: "Old"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + svc := NewService(store) + + updated, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{ + FirstName: " New ", + HasFirstName: true, + LastName: "Name", + HasLastName: true, + About: "bio", + HasAbout: true, + }) + if err != nil { + t.Fatalf("UpdateProfile: %v", err) + } + if updated.FirstName != "New" || updated.LastName != "Name" || updated.About != "bio" { + t.Fatalf("updated profile = %+v, want trimmed names and about", updated) + } + if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{FirstName: " ", HasFirstName: true}); !errors.Is(err, domain.ErrFirstNameInvalid) { + t.Fatalf("empty first name err = %v, want first name invalid", err) + } + if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{About: strings.Repeat("x", 71), HasAbout: true}); !errors.Is(err, domain.ErrAboutTooLong) { + t.Fatalf("long about err = %v, want about too long", err) + } +} + +func TestServiceByIDDoesNotReloadSelf(t *testing.T) { + ctx := context.Background() + base := memory.NewUserStore() + owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + store := &countingUserStore{UserStore: base} + svc := NewService(store) + + got, found, err := svc.ByID(ctx, owner.ID, target.ID) + if err != nil || !found || got.ID != target.ID { + t.Fatalf("ByID = %+v found %v err %v, want target", got, found, err) + } + if store.byIDCalls != 1 { + t.Fatalf("store ByID calls = %d, want 1 target lookup only", store.byIDCalls) + } + if store.lastByID != target.ID { + t.Fatalf("last ByID id = %d, want target %d", store.lastByID, target.ID) + } +} + +type countingUserStore struct { + *memory.UserStore + byIDCalls int + lastByID int64 +} + +func (s *countingUserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) { + s.byIDCalls++ + s.lastByID = id + return s.UserStore.ByID(ctx, id) +} diff --git a/internal/compat/tdesktop/config.go b/internal/compat/tdesktop/config.go new file mode 100644 index 00000000..5925c6b4 --- /dev/null +++ b/internal/compat/tdesktop/config.go @@ -0,0 +1,56 @@ +package tdesktop + +import ( + "time" + + "github.com/gotd/td/tg" +) + +// BuildConfig 构造 help.getConfig 返回的 tg.Config,含自建 DC 的 DCOptions。 +// +// 字段值取 Telegram 常见默认;TDesktop 联调阶段按客户端实际需要微调 +// (记录于 docs/compatibility-matrix.md)。 +func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config { + return &tg.Config{ + Date: int(now.Unix()), + Expires: int(now.Add(time.Hour).Unix()), + TestMode: false, + ThisDC: dc, + DCOptions: []tg.DCOption{ + {ID: dc, IPAddress: ip, Port: port, Static: true}, + }, + ChatSizeMax: 200, + MegagroupSizeMax: 200000, + ForwardedCountMax: 100, + OnlineUpdatePeriodMs: 120000, + OfflineBlurTimeoutMs: 5000, + OfflineIdleTimeoutMs: 30000, + OnlineCloudTimeoutMs: 300000, + NotifyCloudDelayMs: 30000, + NotifyDefaultDelayMs: 1500, + PushChatPeriodMs: 60000, + PushChatLimit: 2, + EditTimeLimit: 172800, + RevokeTimeLimit: 172800, + RevokePmTimeLimit: 172800, + RatingEDecay: 2419200, + StickersRecentLimit: 200, + CallReceiveTimeoutMs: 20000, + CallRingTimeoutMs: 90000, + CallConnectTimeoutMs: 30000, + CallPacketTimeoutMs: 10000, + MeURLPrefix: "https://t.me/", + CaptionLengthMax: 1024, + MessageLengthMax: 4096, + WebfileDCID: dc, + } +} + +// NearestDC 构造 help.getNearestDc 返回值。 +func NearestDC(dc int) *tg.NearestDC { + return &tg.NearestDC{ + Country: "US", + ThisDC: dc, + NearestDC: dc, + } +} diff --git a/internal/compat/tdesktop/defaults.go b/internal/compat/tdesktop/defaults.go new file mode 100644 index 00000000..90fc6012 --- /dev/null +++ b/internal/compat/tdesktop/defaults.go @@ -0,0 +1,96 @@ +package tdesktop + +import ( + "crypto/sha256" + "encoding/binary" + "time" + + "github.com/gotd/td/tg" +) + +const ( + appConfigHash = 4 + countriesListHash = 1 + timezonesListHash = 1 +) + +// AppConfig returns the fallback TDesktop startup app config used when HelpService is absent. +func AppConfig(hash int) tg.HelpAppConfigClass { + if hash == appConfigHash { + return &tg.HelpAppConfigNotModified{} + } + return &tg.HelpAppConfig{ + Hash: appConfigHash, + Config: readMarkAppConfig(), + } +} + +func readMarkAppConfig() *tg.JSONObject { + return &tg.JSONObject{Value: []tg.JSONObjectValue{ + {Key: "chat_read_mark_size_threshold", Value: &tg.JSONNumber{Value: 50}}, + {Key: "chat_read_mark_expire_period", Value: &tg.JSONNumber{Value: 604800}}, + {Key: "pm_read_date_expire_period", Value: &tg.JSONNumber{Value: 604800}}, + {Key: "quote_length_max", Value: &tg.JSONNumber{Value: 1024}}, + {Key: "telegram_antispam_group_size_min", Value: &tg.JSONNumber{Value: 200}}, + {Key: "telegram_antispam_user_id", Value: &tg.JSONString{Value: "5434988373"}}, + }} +} + +// TimezonesList returns a small non-empty timezone set for TDesktop business settings preloading. +func TimezonesList(hash int) tg.HelpTimezonesListClass { + if hash == timezonesListHash { + return &tg.HelpTimezonesListNotModified{} + } + return &tg.HelpTimezonesList{ + Hash: timezonesListHash, + Timezones: []tg.Timezone{ + {ID: "Etc/UTC", Name: "UTC", UtcOffset: 0}, + {ID: "America/New_York", Name: "Eastern Time", UtcOffset: -5 * 60 * 60}, + {ID: "America/Chicago", Name: "Central Time", UtcOffset: -6 * 60 * 60}, + {ID: "America/Denver", Name: "Mountain Time", UtcOffset: -7 * 60 * 60}, + {ID: "America/Los_Angeles", Name: "Pacific Time", UtcOffset: -8 * 60 * 60}, + {ID: "Asia/Shanghai", Name: "China Standard Time", UtcOffset: 8 * 60 * 60}, + }, + } +} + +// CountriesList returns the fallback login country list used when HelpService is absent. +func CountriesList(hash int) tg.HelpCountriesListClass { + if hash == countriesListHash { + return &tg.HelpCountriesListNotModified{} + } + return &tg.HelpCountriesList{ + Hash: countriesListHash, + Countries: []tg.HelpCountry{ + { + ISO2: "US", + DefaultName: "United States", + CountryCodes: []tg.HelpCountryCode{ + {CountryCode: "1", Prefixes: []string{"1"}}, + }, + }, + { + ISO2: "CN", + DefaultName: "China", + CountryCodes: []tg.HelpCountryCode{ + {CountryCode: "86", Prefixes: []string{"86"}}, + }, + }, + }, + } +} + +// LoginToken returns a short-lived placeholder token for TDesktop's QR-login +// screen. First phase only supports phone login, but TDesktop expects this RPC +// to produce an auth.loginToken while the QR screen is visible. +func LoginToken(now time.Time, authKeyID [8]byte, sessionID int64) *tg.AuthLoginToken { + var seed [len(authKeyID) + 8 + 8]byte + copy(seed[:len(authKeyID)], authKeyID[:]) + binary.LittleEndian.PutUint64(seed[len(authKeyID):], uint64(sessionID)) + binary.LittleEndian.PutUint64(seed[len(authKeyID)+8:], uint64(now.UnixNano())) + token := sha256.Sum256(seed[:]) + return &tg.AuthLoginToken{ + Expires: int(now.Add(30 * time.Second).Unix()), + Token: token[:], + } +} diff --git a/internal/compat/tdesktop/doc.go b/internal/compat/tdesktop/doc.go new file mode 100644 index 00000000..800d5ca4 --- /dev/null +++ b/internal/compat/tdesktop/doc.go @@ -0,0 +1,8 @@ +// Package tdesktop 集中存放 Telegram Desktop 兼容逻辑:目标版本/layer 记录、客户端 patch 说明、 +// 启动 RPC 顺序、兼容矩阵辅助、TDesktop 专属 stub 与 feature flags。 +// +// 兼容代码只允许出现在本包,禁止散落进业务 handler。后续 Android/iOS 另开 internal/compat/android|ios。 +// 当前基线:TDesktop dev 9caf32dffc(v6.8.4+15),Layer 225。 +// +// TDesktop 兼容逻辑集中在这里,避免散落到业务服务中。 +package tdesktop diff --git a/internal/compat/tdesktop/startup_stubs.go b/internal/compat/tdesktop/startup_stubs.go new file mode 100644 index 00000000..338dd1e9 --- /dev/null +++ b/internal/compat/tdesktop/startup_stubs.go @@ -0,0 +1,278 @@ +package tdesktop + +import ( + "time" + + "github.com/gotd/td/tg" +) + +// NotifySettings returns default per-peer notification settings for empty first-phase accounts. +func NotifySettings() *tg.PeerNotifySettings { + settings := &tg.PeerNotifySettings{} + settings.SetShowPreviews(true) + settings.SetSilent(false) + settings.SetMuteUntil(0) + settings.SetIosSound(&tg.NotificationSoundDefault{}) + settings.SetAndroidSound(&tg.NotificationSoundDefault{}) + settings.SetOtherSound(&tg.NotificationSoundDefault{}) + settings.SetStoriesMuted(false) + settings.SetStoriesHideSender(false) + settings.SetStoriesIosSound(&tg.NotificationSoundDefault{}) + settings.SetStoriesAndroidSound(&tg.NotificationSoundDefault{}) + settings.SetStoriesOtherSound(&tg.NotificationSoundDefault{}) + return settings +} + +// ReactionsNotifySettings returns conservative defaults for reaction notifications. +func ReactionsNotifySettings() *tg.ReactionsNotifySettings { + return &tg.ReactionsNotifySettings{ + MessagesNotifyFrom: &tg.ReactionNotificationsFromContacts{}, + StoriesNotifyFrom: &tg.ReactionNotificationsFromContacts{}, + PollVotesNotifyFrom: &tg.ReactionNotificationsFromContacts{}, + Sound: &tg.NotificationSoundDefault{}, + ShowPreviews: true, + } +} + +func PrivacyRules(key tg.InputPrivacyKeyClass) *tg.AccountPrivacyRules { + var rule tg.PrivacyRuleClass = &tg.PrivacyValueAllowAll{} + switch key.(type) { + case *tg.InputPrivacyKeyPhoneNumber: + rule = &tg.PrivacyValueDisallowAll{} + case *tg.InputPrivacyKeyBirthday: + rule = &tg.PrivacyValueAllowContacts{} + } + return &tg.AccountPrivacyRules{ + Rules: []tg.PrivacyRuleClass{rule}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +func Authorizations() *tg.AccountAuthorizations { + return &tg.AccountAuthorizations{Authorizations: []tg.Authorization{}} +} + +func Passkeys() *tg.AccountPasskeys { + return &tg.AccountPasskeys{Passkeys: []tg.Passkey{}} +} + +func ContentSettings() *tg.AccountContentSettings { + return &tg.AccountContentSettings{} +} + +func GlobalPrivacySettings() *tg.GlobalPrivacySettings { + return &tg.GlobalPrivacySettings{} +} + +func AccountThemes() tg.AccountThemesClass { + return &tg.AccountThemesNotModified{} +} + +func DefaultEmojiStatuses() tg.AccountEmojiStatusesClass { + return &tg.AccountEmojiStatusesNotModified{} +} + +func CollectibleEmojiStatuses() tg.AccountEmojiStatusesClass { + return &tg.AccountEmojiStatuses{Hash: 0, Statuses: []tg.EmojiStatusClass{}} +} + +func DefaultGroupPhotoEmojis() tg.EmojiListClass { + return &tg.EmojiList{Hash: 0, DocumentID: []int64{}} +} + +func ConnectedBots() *tg.AccountConnectedBots { + return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}} +} + +const availableReactionsHash = 20260602 +const emptyStickerSetHash = 20260602 + +type defaultReaction struct { + emoticon string + title string +} + +var defaultAvailableReactions = []defaultReaction{ + {emoticon: "\U0001f44d", title: "Thumbs Up"}, + {emoticon: "\u2764\ufe0f", title: "Red Heart"}, + {emoticon: "\U0001f602", title: "Face With Tears of Joy"}, + {emoticon: "\U0001f62e", title: "Face With Open Mouth"}, + {emoticon: "\U0001f622", title: "Crying Face"}, + {emoticon: "\U0001f64f", title: "Folded Hands"}, +} + +// DefaultReactionEmoticons returns the TDesktop-compatible emoji reaction catalog order. +func DefaultReactionEmoticons() []string { + out := make([]string, 0, len(defaultAvailableReactions)) + for _, reaction := range defaultAvailableReactions { + out = append(out, reaction.emoticon) + } + return out +} + +func AvailableReactions(hash int) tg.MessagesAvailableReactionsClass { + if hash == availableReactionsHash { + return &tg.MessagesAvailableReactionsNotModified{} + } + reactions := make([]tg.AvailableReaction, 0, len(defaultAvailableReactions)) + for i, reaction := range defaultAvailableReactions { + reactions = append(reactions, availableReaction(reaction, i)) + } + return &tg.MessagesAvailableReactions{ + Hash: availableReactionsHash, + Reactions: reactions, + } +} + +func availableReaction(reaction defaultReaction, index int) tg.AvailableReaction { + const documentBaseID int64 = 900000000000000000 + doc := func(slot int64) tg.DocumentClass { + return &tg.DocumentEmpty{ID: documentBaseID + int64(index)*10 + slot} + } + return tg.AvailableReaction{ + Reaction: reaction.emoticon, + Title: reaction.title, + StaticIcon: doc(1), + AppearAnimation: doc(2), + SelectAnimation: doc(3), + ActivateAnimation: doc(4), + EffectAnimation: doc(5), + } +} + +func Stickers() tg.MessagesStickersClass { + return &tg.MessagesStickersNotModified{} +} + +func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass { + if req != nil && req.Hash == emptyStickerSetHash { + return &tg.MessagesStickerSetNotModified{} + } + title, shortName := "Telesrv Empty Sticker Set", "telesrv_empty" + if req != nil { + switch set := req.Stickerset.(type) { + case *tg.InputStickerSetAnimatedEmoji: + title, shortName = "Animated Emoji", "AnimatedEmojies" + case *tg.InputStickerSetAnimatedEmojiAnimations: + title, shortName = "Emoji Animations", "EmojiAnimations" + case *tg.InputStickerSetEmojiGenericAnimations: + title, shortName = "Emoji Generic Animations", "EmojiGenericAnimations" + case *tg.InputStickerSetDice: + title, shortName = "Dice Animations", "AnimatedDices" + if set.Emoticon != "" { + shortName = "AnimatedDice" + } + case *tg.InputStickerSetPremiumGifts: + title, shortName = "Premium Gifts", "GiftsPremium" + case *tg.InputStickerSetShortName: + if set.ShortName != "" { + title, shortName = set.ShortName, set.ShortName + } + } + } + return &tg.MessagesStickerSet{ + Set: tg.StickerSet{ + ID: 910000000000000000, + AccessHash: 910000000000000001, + Title: title, + ShortName: shortName, + Count: 0, + Hash: emptyStickerSetHash, + }, + Packs: []tg.StickerPack{}, + Keywords: []tg.StickerKeyword{}, + Documents: []tg.DocumentClass{}, + } +} + +func EmojiGroups() tg.MessagesEmojiGroupsClass { + return &tg.MessagesEmojiGroupsNotModified{} +} + +func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass { + return &tg.MessagesEmojiGroups{Hash: 0, Groups: []tg.EmojiGroupClass{}} +} + +func AttachMenuBots() tg.AttachMenuBotsClass { + return &tg.AttachMenuBotsNotModified{} +} + +func QuickReplies() tg.MessagesQuickRepliesClass { + return &tg.MessagesQuickRepliesNotModified{} +} + +func TopPeers() tg.ContactsTopPeersClass { + return &tg.ContactsTopPeersDisabled{} +} + +func BlockedContacts() tg.ContactsBlockedClass { + return &tg.ContactsBlocked{ + Blocked: []tg.PeerBlocked{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +func PeerColors() tg.HelpPeerColorsClass { + return &tg.HelpPeerColorsNotModified{} +} + +func PromoData(now time.Time) tg.HelpPromoDataClass { + return &tg.HelpPromoDataEmpty{Expires: int(now.Add(time.Hour).Unix())} +} + +func TermsOfServiceUpdate(now time.Time) tg.HelpTermsOfServiceUpdateClass { + return &tg.HelpTermsOfServiceUpdateEmpty{Expires: int(now.Add(24 * time.Hour).Unix())} +} + +func PremiumPromo() *tg.HelpPremiumPromo { + return &tg.HelpPremiumPromo{} +} + +func AllStories() tg.StoriesAllStoriesClass { + return &tg.StoriesAllStories{ + State: "", + StealthMode: tg.StoriesStealthMode{}, + } +} + +func StoriesArchive() *tg.StoriesStories { + return &tg.StoriesStories{} +} + +func PinnedStories() *tg.StoriesStories { + return &tg.StoriesStories{} +} + +func StoryAlbums() tg.StoriesAlbumsClass { + return &tg.StoriesAlbums{Hash: 0, Albums: []tg.StoryAlbum{}} +} + +func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass { + return &tg.PaymentsStarGiftActiveAuctionsNotModified{} +} + +func SavedStarGifts() *tg.PaymentsSavedStarGifts { + return &tg.PaymentsSavedStarGifts{ + Gifts: []tg.SavedStarGift{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +func AiComposeTones() tg.AicomposeTonesClass { + return &tg.AicomposeTonesNotModified{} +} + +func WebPage(url string) *tg.MessagesWebPage { + page := &tg.WebPageEmpty{ID: 0} + if url != "" { + page.SetURL(url) + } + return &tg.MessagesWebPage{ + Webpage: page, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} diff --git a/internal/compat/tdesktop/startup_stubs_test.go b/internal/compat/tdesktop/startup_stubs_test.go new file mode 100644 index 00000000..740f198c --- /dev/null +++ b/internal/compat/tdesktop/startup_stubs_test.go @@ -0,0 +1,157 @@ +package tdesktop + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +func TestNotifySettingsDefaultIsAudible(t *testing.T) { + settings := NotifySettings() + if value, ok := settings.GetShowPreviews(); !ok || !value { + t.Fatalf("show_previews = %v ok=%v, want true", value, ok) + } + if value, ok := settings.GetSilent(); !ok || value { + t.Fatalf("silent = %v ok=%v, want explicit false", value, ok) + } + if value, ok := settings.GetMuteUntil(); !ok || value != 0 { + t.Fatalf("mute_until = %d ok=%v, want explicit 0", value, ok) + } + if value, ok := settings.GetOtherSound(); !ok || value == nil { + t.Fatalf("other_sound = %#v ok=%v, want default sound", value, ok) + } +} + +func TestTimezonesListIsNonEmptyAndHashable(t *testing.T) { + got, ok := TimezonesList(0).(*tg.HelpTimezonesList) + if !ok || got.Hash == 0 || len(got.Timezones) == 0 { + t.Fatalf("TimezonesList(0) = %#v, want non-empty modified list", got) + } + if _, ok := TimezonesList(got.Hash).(*tg.HelpTimezonesListNotModified); !ok { + t.Fatalf("TimezonesList(hash) = %#v, want notModified", TimezonesList(got.Hash)) + } +} + +func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) { + got, ok := AvailableReactions(0).(*tg.MessagesAvailableReactions) + if !ok { + t.Fatalf("AvailableReactions(0) = %T, want modified list", got) + } + if got.Hash == 0 { + t.Fatal("AvailableReactions(0).Hash = 0, want stable cache hash") + } + if len(got.Reactions) == 0 { + t.Fatal("AvailableReactions(0).Reactions is empty") + } + for i, reaction := range got.Reactions { + if reaction.Reaction == "" { + t.Fatalf("reaction[%d].Reaction is empty", i) + } + if reaction.Title == "" { + t.Fatalf("reaction[%d].Title is empty", i) + } + if reaction.StaticIcon == nil || + reaction.AppearAnimation == nil || + reaction.SelectAnimation == nil || + reaction.ActivateAnimation == nil || + reaction.EffectAnimation == nil { + t.Fatalf("reaction[%d] has nil required document: %#v", i, reaction) + } + if reaction.Inactive || reaction.Premium { + t.Fatalf("reaction[%d] flags = inactive %v premium %v, want active non-premium", i, reaction.Inactive, reaction.Premium) + } + } + if _, ok := AvailableReactions(got.Hash).(*tg.MessagesAvailableReactionsNotModified); !ok { + t.Fatalf("AvailableReactions(hash) = %#v, want notModified", AvailableReactions(got.Hash)) + } +} + +func TestCollectibleEmojiStatusesIsEmptyModifiedList(t *testing.T) { + got, ok := CollectibleEmojiStatuses().(*tg.AccountEmojiStatuses) + if !ok { + t.Fatalf("CollectibleEmojiStatuses() = %T, want empty modified list", got) + } + if got.Hash != 0 { + t.Fatalf("CollectibleEmojiStatuses().Hash = %d, want 0", got.Hash) + } + if len(got.Statuses) != 0 { + t.Fatalf("CollectibleEmojiStatuses().Statuses length = %d, want 0", len(got.Statuses)) + } +} + +func TestDefaultGroupPhotoEmojisIsEmptyModifiedList(t *testing.T) { + got, ok := DefaultGroupPhotoEmojis().(*tg.EmojiList) + if !ok { + t.Fatalf("DefaultGroupPhotoEmojis() = %T, want empty modified list", got) + } + if got.Hash != 0 { + t.Fatalf("DefaultGroupPhotoEmojis().Hash = %d, want 0", got.Hash) + } + if len(got.DocumentID) != 0 { + t.Fatalf("DefaultGroupPhotoEmojis().DocumentID length = %d, want 0", len(got.DocumentID)) + } +} + +func TestEmojiProfilePhotoGroupsIsEmptyModifiedList(t *testing.T) { + got, ok := EmojiProfilePhotoGroups().(*tg.MessagesEmojiGroups) + if !ok { + t.Fatalf("EmojiProfilePhotoGroups() = %T, want empty modified list", got) + } + if got.Hash != 0 { + t.Fatalf("EmojiProfilePhotoGroups().Hash = %d, want 0", got.Hash) + } + if len(got.Groups) != 0 { + t.Fatalf("EmojiProfilePhotoGroups().Groups length = %d, want 0", len(got.Groups)) + } +} + +func TestConnectedBotsIsEmptyList(t *testing.T) { + got := ConnectedBots() + if got == nil { + t.Fatal("ConnectedBots() = nil") + } + if len(got.ConnectedBots) != 0 || len(got.Users) != 0 { + t.Fatalf("ConnectedBots() = bots %d users %d, want empty vectors", len(got.ConnectedBots), len(got.Users)) + } +} + +func TestStoryAlbumsIsEmptyModifiedList(t *testing.T) { + got, ok := StoryAlbums().(*tg.StoriesAlbums) + if !ok { + t.Fatalf("StoryAlbums() = %T, want empty modified list", got) + } + if got.Hash != 0 { + t.Fatalf("StoryAlbums().Hash = %d, want 0", got.Hash) + } + if len(got.Albums) != 0 { + t.Fatalf("StoryAlbums().Albums length = %d, want 0", len(got.Albums)) + } +} + +func TestStickerSetReturnsEmptyModifiedSetForColdRequest(t *testing.T) { + got, ok := StickerSet(&tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetEmojiGenericAnimations{}, + Hash: 0, + }).(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("StickerSet(hash=0) = %T, want modified empty set", got) + } + if got.Set.Hash == 0 { + t.Fatal("StickerSet(hash=0).Set.Hash = 0, want stable cache hash") + } + if got.Set.ShortName != "EmojiGenericAnimations" { + t.Fatalf("StickerSet(hash=0).Set.ShortName = %q, want EmojiGenericAnimations", got.Set.ShortName) + } + if len(got.Packs) != 0 || len(got.Keywords) != 0 || len(got.Documents) != 0 { + t.Fatalf("StickerSet(hash=0) = packs %d keywords %d documents %d, want empty vectors", len(got.Packs), len(got.Keywords), len(got.Documents)) + } + if _, ok := StickerSet(&tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetEmojiGenericAnimations{}, + Hash: got.Set.Hash, + }).(*tg.MessagesStickerSetNotModified); !ok { + t.Fatalf("StickerSet(hash) = %#v, want notModified", StickerSet(&tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetEmojiGenericAnimations{}, + Hash: got.Set.Hash, + })) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..2de86039 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,127 @@ +// Package config 负责 telesrv 运行配置的加载与校验。 +package config + +import ( + "os" + "strconv" + "time" +) + +// Config 是 telesrv 的运行配置。 +type Config struct { + // ListenAddr 是 MTProto TCP 监听地址。 + // 需与 TDesktop patch 指向的自建 DC 地址/端口一致(记录于 docs/tdesktop-patch-notes.md)。 + ListenAddr string + // AdvertiseIP 是写入 help.getConfig DCOptions 的对外可达 IP(客户端据此连接本 DC)。 + AdvertiseIP string + // RSAKeyPath 是 server RSA 私钥的 PEM 路径;不存在时自动生成。 + RSAKeyPath string + // DC 是本 server 的 DC ID。 + DC int + + // PostgresDSN 是业务数据(auth_key / user / authorization 等)持久化的 PostgreSQL 连接串。 + // 依赖由 deploy/docker-compose.yml 启动;职责划分见 docs/persistence-layer.md。 + PostgresDSN string + // PostgresMaxConns 是 pgxpool 最大连接数。<=0 用 pgx 默认(max(4, NumCPU),生产偏小)。 + // 需覆盖发送事务 + outbox worker 并发 + RPC 读,过小会在高并发下排队(表现为尾延迟突刺)。 + PostgresMaxConns int + // PostgresMinConns 是启动时预热的 pgxpool 连接数,降低 TDesktop 冷启动并发 RPC 的建连等待。 + PostgresMinConns int + // RedisAddr 是高频易失态(验证码、限流计数、update 队列)的 Redis 地址。 + RedisAddr string + // RedisPassword 是 Redis 密码;开发默认空。 + RedisPassword string + // RedisDB 是 Redis 逻辑库编号。 + RedisDB int + + // DevAuthCode 是开发固定验证码;生产短信/风控不在当前范围内。 + DevAuthCode string + // LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。 + LangPackSeedDir string + // BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。 + BlobDir string + // StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob)。 + StickerSeedDir string + // StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。 + StickerSeedMaxSets int + + // OutboxWorkers 是并发 claim 的 outbox worker 数。worker 间用 FOR UPDATE SKIP LOCKED 互不重叠。 + // 开发默认偏保守,避免 TDesktop 启动风暴下多个 worker 同扫分区父表触发 PG lock/shared-memory 压力; + // 压测或生产可按硬件用 TELESRV_OUTBOX_WORKERS 调高。 + OutboxWorkers int + // OutboxBatch 是 transactional outbox worker 每次 claim 的最大条数。 + // 调大提升吞吐、增大单批 PG/推送压力;调小降低延迟抖动。配套压测见 docs/message-module.md。 + OutboxBatch int + // OutboxInterval 是 outbox worker 两次 claim 之间的轮询间隔。 + OutboxInterval time.Duration + // OutboxLeaseTimeout 是 'dispatching' 行被判定为租约过期、允许其它 worker 重新 claim 的时长。 + // 取值需大于单批投递耗时,否则会重复推送;过大则 worker 崩溃后积压恢复变慢。 + OutboxLeaseTimeout time.Duration + // OutboundPushTimeout 是 best-effort updates 推送等待 outbound 队列接受的最长时间。 + OutboundPushTimeout time.Duration + // UpdateEventRetention 是 durable update log 保留期;只清理已被水位/state 覆盖的事件。 + UpdateEventRetention time.Duration + // RetentionInterval 是 retention worker 的运行间隔。 + RetentionInterval time.Duration + // RetentionBatch 是单次 retention 最多删除的行数。 + RetentionBatch int +} + +// Load 从环境变量读取配置并填充默认值。第一阶段不做严格校验。 +func Load() (Config, error) { + cfg := Config{ + ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), + AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"), + RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"), + DC: envIntOr("TELESRV_DC", 2), + + PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable"), + PostgresMaxConns: envIntOr("TELESRV_POSTGRES_MAX_CONNS", 50), + PostgresMinConns: envIntOr("TELESRV_POSTGRES_MIN_CONNS", 16), + RedisAddr: envOr("TELESRV_REDIS_ADDR", "localhost:6399"), + RedisPassword: envOr("TELESRV_REDIS_PASSWORD", ""), + RedisDB: envIntOr("TELESRV_REDIS_DB", 0), + + DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"), + LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"), + BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"), + StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"), + StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 40), + + OutboxWorkers: envIntOr("TELESRV_OUTBOX_WORKERS", 2), + OutboxBatch: envIntOr("TELESRV_OUTBOX_BATCH", 100), + OutboxInterval: envDurationOr("TELESRV_OUTBOX_INTERVAL", 200*time.Millisecond), + OutboxLeaseTimeout: envDurationOr("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second), + OutboundPushTimeout: envDurationOr("TELESRV_OUTBOUND_PUSH_TIMEOUT", 200*time.Millisecond), + UpdateEventRetention: envDurationOr("TELESRV_UPDATE_EVENT_RETENTION", 168*time.Hour), + RetentionInterval: envDurationOr("TELESRV_RETENTION_INTERVAL", time.Hour), + RetentionBatch: envIntOr("TELESRV_RETENTION_BATCH", 10000), + } + return cfg, nil +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envIntOr(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +// envDurationOr 读取 time.ParseDuration 格式(如 "200ms"、"30s")的时长配置;解析失败回退默认值。 +func envDurationOr(key string, def time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return def +} diff --git a/internal/domain/account.go b/internal/domain/account.go new file mode 100644 index 00000000..4217e0db --- /dev/null +++ b/internal/domain/account.go @@ -0,0 +1,65 @@ +package domain + +// PasswordSettings 是账号 2FA/SRP 配置。第一阶段默认 HasPassword=false。 +type PasswordSettings struct { + HasRecovery bool + HasSecureValues bool + HasPassword bool + Hint string + EmailUnconfirmedPattern string + LoginEmailPattern string + SecureRandom []byte +} + +// ReactionNotifyFrom stores one account-level reaction notification scope. +type ReactionNotifyFrom string + +const ( + ReactionNotifyFromNone ReactionNotifyFrom = "none" + ReactionNotifyFromContacts ReactionNotifyFrom = "contacts" + ReactionNotifyFromAll ReactionNotifyFrom = "all" +) + +// ReactionsNotifySettings stores the account reaction notification settings +// consumed by account.get/setReactionsNotifySettings. +type ReactionsNotifySettings struct { + MessagesFrom ReactionNotifyFrom + StoriesFrom ReactionNotifyFrom + PollVotesFrom ReactionNotifyFrom + ShowPreviews bool +} + +// PaidReactionPrivacyKind stores the account default paid reaction privacy. +type PaidReactionPrivacyKind string + +const ( + PaidReactionPrivacyDefault PaidReactionPrivacyKind = "default" + PaidReactionPrivacyAnonymous PaidReactionPrivacyKind = "anonymous" + PaidReactionPrivacyPeer PaidReactionPrivacyKind = "peer" +) + +// PaidReactionPrivacy is the domain representation of tg.PaidReactionPrivacy. +type PaidReactionPrivacy struct { + Kind PaidReactionPrivacyKind + Peer *Peer +} + +// AccountReactionSettings groups account-level reaction preferences. +type AccountReactionSettings struct { + Notify ReactionsNotifySettings + DefaultReaction MessageReaction + PaidPrivacy PaidReactionPrivacy +} + +func DefaultAccountReactionSettings() AccountReactionSettings { + return AccountReactionSettings{ + Notify: ReactionsNotifySettings{ + MessagesFrom: ReactionNotifyFromContacts, + StoriesFrom: ReactionNotifyFromContacts, + PollVotesFrom: ReactionNotifyFromContacts, + ShowPreviews: true, + }, + DefaultReaction: MessageReaction{Type: MessageReactionEmoji, Emoticon: "👍"}, + PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault}, + } +} diff --git a/internal/domain/authorization.go b/internal/domain/authorization.go new file mode 100644 index 00000000..1c8b4c93 --- /dev/null +++ b/internal/domain/authorization.go @@ -0,0 +1,15 @@ +package domain + +// Authorization 是一条设备授权:auth_key 与 user 的绑定 + initConnection 设备信息。 +// auth_key 是协议产物、授权是业务产物,故独立于 store.AuthKeyData。 +type Authorization struct { + AuthKeyID [8]byte // 协议原生 auth_key_id;store 边界按小端转 int64 + UserID int64 + Layer int + DeviceModel string + Platform string + SystemVersion string + APIID int + AppVersion string + IP string +} diff --git a/internal/domain/channel.go b/internal/domain/channel.go new file mode 100644 index 00000000..55548605 --- /dev/null +++ b/internal/domain/channel.go @@ -0,0 +1,1546 @@ +package domain + +const ( + // MaxChannelDifferenceLimit limits a single updates.getChannelDifference page. + MaxChannelDifferenceLimit = 100 + // MaxChannelDifferenceTooLongMessages limits the latest message snapshot returned by channelDifferenceTooLong. + MaxChannelDifferenceTooLongMessages = 100 + // MaxChannelParticipantsLimit limits a single participants page. + MaxChannelParticipantsLimit = 200 + // MaxChannelParticipantsOffset bounds channels.getParticipants deep OFFSET work. + MaxChannelParticipantsOffset = 10000 + // MaxChannelParticipantsQueryLength bounds member search strings before LIKE scans. + MaxChannelParticipantsQueryLength = 128 + // MaxChannelInviteUsers limits a single inviteToChannel/createChat member batch. + MaxChannelInviteUsers = 200 + // MaxChannelRealtimeFanout caps best-effort realtime channel pushes until a presence/subscription index exists. + MaxChannelRealtimeFanout = 500 + // MaxSynchronousChannelDialogFanout bounds per-member channel_dialogs writes in the send transaction. + MaxSynchronousChannelDialogFanout = 1000 + // MaxChannelTypingFanout caps transient typing fanout. + MaxChannelTypingFanout = MaxChannelRealtimeFanout + // MaxChannelAdminRankLength limits custom admin rank text. + MaxChannelAdminRankLength = 32 + // MaxChannelInviteTitleLength limits invite link admin-only labels. + MaxChannelInviteTitleLength = 32 + // MaxChannelInviteListLimit limits invite management pages. + MaxChannelInviteListLimit = 100 + // MaxChannelHideJoinRequests limits one hideAllChatJoinRequests batch. + MaxChannelHideJoinRequests = 1000 + // MaxChannelPendingJoinRecentRequesters limits recent_requesters carried in pending join request updates. + MaxChannelPendingJoinRecentRequesters = 5 + // MaxAdminedPublicChannels limits channels.getAdminedPublicChannels payload size. + MaxAdminedPublicChannels = 200 + // MaxChannelAdminLogLimit limits one channels.getAdminLog page. + MaxChannelAdminLogLimit = 100 + // MaxChannelAdminLogAdmins limits admin filter fan-in. + MaxChannelAdminLogAdmins = 100 + // MaxChannelAdminLogQueryLength limits free-text admin log search. + MaxChannelAdminLogQueryLength = 128 + // MaxChannelHistoryQueryLength limits channel history/search query strings before LIKE scans. + MaxChannelHistoryQueryLength = 256 + // MaxChannelSearchPostsLimit limits one global public posts search page. + MaxChannelSearchPostsLimit = 50 + // MaxChannelGlobalSearchLimit limits one messages.searchGlobal channel page. + MaxChannelGlobalSearchLimit = 50 + // MaxPublicChannelSearchLimit limits one contacts.search public peer lookup page. + MaxPublicChannelSearchLimit = 50 + // MaxPublicChannelSearchQueryLength bounds public channel peer search strings. + MaxPublicChannelSearchQueryLength = 256 + // MaxChannelReactionItems limits one channel reaction policy payload. + MaxChannelReactionItems = 64 + // MaxChannelReactionEmoticonLength limits one emoji reaction string. + MaxChannelReactionEmoticonLength = 32 + // MaxChannelMessageReactionsPerUser limits one user's reactions on one channel message. + MaxChannelMessageReactionsPerUser = 16 + // MaxChannelMessageReactionRecent limits recent reactors embedded in messageReactions. + MaxChannelMessageReactionRecent = 3 + // MaxChannelMessageReactionListLimit limits one messages.getMessageReactionsList page. + MaxChannelMessageReactionListLimit = 100 + // MaxRecentMessageReactions limits one messages.getRecentReactions page. + MaxRecentMessageReactions = 100 + // MaxTopMessageReactions limits one messages.getTopReactions page. + MaxTopMessageReactions = 100 + // MaxSavedReactionTags limits one messages.getSavedReactionTags page. + MaxSavedReactionTags = 100 + // MaxChannelReadParticipants limits per-message read receipt fan-in. + MaxChannelReadParticipants = 50 + // ChannelReadMarkExpirePeriod matches TDesktop's default chat_read_mark_expire_period. + ChannelReadMarkExpirePeriod = 7 * 24 * 60 * 60 + // MaxChannelReadOutboxFanout caps senders notified by one channel readHistory. + MaxChannelReadOutboxFanout = 128 + // MaxChannelReadOutboxScanMessages bounds the read delta scanned for sender receipts. + MaxChannelReadOutboxScanMessages = 1000 + // MaxCommonChannelsLimit limits one messages.getCommonChats page. + MaxCommonChannelsLimit = 100 + // MaxLeftChannelsLimit limits one channels.getLeftChannels page. + MaxLeftChannelsLimit = 100 + // MaxLeftChannelsOffset bounds the legacy count-offset export API. + MaxLeftChannelsOffset = 10000 + // MaxInactiveChannelsLimit limits one channels.getInactiveChannels payload. + MaxInactiveChannelsLimit = 100 + // DefaultChannelRecommendationsLimit matches Telegram Desktop's default similar channel preview cap. + DefaultChannelRecommendationsLimit = 10 + // MaxChannelRecommendationsLimit bounds one channels.getChannelRecommendations payload. + MaxChannelRecommendationsLimit = 100 + // MaxDiscussionGroupsLimit limits the channels.getGroupsForDiscussion payload. + MaxDiscussionGroupsLimit = 200 + // MaxChannelRepliesLimit limits one messages.getReplies page. + MaxChannelRepliesLimit = 100 + // MaxChannelUnreadMentionsLimit limits one messages.getUnreadMentions page. + MaxChannelUnreadMentionsLimit = 100 + // MaxChannelUnreadReactionsLimit limits one messages.getUnreadReactions page. + MaxChannelUnreadReactionsLimit = 100 + // MaxChannelForumTopicsLimit limits one messages.getForumTopics page. + MaxChannelForumTopicsLimit = 100 + // MaxChannelForumTopicIDs limits one messages.getForumTopicsByID vector. + MaxChannelForumTopicIDs = 100 + // MaxChannelForumTopicTitleLength bounds topic titles before persistence. + MaxChannelForumTopicTitleLength = 128 + // DefaultForumTopicIconColor is Telegram Desktop's General-topic fallback blue. + DefaultForumTopicIconColor = 0x6FB9F0 + // MaxChannelReadMentionsBatch limits one messages.readMentions clearing batch. + MaxChannelReadMentionsBatch = 1000 + // MaxChannelReadReactionsBatch limits one messages.readReactions clearing batch. + MaxChannelReadReactionsBatch = 1000 + // MaxDeleteParticipantReactionsBatch limits one moderation clear batch. + MaxDeleteParticipantReactionsBatch = 1000 + // MaxChannelMentionRecipients limits mention state writes for one channel message. + MaxChannelMentionRecipients = 100 +) + +// ValidChannelSlowModeSeconds reports whether seconds is accepted by Telegram clients. +func ValidChannelSlowModeSeconds(seconds int) bool { + switch seconds { + case 0, 10, 30, 60, 300, 900, 3600: + return true + default: + return false + } +} + +// ChannelMemberRole describes a member's role in a channel or megagroup. +type ChannelMemberRole string + +const ( + ChannelRoleCreator ChannelMemberRole = "creator" + ChannelRoleAdmin ChannelMemberRole = "admin" + ChannelRoleMember ChannelMemberRole = "member" +) + +// ChannelMemberStatus describes current membership state. +type ChannelMemberStatus string + +const ( + ChannelMemberActive ChannelMemberStatus = "active" + ChannelMemberLeft ChannelMemberStatus = "left" + ChannelMemberKicked ChannelMemberStatus = "kicked" + ChannelMemberBanned ChannelMemberStatus = "banned" +) + +// ChannelAdminRights is a domain-only representation of Telegram admin rights. +type ChannelAdminRights struct { + ChangeInfo bool + PostMessages bool + EditMessages bool + DeleteMessages bool + BanUsers bool + InviteUsers bool + PinMessages bool + AddAdmins bool + ManageCall bool + Anonymous bool +} + +// ChannelBannedRights is a domain-only representation of Telegram banned rights. +type ChannelBannedRights struct { + ViewMessages bool + SendMessages bool + SendMedia bool + SendStickers bool + SendGifs bool + SendGames bool + SendInline bool + EmbedLinks bool + SendPolls bool + ChangeInfo bool + InviteUsers bool + PinMessages bool + UntilDate int +} + +// ChannelReactionPolicyType describes which reactions are allowed in a channel. +type ChannelReactionPolicyType string + +const ( + ChannelReactionPolicyDefault ChannelReactionPolicyType = "" + ChannelReactionPolicyNone ChannelReactionPolicyType = "none" + ChannelReactionPolicyAll ChannelReactionPolicyType = "all" + ChannelReactionPolicySome ChannelReactionPolicyType = "some" +) + +// ChannelReactionPolicy is a domain-only representation of chatReactions*. +type ChannelReactionPolicy struct { + Type ChannelReactionPolicyType + AllowCustom bool + Emoticons []string + CustomEmojiIDs []int64 + Limit int + PaidEnabled bool +} + +// ChannelPeerColor is a domain-only representation of PeerColor. +type ChannelPeerColor struct { + HasColor bool + Color int + BackgroundEmojiID int64 +} + +// Empty reports whether the peer has no explicit color state. +func (c ChannelPeerColor) Empty() bool { + return !c.HasColor && c.BackgroundEmojiID == 0 +} + +// ChannelEmojiStatus is a domain-only representation of a regular EmojiStatus. +type ChannelEmojiStatus struct { + DocumentID int64 + Until int +} + +// Empty reports whether no emoji status is set. +func (s ChannelEmojiStatus) Empty() bool { + return s.DocumentID == 0 +} + +// Channel is a Telegram channel/supergroup entity. +type Channel struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Title string + About string + Username string + Broadcast bool + Megagroup bool + Forum bool + ForumTabs bool + Autotranslation bool + RestrictedSponsored bool + BroadcastMessagesAllowed bool + SendPaidMessagesStars int64 + NoForwards bool + JoinToSend bool + JoinRequest bool + Signatures bool + PreHistoryHidden bool + ParticipantsHidden bool + AntiSpam bool + LinkedChatID int64 + SlowmodeSeconds int + DefaultBannedRights ChannelBannedRights + ReactionPolicy ChannelReactionPolicy + Color ChannelPeerColor + ProfileColor ChannelPeerColor + EmojiStatus ChannelEmojiStatus + ParticipantsCount int + AdminsCount int + KickedCount int + BannedCount int + TopMessageID int + PinnedMessageID int + Pts int + TTLPeriod int + Date int + Deleted bool + // 当前头像(反范式存于 channels 表)。PhotoID==0 表示无头像。 + PhotoID int64 + PhotoDCID int + PhotoStripped []byte +} + +// ChannelMember is one user's channel membership and read state. +type ChannelMember struct { + ChannelID int64 + UserID int64 + InviterUserID int64 + Role ChannelMemberRole + Status ChannelMemberStatus + JoinedAt int + LeftAt int + AdminRights ChannelAdminRights + BannedRights ChannelBannedRights + Rank string + AvailableMinID int + AvailableMinPts int + ReadInboxMaxID int + ReadInboxDate int + ReadOutboxMaxID int + UnreadMark bool + SlowmodeLastSendDate int +} + +// ChannelDialog is the current user's owner-view dialog state for a channel. +type ChannelDialog struct { + UserID int64 + ChannelID int64 + FolderID int + TopMessageID int + TopMessageDate int + ReadInboxMaxID int + ReadOutboxMaxID int + UnreadCount int + UnreadMentions int + UnreadReactions int + Pinned bool + PinnedOrder int + UnreadMark bool + ViewForumAsMessages bool + DefaultSendAs *Peer +} + +// ChannelMessageActionType identifies service messages generated by channel operations. +type ChannelMessageActionType string + +const ( + ChannelActionNone ChannelMessageActionType = "" + ChannelActionCreate ChannelMessageActionType = "channel_create" + ChannelActionChatAddUser ChannelMessageActionType = "chat_add_user" + ChannelActionChatDelete ChannelMessageActionType = "chat_delete_user" + ChannelActionChatJoined ChannelMessageActionType = "chat_joined" + ChannelActionEditTitle ChannelMessageActionType = "chat_edit_title" + ChannelActionTopicCreate ChannelMessageActionType = "topic_create" + ChannelActionTopicEdit ChannelMessageActionType = "topic_edit" +) + +// ChannelMessageAction describes a service action without depending on tg.*. +type ChannelMessageAction struct { + Type ChannelMessageActionType + Title string + IconColor int + IconEmojiID int64 + IconEmojiIDSet bool + TitleMissing bool + Closed *bool + Hidden *bool + UserIDs []int64 +} + +// ChannelMessage is a single stored message in a channel/supergroup. +type ChannelMessage struct { + ChannelID int64 + ID int + RandomID int64 + SenderUserID int64 + From Peer + SendAs *Peer + Date int + EditDate int + Post bool + Silent bool + NoForwards bool + Body string + Entities []MessageEntity + ReplyTo *MessageReply + Forward *MessageForward + Discussion *ChannelDiscussionRef + Replies *ChannelMessageReplies + Reactions *ChannelMessageReactions + Action *ChannelMessageAction + Media *MessageMedia + Pts int + Deleted bool +} + +// MessageReactionType identifies one stored reaction constructor without depending on TL types. +type MessageReactionType string + +const ( + MessageReactionEmoji MessageReactionType = "emoji" +) + +// MessageReaction describes one supported message reaction value. +type MessageReaction struct { + Type MessageReactionType + Emoticon string +} + +// ChannelMessageReactionCount is an aggregated reaction counter for one message. +type ChannelMessageReactionCount struct { + Reaction MessageReaction + Count int + ChosenOrder int +} + +// ChannelMessagePeerReaction describes one peer's reaction entry. +type ChannelMessagePeerReaction struct { + ChannelID int64 + MessageID int + SenderUserID int64 + UserID int64 + Reaction MessageReaction + Big bool + Unread bool + My bool + ChosenOrder int + Date int +} + +// ChannelMessageReactions is the read model carried by channel messages and reaction updates. +type ChannelMessageReactions struct { + CanSeeList bool + Results []ChannelMessageReactionCount + Recent []ChannelMessagePeerReaction +} + +// SetChannelMessageReactionsRequest replaces the current user's reactions for one message. +type SetChannelMessageReactionsRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Reactions []MessageReaction + Big bool + AddToRecent bool + Date int +} + +// ChannelMessageReactionsResult describes one reaction update. +type ChannelMessageReactionsResult struct { + Channel Channel + Message ChannelMessage + Messages []ChannelMessage + Reactions ChannelMessageReactions + Recipients []int64 +} + +// DeleteChannelParticipantReactionRequest removes one participant reaction on one message. +type DeleteChannelParticipantReactionRequest struct { + UserID int64 + ChannelID int64 + MessageID int + ParticipantUserID int64 + Date int +} + +// DeleteChannelParticipantReactionsRequest removes a bounded page of one participant's reactions. +type DeleteChannelParticipantReactionsRequest struct { + UserID int64 + ChannelID int64 + ParticipantUserID int64 + Limit int + Date int +} + +// DeleteChannelParticipantReactionsResult describes moderation reaction clears. +type DeleteChannelParticipantReactionsResult struct { + Channel Channel + Messages []ChannelMessage + Recipients []int64 + Deleted int +} + +// ChannelMessageReactionsRequest fetches reaction summaries for exact message ids. +type ChannelMessageReactionsRequest struct { + UserID int64 + ChannelID int64 + IDs []int +} + +// ChannelMessageReactionsListRequest pages per-peer reactions for one message. +type ChannelMessageReactionsListRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Reaction *MessageReaction + Offset string + Limit int +} + +// ChannelMessageReactionsList is a bounded page for messages.getMessageReactionsList. +type ChannelMessageReactionsList struct { + Channel Channel + Message ChannelMessage + Count int + Reactions []ChannelMessagePeerReaction + NextOffset string +} + +// RecentMessageReaction is one account-level recently used message reaction. +type RecentMessageReaction struct { + UserID int64 + Reaction MessageReaction + Date int +} + +// TopMessageReaction is one account-level frequently used message reaction. +type TopMessageReaction struct { + UserID int64 + Reaction MessageReaction + Count int + Date int +} + +// SavedReactionTag is one account-level custom title for a saved-message reaction tag. +type SavedReactionTag struct { + UserID int64 + Reaction MessageReaction + Title string + Count int +} + +// ChannelDiscussionRef links a broadcast post to its discussion megagroup root message. +type ChannelDiscussionRef struct { + ChannelID int64 + MessageID int +} + +// ChannelMessageReplies describes thread/comment counters without depending on TL types. +type ChannelMessageReplies struct { + Comments bool + Replies int + RepliesPts int + RecentRepliers []Peer + ChannelID int64 + MaxID int + ReadMaxID int +} + +// ChannelUpdateEventType identifies channel pts events. +type ChannelUpdateEventType string + +const ( + ChannelUpdateNewMessage ChannelUpdateEventType = "new_channel_message" + ChannelUpdateEditMessage ChannelUpdateEventType = "edit_channel_message" + ChannelUpdateDeleteMessages ChannelUpdateEventType = "delete_channel_messages" + ChannelUpdateParticipant ChannelUpdateEventType = "channel_participant" + ChannelUpdatePinnedMessages ChannelUpdateEventType = "pinned_channel_messages" + ChannelUpdateNoop ChannelUpdateEventType = "noop" +) + +// ChannelUpdateEvent is the channel-scoped durable update log entry. +type ChannelUpdateEvent struct { + ChannelID int64 + Type ChannelUpdateEventType + Pts int + PtsCount int + Date int + Message ChannelMessage + MessageIDs []int + SenderUserID int64 + UserIDs []int64 + Pinned bool + Previous ChannelMember + Participant ChannelMember +} + +// FilterChannelUpdateEventForAvailableMinID hides message-scoped updates that +// belong to a member's pre-history-hidden range while preserving pts progress. +func FilterChannelUpdateEventForAvailableMinID(event ChannelUpdateEvent, availableMinID int) (ChannelUpdateEvent, bool) { + if availableMinID <= 0 { + return event, true + } + switch event.Type { + case ChannelUpdateNewMessage, ChannelUpdateEditMessage: + if event.Message.ID != 0 && event.Message.ID <= availableMinID { + return event, false + } + case ChannelUpdateDeleteMessages, ChannelUpdatePinnedMessages: + if len(event.MessageIDs) == 0 { + return event, true + } + ids := make([]int, 0, len(event.MessageIDs)) + for _, id := range event.MessageIDs { + if id > availableMinID { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return event, false + } + event.MessageIDs = ids + } + return event, true +} + +// ChannelAdminLogEventType identifies durable admin log actions. +type ChannelAdminLogEventType string + +const ( + ChannelAdminLogChangeTitle ChannelAdminLogEventType = "change_title" + ChannelAdminLogChangeUsername ChannelAdminLogEventType = "change_username" + ChannelAdminLogChangeLinkedChat ChannelAdminLogEventType = "change_linked_chat" + ChannelAdminLogToggleSignatures ChannelAdminLogEventType = "toggle_signatures" + ChannelAdminLogTogglePreHistoryHidden ChannelAdminLogEventType = "toggle_pre_history_hidden" + ChannelAdminLogToggleForum ChannelAdminLogEventType = "toggle_forum" + ChannelAdminLogToggleAutotranslation ChannelAdminLogEventType = "toggle_autotranslation" + ChannelAdminLogToggleAntiSpam ChannelAdminLogEventType = "toggle_anti_spam" + ChannelAdminLogToggleSlowMode ChannelAdminLogEventType = "toggle_slow_mode" + ChannelAdminLogParticipantInvite ChannelAdminLogEventType = "participant_invite" + ChannelAdminLogParticipantJoin ChannelAdminLogEventType = "participant_join" + ChannelAdminLogParticipantLeave ChannelAdminLogEventType = "participant_leave" + ChannelAdminLogParticipantPromote ChannelAdminLogEventType = "participant_promote" + ChannelAdminLogParticipantDemote ChannelAdminLogEventType = "participant_demote" + ChannelAdminLogParticipantBan ChannelAdminLogEventType = "participant_ban" + ChannelAdminLogParticipantUnban ChannelAdminLogEventType = "participant_unban" + ChannelAdminLogParticipantKick ChannelAdminLogEventType = "participant_kick" + ChannelAdminLogParticipantUnkick ChannelAdminLogEventType = "participant_unkick" + ChannelAdminLogUpdatePinned ChannelAdminLogEventType = "update_pinned" + ChannelAdminLogSendMessage ChannelAdminLogEventType = "send_message" + ChannelAdminLogEditMessage ChannelAdminLogEventType = "edit_message" + ChannelAdminLogDeleteMessage ChannelAdminLogEventType = "delete_message" +) + +// ChannelAdminLogEvent is a channel-scoped audit entry. +type ChannelAdminLogEvent struct { + ID int64 + ChannelID int64 + UserID int64 + Date int + Type ChannelAdminLogEventType + PrevString string + NewString string + PrevBool bool + NewBool bool + PrevInt int + NewInt int + PrevParticipant *ChannelMember + NewParticipant *ChannelMember + Participant *ChannelMember + Message *ChannelMessage + PrevMessage *ChannelMessage + NewMessage *ChannelMessage + Query string +} + +// ChannelAdminLogFilter mirrors the user-visible admin log filter categories. +type ChannelAdminLogFilter struct { + Join bool + Leave bool + Invite bool + Ban bool + Unban bool + Kick bool + Unkick bool + Promote bool + Demote bool + Info bool + Settings bool + Pinned bool + Edit bool + Delete bool + Send bool + Invites bool + Forums bool + SubExtend bool + EditRank bool +} + +// Empty reports whether no admin log category filter was supplied. +func (f ChannelAdminLogFilter) Empty() bool { + return !f.Join && !f.Leave && !f.Invite && !f.Ban && !f.Unban && !f.Kick && !f.Unkick && + !f.Promote && !f.Demote && !f.Info && !f.Settings && !f.Pinned && !f.Edit && !f.Delete && + !f.Send && !f.Invites && !f.Forums && !f.SubExtend && !f.EditRank +} + +// ChannelAdminLogRequest describes one bounded admin log page. +type ChannelAdminLogRequest struct { + UserID int64 + ChannelID int64 + Query string + AdminUserIDs []int64 + MaxID int64 + MinID int64 + Limit int + Filter ChannelAdminLogFilter +} + +// ChannelAdminLogResult is returned by channels.getAdminLog. +type ChannelAdminLogResult struct { + Channel Channel + Events []ChannelAdminLogEvent +} + +// ChannelView contains channel data personalized for a viewer. +type ChannelView struct { + Channel Channel + Self ChannelMember + Dialog ChannelDialog +} + +// ChannelParticipantList is a paged participant response. +type ChannelParticipantList struct { + Channel Channel + Participants []ChannelMember + Users []User + Count int + Hash int64 +} + +// ChannelParticipantsFilterKind identifies channels.getParticipants filters. +type ChannelParticipantsFilterKind string + +const ( + ChannelParticipantsRecent ChannelParticipantsFilterKind = "recent" + ChannelParticipantsAdmins ChannelParticipantsFilterKind = "admins" + ChannelParticipantsKicked ChannelParticipantsFilterKind = "kicked" + ChannelParticipantsBanned ChannelParticipantsFilterKind = "banned" + ChannelParticipantsSearch ChannelParticipantsFilterKind = "search" + ChannelParticipantsBots ChannelParticipantsFilterKind = "bots" + ChannelParticipantsContacts ChannelParticipantsFilterKind = "contacts" + ChannelParticipantsMentions ChannelParticipantsFilterKind = "mentions" +) + +// ChannelParticipantsFilter is a domain-only participant list filter. +type ChannelParticipantsFilter struct { + Kind ChannelParticipantsFilterKind + Query string +} + +// ChannelDialogList is a paged dialog response for channel/supergroup peers. +type ChannelDialogList struct { + Dialogs []Dialog + Messages []ChannelMessage + Channels []Channel + Users []User + Count int + Hash int64 +} + +// CommonChannelsRequest describes a bounded common-supergroup page. +type CommonChannelsRequest struct { + UserID int64 + TargetUserID int64 + MaxID int64 + Limit int + CountOnly bool +} + +// CommonChannelsResult contains shared megagroups and the full shared count. +type CommonChannelsResult struct { + Count int + Channels []Channel +} + +// ChannelRecommendationsRequest describes one public broadcast channel recommendation page. +type ChannelRecommendationsRequest struct { + UserID int64 + SourceChannelID int64 + Limit int +} + +// ChannelRecommendationsResult contains public broadcast channel recommendations and total count. +type ChannelRecommendationsResult struct { + Count int + Channels []Channel +} + +// PublicChannelSearchResult contains contacts.search public channel/supergroup matches. +type PublicChannelSearchResult struct { + MyResults []Channel + Results []Channel +} + +// LeftChannel is one channel/supergroup the user has left. +type LeftChannel struct { + Channel Channel + Self ChannelMember +} + +// LeftChannelsResult contains a bounded page plus the full left count. +type LeftChannelsResult struct { + Count int + Channels []LeftChannel +} + +// DiscussionGroupUpdateResult contains channels whose linked_chat_id changed. +type DiscussionGroupUpdateResult struct { + Channels []Channel +} + +// ChannelHistory is a paged channel history response. +type ChannelHistory struct { + Channel Channel + Self ChannelMember + Channels []Channel + Topics []ChannelForumTopic + Messages []ChannelMessage + Users []User + Count int + Hash int64 +} + +// ChannelForumTopic is a forum topic materialized from the topic root service message. +type ChannelForumTopic struct { + ChannelID int64 + TopicID int + CreatorUserID int64 + Title string + IconColor int + IconEmojiID int64 + TitleMissing bool + Closed bool + Hidden bool + Pinned bool + PinnedOrder int + Date int + TopMessageID int + ReadInboxMaxID int + ReadOutboxMaxID int + UnreadCount int + UnreadMentionsCount int + UnreadReactionsCount int + UnreadPollVotesCount int +} + +// ChannelForumTopicFilter pages a forum topic list without OFFSET scans. +type ChannelForumTopicFilter struct { + ChannelID int64 + Query string + OffsetDate int + OffsetID int + OffsetTopic int + Limit int +} + +// ChannelForumTopicList contains a bounded topic page and channel context. +type ChannelForumTopicList struct { + Channel Channel + Dialog ChannelDialog + Topics []ChannelForumTopic + Messages []ChannelMessage + Users []User + Count int +} + +// ChannelDifference is updates.getChannelDifference domain output. +type ChannelDifference struct { + Channel Channel + Self ChannelMember + Events []ChannelUpdateEvent + NewMessages []ChannelMessage + OtherUpdates []ChannelUpdateEvent + Users []User + Channels []Channel + Pts int + Final bool + TooLong bool + Dialog ChannelDialog + Timeout int +} + +// CreateChannelRequest creates a broadcast channel or megagroup. +type CreateChannelRequest struct { + CreatorUserID int64 + Title string + About string + RandomID int64 + Broadcast bool + Megagroup bool + Forum bool + ForumTabs bool + TTLPeriod int + MemberUserIDs []int64 + Date int +} + +// CreateChannelResult is returned by channel creation paths. +type CreateChannelResult struct { + Channel Channel + Members []ChannelMember + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// EditChannelTitleRequest edits a channel/supergroup title and emits a service message. +type EditChannelTitleRequest struct { + UserID int64 + ChannelID int64 + Title string + Date int +} + +// EditChannelTitleResult describes one title edit. +type EditChannelTitleResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// EditChannelAboutRequest modifies a channel/supergroup description. +type EditChannelAboutRequest struct { + UserID int64 + ChannelID int64 + About string + Date int +} + +// EditChannelAdminRequest modifies a member's admin rights. +type EditChannelAdminRequest struct { + UserID int64 + ChannelID int64 + MemberID int64 + AdminRights ChannelAdminRights + Rank string + Date int +} + +// EditChannelAdminResult describes the participant transition. +type EditChannelAdminResult struct { + Channel Channel + Previous ChannelMember + Participant ChannelMember + Event ChannelUpdateEvent + Recipients []int64 + Date int +} + +// EditChannelBannedRequest modifies a participant's banned rights. +type EditChannelBannedRequest struct { + UserID int64 + ChannelID int64 + Participant Peer + BannedRights ChannelBannedRights + Date int +} + +// EditChannelDefaultBannedRightsRequest modifies global default restrictions. +type EditChannelDefaultBannedRightsRequest struct { + UserID int64 + ChannelID int64 + BannedRights ChannelBannedRights + Date int +} + +// EditChannelBannedResult describes the participant transition. +type EditChannelBannedResult struct { + Channel Channel + Previous ChannelMember + Participant ChannelMember + Event ChannelUpdateEvent + Recipients []int64 + Date int +} + +// DeleteChannelRequest deletes a channel/supergroup. Only the creator may do this. +type DeleteChannelRequest struct { + UserID int64 + ChannelID int64 + Date int +} + +// UpdateChannelUsernameRequest updates or clears a channel public username. +type UpdateChannelUsernameRequest struct { + UserID int64 + ChannelID int64 + Username string +} + +// DeleteChannelResult describes a deleted channel. +type DeleteChannelResult struct { + Channel Channel + Recipients []int64 +} + +// SendChannelMessageRequest sends one channel/supergroup message. +type SendChannelMessageRequest struct { + UserID int64 + ChannelID int64 + RandomID int64 + Message string + Entities []MessageEntity + Media *MessageMedia + MentionUserIDs []int64 + Silent bool + NoForwards bool + ReplyTo *MessageReply + Forward *MessageForward + SendAs *Peer + Action *ChannelMessageAction + Date int +} + +// SaveChannelDefaultSendAsRequest stores the current user's default send-as peer for a channel dialog. +type SaveChannelDefaultSendAsRequest struct { + UserID int64 + ChannelID int64 + SendAs *Peer +} + +// ChannelMessageViewsRequest reads and optionally increments channel-scoped message view counters. +type ChannelMessageViewsRequest struct { + UserID int64 + ChannelID int64 + IDs []int + Increment bool + Date int +} + +// ChannelMessageViewsResult returns current view counters by visible message id. +type ChannelMessageViewsResult struct { + Views map[int]int +} + +// ReadChannelMessageContentsRequest marks channel/supergroup content-read hints for one viewer. +type ReadChannelMessageContentsRequest struct { + UserID int64 + ChannelID int64 + IDs []int +} + +// ReadChannelMessageContentsResult contains visible messages whose content-read hint can be synced. +type ReadChannelMessageContentsResult struct { + Channel Channel + Messages []ChannelMessage + ClearedUnreadReactionMessageIDs []int +} + +// GetChannelMessageAuthorRequest resolves the original user author of one channel message. +type GetChannelMessageAuthorRequest struct { + UserID int64 + ChannelID int64 + ID int +} + +// GetChannelMessageAuthorResult contains the resolved message author user id. +type GetChannelMessageAuthorResult struct { + Channel Channel + MessageID int + SenderUserID int64 +} + +// SendChannelMessageResult describes a channel message send. +type SendChannelMessageResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 + Duplicate bool + Discussion *SendChannelDiscussionResult +} + +// SendChannelDiscussionResult describes the discussion megagroup root created for a broadcast post. +type SendChannelDiscussionResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// CreateChannelForumTopicRequest creates one forum topic root service message. +type CreateChannelForumTopicRequest struct { + UserID int64 + ChannelID int64 + Title string + TitleMissing bool + IconColor int + IconEmojiID int64 + RandomID int64 + SendAs *Peer + Date int +} + +// CreateChannelForumTopicResult describes the topic root message and topic state. +type CreateChannelForumTopicResult struct { + Channel Channel + Topic ChannelForumTopic + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 + Duplicate bool +} + +// EditChannelForumTopicRequest edits topic metadata and emits a service message. +type EditChannelForumTopicRequest struct { + UserID int64 + ChannelID int64 + TopicID int + Title *string + IconEmojiID *int64 + Closed *bool + Hidden *bool + Date int +} + +// EditChannelForumTopicResult describes one topic edit service message. +type EditChannelForumTopicResult struct { + Channel Channel + Topic ChannelForumTopic + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// UpdateChannelForumTopicPinnedRequest pins or unpins one topic. +type UpdateChannelForumTopicPinnedRequest struct { + UserID int64 + ChannelID int64 + TopicID int + Pinned bool + Date int +} + +// UpdateChannelForumTopicPinnedResult describes one pinned-topic update. +type UpdateChannelForumTopicPinnedResult struct { + Channel Channel + Topic ChannelForumTopic + Recipients []int64 +} + +// ReorderChannelPinnedForumTopicsRequest stores a bounded pinned topic order. +type ReorderChannelPinnedForumTopicsRequest struct { + UserID int64 + ChannelID int64 + Order []int + Force bool + Date int +} + +// ReorderChannelPinnedForumTopicsResult describes a pinned topic order update. +type ReorderChannelPinnedForumTopicsResult struct { + Channel Channel + Order []int + Recipients []int64 +} + +// DeleteChannelForumTopicHistoryRequest deletes one forum topic page. +type DeleteChannelForumTopicHistoryRequest struct { + UserID int64 + ChannelID int64 + TopicID int + Date int +} + +// EditChannelMessageRequest edits one text message in a channel/supergroup. +type EditChannelMessageRequest struct { + UserID int64 + ChannelID int64 + ID int + Message string + Entities []MessageEntity + EditDate int +} + +// EditChannelMessageResult describes one channel edit update. +type EditChannelMessageResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// DeleteChannelMessagesRequest deletes a bounded set of channel/supergroup messages. +type DeleteChannelMessagesRequest struct { + UserID int64 + ChannelID int64 + IDs []int + Date int +} + +// DeleteChannelMessagesResult describes one channel delete update. +type DeleteChannelMessagesResult struct { + Channel Channel + Event ChannelUpdateEvent + DeletedIDs []int + Recipients []int64 +} + +// DeleteChannelHistoryRequest clears or deletes a bounded channel/supergroup history page. +type DeleteChannelHistoryRequest struct { + UserID int64 + ChannelID int64 + MaxID int + ForEveryone bool + Date int +} + +// DeleteChannelParticipantHistoryRequest deletes one bounded page of messages sent by a participant. +type DeleteChannelParticipantHistoryRequest struct { + UserID int64 + ChannelID int64 + ParticipantUserID int64 + Date int +} + +// DeleteChannelHistoryResult describes a channel history delete/clear page. +type DeleteChannelHistoryResult struct { + Channel Channel + Event ChannelUpdateEvent + DeletedIDs []int + Recipients []int64 + Offset int + AvailableMinID int +} + +// UpdateChannelPinnedMessageRequest pins or unpins one channel/supergroup message. +type UpdateChannelPinnedMessageRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Pinned bool + Silent bool + Date int +} + +// UpdateChannelPinnedMessageResult describes one pinned-message update. +type UpdateChannelPinnedMessageResult struct { + Channel Channel + Event ChannelUpdateEvent + Recipients []int64 +} + +// ChannelInvite is an exported invite link without TL dependencies. +type ChannelInvite struct { + ChannelID int64 + InviteID int64 + Hash string + AdminUserID int64 + Title string + Permanent bool + Revoked bool + RequestNeeded bool + ExpireDate int + UsageLimit int + UsageCount int + RequestedCount int + Date int +} + +// ExportChannelInviteRequest creates one invite link. +type ExportChannelInviteRequest struct { + UserID int64 + ChannelID int64 + Title string + RequestNeeded bool + ExpireDate int + UsageLimit int + LegacyRevokePermanent bool + Date int +} + +// ExportChannelInviteResult returns the exported invite. +type ExportChannelInviteResult struct { + Channel Channel + Invite ChannelInvite +} + +// CheckChannelInviteResult returns public invite preview info. +type CheckChannelInviteResult struct { + Channel Channel + Invite ChannelInvite + Already bool + Self ChannelMember +} + +// ImportChannelInviteRequest imports an invite and joins the channel when no approval is needed. +type ImportChannelInviteRequest struct { + UserID int64 + Hash string + Date int +} + +// ChannelInviteListRequest describes an exported invite management page. +type ChannelInviteListRequest struct { + UserID int64 + ChannelID int64 + AdminUserID int64 + Revoked bool + OffsetDate int + OffsetHash string + Limit int +} + +// ChannelInviteList is a bounded exported invite page. +type ChannelInviteList struct { + Count int + Invites []ChannelInvite +} + +// GetChannelInviteRequest fetches one exported invite by hash. +type GetChannelInviteRequest struct { + UserID int64 + ChannelID int64 + Hash string +} + +// EditChannelInviteRequest edits or revokes an exported invite. +type EditChannelInviteRequest struct { + UserID int64 + ChannelID int64 + Hash string + Revoked bool + HasExpireDate bool + ExpireDate int + HasUsageLimit bool + UsageLimit int + HasRequestNeeded bool + RequestNeeded bool + HasTitle bool + Title string + Date int +} + +// EditChannelInviteResult returns the edited invite and optional replacement. +type EditChannelInviteResult struct { + Invite ChannelInvite + NewInvite *ChannelInvite +} + +// DeleteChannelInviteRequest removes one exported invite. +type DeleteChannelInviteRequest struct { + UserID int64 + ChannelID int64 + Hash string +} + +// DeleteRevokedChannelInvitesRequest removes revoked invites for one admin. +type DeleteRevokedChannelInvitesRequest struct { + UserID int64 + ChannelID int64 + AdminUserID int64 + Limit int +} + +// ChannelAdminInviteCount aggregates invite counters for one admin. +type ChannelAdminInviteCount struct { + AdminUserID int64 + InvitesCount int + RevokedInvitesCount int +} + +// ChannelInviteImporter describes a user that joined or requested via an invite. +type ChannelInviteImporter struct { + ChannelID int64 + InviteID int64 + UserID int64 + Date int + Requested bool + ApprovedBy int64 + ViaChatlist bool + About string +} + +// ChannelInviteImportersRequest describes importer/join-request pagination. +type ChannelInviteImportersRequest struct { + UserID int64 + ChannelID int64 + Hash string + Requested bool + Query string + OffsetDate int + OffsetUserID int64 + Limit int +} + +// ChannelInviteImporterList is a bounded importer page. +type ChannelInviteImporterList struct { + Count int + Importers []ChannelInviteImporter +} + +// ChannelPendingJoinRequests is the admin-visible pending join request summary. +type ChannelPendingJoinRequests struct { + ChannelID int64 + Count int + RecentRequesters []int64 +} + +// HideChannelJoinRequestRequest approves or dismisses one pending join request. +type HideChannelJoinRequestRequest struct { + UserID int64 + ChannelID int64 + TargetUserID int64 + Approved bool + Date int +} + +// HideChannelJoinRequestsRequest approves or dismisses pending join requests in a bounded batch. +type HideChannelJoinRequestsRequest struct { + UserID int64 + ChannelID int64 + Hash string + Approved bool + Limit int + Date int +} + +// ChannelHistoryFilter describes channel history query conditions. +type ChannelHistoryFilter struct { + ChannelID int64 + Query string + SenderUserID int64 + OffsetID int + OffsetDate int + AddOffset int + Limit int + MinDate int + MaxDate int + MaxID int + MinID int + Hash int64 +} + +// ChannelSearchPostsRequest describes a bounded global public post search. +type ChannelSearchPostsRequest struct { + Hashtag string + Query string + OffsetRate int + OffsetChannelID int64 + OffsetID int + Limit int +} + +// ChannelGlobalSearchRequest describes a bounded messages.searchGlobal page +// over channel/supergroup messages visible to the current account. +type ChannelGlobalSearchRequest struct { + Query string + BroadcastsOnly bool + GroupsOnly bool + HasFolderID bool + FolderID int + OffsetRate int + OffsetChannelID int64 + OffsetID int + MinDate int + MaxDate int + Limit int +} + +// ChannelRepliesFilter describes messages.getReplies query conditions. +type ChannelRepliesFilter struct { + ChannelID int64 + RootMessageID int + OffsetID int + OffsetDate int + AddOffset int + Limit int + MaxID int + MinID int + Hash int64 +} + +// ChannelUnreadMentionsFilter describes messages.getUnreadMentions query conditions. +type ChannelUnreadMentionsFilter struct { + ChannelID int64 + TopMsgID int + OffsetID int + OffsetDate int + AddOffset int + Limit int + MaxID int + MinID int +} + +// ChannelUnreadReactionsFilter describes messages.getUnreadReactions query conditions. +type ChannelUnreadReactionsFilter struct { + ChannelID int64 + TopMsgID int + OffsetID int + AddOffset int + Limit int + MaxID int + MinID int +} + +// ReadChannelMentionsRequest clears unread mention state for a channel/supergroup. +type ReadChannelMentionsRequest struct { + UserID int64 + ChannelID int64 + TopMsgID int + Limit int +} + +// ReadChannelMentionsResult describes a bounded mentions clear operation. +type ReadChannelMentionsResult struct { + Channel Channel + Cleared int + Remaining int + Offset int + ChannelPts int +} + +// ReadChannelReactionsRequest clears unread reaction state for a channel/supergroup. +type ReadChannelReactionsRequest struct { + UserID int64 + ChannelID int64 + TopMsgID int + Limit int +} + +// ReadChannelReactionsResult describes a bounded reactions clear operation. +type ReadChannelReactionsResult struct { + Channel Channel + Cleared int + Remaining int + Offset int + ChannelPts int +} + +// ChannelDiscussionMessage describes messages.getDiscussionMessage output. +type ChannelDiscussionMessage struct { + PostChannel Channel + DiscussionChannel Channel + Messages []ChannelMessage + Channels []Channel + Users []User + MaxID int + ReadInboxMaxID int + ReadOutboxMaxID int + UnreadCount int +} + +// ChannelDifferenceRequest describes a channel difference query. +type ChannelDifferenceRequest struct { + UserID int64 + ChannelID int64 + Pts int + Limit int + Force bool +} + +// ReadChannelHistoryRequest advances the current user's channel read watermark. +type ReadChannelHistoryRequest struct { + UserID int64 + ChannelID int64 + MaxID int + Date int +} + +// ReadChannelHistoryResult describes a readHistory channel result. +type ReadChannelHistoryResult struct { + ChannelID int64 + MaxID int + StillUnreadCount int + Changed bool + Pts int + Dialog ChannelDialog + OutboxUpdates []ChannelReadOutboxUpdate +} + +// ChannelReadOutboxUpdate advances one sender's channel outbox read watermark. +type ChannelReadOutboxUpdate struct { + UserID int64 + MaxID int +} + +// ChannelReadParticipant describes one member's read receipt for a channel message. +type ChannelReadParticipant struct { + UserID int64 + Date int +} + +// ChannelReadParticipantsRequest queries read receipts for one small megagroup message. +type ChannelReadParticipantsRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Limit int + Date int +} + +// ChannelReadParticipantsResult is a bounded set of read receipt participants. +type ChannelReadParticipantsResult struct { + Channel Channel + Message ChannelMessage + Participants []ChannelReadParticipant +} diff --git a/internal/domain/channel_errors.go b/internal/domain/channel_errors.go new file mode 100644 index 00000000..c2789a3f --- /dev/null +++ b/internal/domain/channel_errors.go @@ -0,0 +1,68 @@ +package domain + +import ( + "errors" + "fmt" +) + +var ( + ErrChannelInvalid = errors.New("channel invalid") + ErrChannelPrivate = errors.New("channel private") + ErrChannelTitleInvalid = errors.New("channel title invalid") + ErrChannelUserBanned = errors.New("user banned in channel") + ErrChannelWriteForbidden = errors.New("chat write forbidden") + ErrChannelAdminRequired = errors.New("chat admin required") + ErrChannelNotModified = errors.New("chat not modified") + ErrChannelForumMissing = errors.New("channel forum missing") + ErrLinkNotModified = errors.New("discussion link not modified") + ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed") + ErrBroadcastIDInvalid = errors.New("broadcast id invalid") + ErrMegagroupIDInvalid = errors.New("megagroup id invalid") + ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden") + ErrChatPublicRequired = errors.New("chat public required") + ErrChannelUserCreator = errors.New("channel user creator") + ErrChannelRightForbidden = errors.New("channel right forbidden") + ErrPersistentTimestamp = errors.New("persistent timestamp invalid") + ErrInviteHashEmpty = errors.New("invite hash empty") + ErrInviteHashInvalid = errors.New("invite hash invalid") + ErrInviteHashExpired = errors.New("invite hash expired") + ErrInvitePermanent = errors.New("chat invite permanent") + ErrInviteRevokedMissing = errors.New("invite revoked missing") + ErrInviteRequestSent = errors.New("invite request sent") + ErrHideRequesterMissing = errors.New("hide requester missing") + ErrUsersTooMuch = errors.New("users too much") + ErrUserAlreadyParticipant = errors.New("user already participant") + ErrUserKicked = errors.New("user kicked") +) + +// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation. +type SlowModeWaitError struct { + Seconds int +} + +func (e SlowModeWaitError) Error() string { + if e.Seconds <= 0 { + return "slowmode wait" + } + return fmt.Sprintf("slowmode wait %d seconds", e.Seconds) +} + +// NewSlowModeWaitError creates a bounded slow mode wait error. +func NewSlowModeWaitError(seconds int) error { + if seconds <= 0 { + seconds = 1 + } + return SlowModeWaitError{Seconds: seconds} +} + +// SlowModeWaitSeconds extracts the wait duration from err. +func SlowModeWaitSeconds(err error) (int, bool) { + var wait SlowModeWaitError + if !errors.As(err, &wait) { + return 0, false + } + if wait.Seconds <= 0 { + return 1, true + } + return wait.Seconds, true +} diff --git a/internal/domain/contact.go b/internal/domain/contact.go new file mode 100644 index 00000000..a2d67d3b --- /dev/null +++ b/internal/domain/contact.go @@ -0,0 +1,65 @@ +package domain + +// Contact 是当前账号通讯录中的一个已注册联系人。 +// +// FirstName/LastName/Phone/Note 是 owner 视角数据:同一个 target user 在不同 +// owner 的通讯录里可以有不同备注,这些字段不回写 users 全局资料。 +type Contact struct { + User User + FirstName string + LastName string + Phone string + Note string + NoteEntities []MessageEntity + Mutual bool +} + +// ContactList 是通讯录查询结果。 +type ContactList struct { + Contacts []Contact + Hash int64 +} + +// ContactInput 描述一次 owner 视角联系人写入。 +type ContactInput struct { + ContactUserID int64 + ClientID int64 + Phone string + FirstName string + LastName string + Note string + NoteEntities []MessageEntity + AddPhonePrivacyException bool +} + +// ImportedContact 是 contacts.importContacts 成功导入项。 +type ImportedContact struct { + UserID int64 + ClientID int64 +} + +// ImportContactsResult 是 contacts.importContacts 的业务结果。 +type ImportContactsResult struct { + Imported []ImportedContact + Contacts []Contact + RetryContacts []int64 +} + +// UserSearchResult 是 contacts.search 的业务结果。 +// MyResults 放当前账号通讯录内命中的用户;Results 放其他全局命中用户。 +// MyChannelResults 放当前账号已加入的公开 channel/supergroup;ChannelResults 放其他公开命中。 +type UserSearchResult struct { + MyResults []User + Results []User + MyChannelResults []Channel + ChannelResults []Channel +} + +// PeerSettings 是当前 owner 看某个 peer 的可操作状态。 +type PeerSettings struct { + AddContact bool + BlockContact bool + ShareContact bool + NeedContactsException bool + HiddenPeerSettingsBar bool +} diff --git a/internal/domain/dialog.go b/internal/domain/dialog.go new file mode 100644 index 00000000..6c93c43e --- /dev/null +++ b/internal/domain/dialog.go @@ -0,0 +1,155 @@ +package domain + +// PeerType 标识 dialog 所属 peer 类型。 +type PeerType string + +const ( + PeerTypeUser PeerType = "user" + PeerTypeChannel PeerType = "channel" +) + +const ( + // DialogMainFolderID 是 TDesktop 主会话列表 folder_id。 + DialogMainFolderID = 0 + // DialogArchiveFolderID 是 Telegram 约定的归档会话 folder_id。 + DialogArchiveFolderID = 1 + // DialogCustomFolderMinID 起才允许用户自定义 filter。 + DialogCustomFolderMinID = 2 + // MaxDialogFolders 限制单用户自定义 filter 数量,避免无界配置拖垮启动同步。 + MaxDialogFolders = 100 + // MaxDialogFolderPeers 限制单 filter 中 include/exclude/pinned peer 数。 + MaxDialogFolderPeers = 100 + // MaxDialogFolderTitleRunes 对齐 Telegram folder title 的短标题语义。 + MaxDialogFolderTitleRunes = 64 + // MaxDialogDraftsPerUser bounds messages.getAllDrafts / clearAllDrafts work. + MaxDialogDraftsPerUser = 1000 +) + +// Peer 是业务层 peer 值对象,不依赖 TL 类型。 +type Peer struct { + Type PeerType + ID int64 +} + +// Dialog 是账号的一条会话摘要。 +type Dialog struct { + Peer Peer + ChannelLeft bool + FolderID int + TopMessage int + TopMessageDate int + ReadInboxMaxID int + ReadOutboxMaxID int + UnreadCount int + UnreadMentions int + UnreadReactions int + Pinned bool + PinnedOrder int + UnreadMark bool + ViewForumAsMessages bool + PeerSettingsBarHidden bool + Draft *DialogDraft +} + +// DialogDraftWebPage stores a draft link preview without depending on TL input media types. +type DialogDraftWebPage struct { + URL string + ForceLargeMedia bool + ForceSmallMedia bool + Optional bool +} + +// DialogDraft is a cloud draft for one peer/topic, expressed only in domain types. +type DialogDraft struct { + Peer Peer + TopMessageID int + Date int + NoWebpage bool + InvertMedia bool + Message string + Entities []MessageEntity + ReplyTo *MessageReply + WebPage *DialogDraftWebPage + Effect int64 +} + +// Empty reports whether this draft should clear the cloud draft slot. +func (d DialogDraft) Empty() bool { + replyOnlyTopic := d.ReplyTo != nil && d.ReplyTo.MessageID == 0 && d.ReplyTo.TopMessageID > 0 + return !d.NoWebpage && + !d.InvertMedia && + d.Message == "" && + len(d.Entities) == 0 && + (d.ReplyTo == nil || replyOnlyTopic) && + d.WebPage == nil && + d.Effect == 0 +} + +// DialogList 是 dialogs 查询结果。 +type DialogList struct { + Dialogs []Dialog + Messages []Message + ChannelMessages []ChannelMessage + Users []User + Channels []Channel + State UpdateState + Hash int64 + Count int +} + +// DialogFilter 是会话列表查询条件。 +type DialogFilter struct { + PinnedOnly bool + ExcludePinned bool + HasFolderID bool + FolderID int + Folder *DialogFolder + OffsetDate int + OffsetID int + HasOffsetPeer bool + OffsetPeer Peer + Limit int + Hash int64 +} + +// DialogFolderPeer 是 folder/filter 规则中的 peer,保留 access_hash 供 RPC 层回写 InputPeer。 +type DialogFolderPeer struct { + Peer Peer + AccessHash int64 +} + +// DialogFolder 是用户自定义会话分组规则。它只表达业务含义,不依赖 TL 生成类型。 +type DialogFolder struct { + ID int + Contacts bool + NonContacts bool + Groups bool + Broadcasts bool + Bots bool + ExcludeMuted bool + ExcludeRead bool + ExcludeArchived bool + TitleNoanimate bool + Title string + TitleEntities []MessageEntity + Emoticon string + HasEmoticon bool + Color int + HasColor bool + PinnedPeers []DialogFolderPeer + IncludePeers []DialogFolderPeer + ExcludePeers []DialogFolderPeer + IsChatlist bool +} + +// DialogFolderList 是 messages.getDialogFilters 的业务响应。 +type DialogFolderList struct { + TagsEnabled bool + Folders []DialogFolder +} + +// FolderPeerUpdate 描述 folders.editPeerFolders 的单个归档/还原变更。 +type FolderPeerUpdate struct { + Peer Peer + FolderID int +} diff --git a/internal/domain/doc.go b/internal/domain/doc.go new file mode 100644 index 00000000..22c78c12 --- /dev/null +++ b/internal/domain/doc.go @@ -0,0 +1,4 @@ +// Package domain 存放业务实体与值对象(User、Peer、Dialog、Message、MessageID 等)。 +// +// 铁律:本包禁止依赖 gotd/td/tg 等协议层类型;TL 类型只允许出现在 RPC/MTProto 边界。 +package domain diff --git a/internal/domain/help.go b/internal/domain/help.go new file mode 100644 index 00000000..bdf63c5d --- /dev/null +++ b/internal/domain/help.go @@ -0,0 +1,30 @@ +package domain + +// AppConfig 是客户端应用配置。 +type AppConfig struct { + Client string + Hash int + JSON []byte +} + +// CountryCode 是一个国家的电话区号规则。 +type CountryCode struct { + CountryCode string + Prefixes []string + Patterns []string +} + +// Country 是登录页国家/区号选择项。 +type Country struct { + ISO2 string + DefaultName string + Name string + Hidden bool + CountryCodes []CountryCode +} + +// CountriesList 是 help.getCountriesList 查询结果。 +type CountriesList struct { + Hash int + Countries []Country +} diff --git a/internal/domain/langpack.go b/internal/domain/langpack.go new file mode 100644 index 00000000..e90e6722 --- /dev/null +++ b/internal/domain/langpack.go @@ -0,0 +1,24 @@ +package domain + +// LangPack 是一份客户端语言包的查询结果。 +type LangPack struct { + LangPack string + LangCode string + FromVersion int + Version int + Strings []LangPackString +} + +// LangPackString 是语言包中的一个普通或复数形式字符串。 +type LangPackString struct { + Key string + Value string + Pluralized bool + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool +} diff --git a/internal/domain/media.go b/internal/domain/media.go new file mode 100644 index 00000000..38bb4839 --- /dev/null +++ b/internal/domain/media.go @@ -0,0 +1,331 @@ +package domain + +// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。 +// 这些类型完全不依赖 tg.*;rpc 层负责 domain↔tg 转换。 +// +// 字段带 json tag 是为了 store 层可直接 json.Marshal 落 JSONB(消息 media 快照、 +// 文档/照片元数据)。它们是协议无关的纯数据,不是 tg 生成类型。 + +// MediaBackend 标识 blob 字节实际存放后端。第一阶段只有本地磁盘。 +type MediaBackend string + +const ( + // MediaBackendLocalFS 表示 blob 字节存在本地磁盘(object_key 为相对路径)。 + MediaBackendLocalFS MediaBackend = "localfs" +) + +// FileBlob 是一个可下载的二进制对象的索引项:location_key → 后端/对象键/大小/mime。 +// 真正的字节由 blob backend 按 ObjectKey 读写;本结构只描述定位与元数据。 +type FileBlob struct { + // LocationKey 是稳定的逻辑定位键,由 getFile 的 InputFileLocation 推导: + // doc: 文档主体 + // doc:: 文档缩略图(PhotoSize type) + // photo:: 照片某尺寸 + LocationKey string `json:"location_key"` + Backend MediaBackend `json:"backend"` + ObjectKey string `json:"object_key"` + Size int64 `json:"size"` + SHA256 []byte `json:"sha256,omitempty"` + MimeType string `json:"mime_type,omitempty"` +} + +// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片(落 PG,组装后清理)。 +type UploadPart struct { + OwnerUserID int64 + FileID int64 + Part int + TotalParts int // big file 已知总数;small file 为 0 + Big bool + Bytes []byte +} + +// UploadedFileRef 引用一个客户端已通过 upload.saveFilePart(Big) 上传完毕的文件。 +// rpc 层从 tg.InputFile/InputFileBig 转换得到;files 服务据此组装 blob。 +type UploadedFileRef struct { + OwnerUserID int64 + FileID int64 + Parts int + Name string + Big bool + MD5 string // small file 客户端 md5_checksum(hex),可校验;big file 为空 +} + +// DocumentSpec 描述从上传文件创建 Document 的元数据(来自 InputMediaUploadedDocument)。 +type DocumentSpec struct { + MimeType string + Attributes []DocumentAttribute + Thumb *UploadedFileRef // 可选缩略图上传,生成 doc::m + ForceFile bool +} + +// FileDownloadRequest 是 upload.getFile 解析后的下载请求; +// LocationKey 由 rpc 层从 tg.InputFileLocation 推导(doc: / photo:: 等)。 +type FileDownloadRequest struct { + LocationKey string + Offset int64 + Limit int +} + +// FileChunk 是 upload.getFile 返回的一段内容。 +type FileChunk struct { + Bytes []byte + MimeType string + Total int64 +} + +// PhotoSizeKind 标识 PhotoSize 的 TL 变体。 +type PhotoSizeKind string + +const ( + // PhotoSizeKindDefault → photoSize(可下载,type/w/h/size)。 + PhotoSizeKindDefault PhotoSizeKind = "size" + // PhotoSizeKindStripped → photoStrippedSize(内联字节,秒开模糊图)。 + PhotoSizeKindStripped PhotoSizeKind = "stripped" + // PhotoSizeKindCached → photoCachedSize(内联字节 + w/h)。 + PhotoSizeKindCached PhotoSizeKind = "cached" + // PhotoSizeKindPath → photoPathSize(内联 svg path 字节,矢量占位)。 + PhotoSizeKindPath PhotoSizeKind = "path" + // PhotoSizeKindProgressive → photoSizeProgressive(渐进式 jpeg 多段大小)。 + PhotoSizeKindProgressive PhotoSizeKind = "progressive" +) + +// PhotoSize 描述照片/缩略图的一种渲染尺寸。 +type PhotoSize struct { + Kind PhotoSizeKind `json:"kind"` + Type string `json:"type"` + W int `json:"w,omitempty"` + H int `json:"h,omitempty"` + Size int `json:"size,omitempty"` + Bytes []byte `json:"bytes,omitempty"` // stripped/cached/path 内联内容 + Sizes []int `json:"sizes,omitempty"` // progressive +} + +// Downloadable 表示该尺寸需要客户端通过 upload.getFile 拉取(而非内联字节)。 +func (s PhotoSize) Downloadable() bool { + return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive +} + +// DocumentAttributeKind 标识 TL DocumentAttribute 变体。 +type DocumentAttributeKind string + +const ( + DocAttrImageSize DocumentAttributeKind = "image_size" + DocAttrAnimated DocumentAttributeKind = "animated" + DocAttrSticker DocumentAttributeKind = "sticker" + DocAttrVideo DocumentAttributeKind = "video" + DocAttrAudio DocumentAttributeKind = "audio" + DocAttrFilename DocumentAttributeKind = "filename" + DocAttrCustomEmoji DocumentAttributeKind = "custom_emoji" +) + +// DocumentAttribute 是主路径用到的 TL DocumentAttribute 变体的并集。 +type DocumentAttribute struct { + Kind DocumentAttributeKind `json:"kind"` + + // image_size / video / sticker box + W int `json:"w,omitempty"` + H int `json:"h,omitempty"` + + // sticker / custom_emoji + Alt string `json:"alt,omitempty"` + Mask bool `json:"mask,omitempty"` + StickerSetID int64 `json:"sticker_set_id,omitempty"` + StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"` + Free bool `json:"free,omitempty"` // custom_emoji + TextColor bool `json:"text_color,omitempty"` // custom_emoji + + // video + Duration float64 `json:"duration,omitempty"` + RoundMessage bool `json:"round_message,omitempty"` + SupportsStreaming bool `json:"supports_streaming,omitempty"` + + // audio + AudioDuration int `json:"audio_duration,omitempty"` + Voice bool `json:"voice,omitempty"` + Title string `json:"title,omitempty"` + Performer string `json:"performer,omitempty"` + Waveform []byte `json:"waveform,omitempty"` + + // filename + FileName string `json:"file_name,omitempty"` +} + +// Document 是已存储的 Telegram 文档(贴纸、gif、文件、视频、音频、自定义 emoji……)。 +type Document struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + FileReference []byte `json:"file_reference,omitempty"` + Date int `json:"date,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Size int64 `json:"size,omitempty"` + DCID int `json:"dc_id,omitempty"` + Attributes []DocumentAttribute `json:"attributes,omitempty"` + Thumbs []PhotoSize `json:"thumbs,omitempty"` +} + +// StickerSetRef 返回该文档归属的贴纸集引用(若有 sticker/custom_emoji 属性)。 +func (d Document) StickerSetRef() (id, accessHash int64, ok bool) { + for _, attr := range d.Attributes { + if attr.Kind == DocAttrSticker || attr.Kind == DocAttrCustomEmoji { + if attr.StickerSetID != 0 { + return attr.StickerSetID, attr.StickerSetAccessHash, true + } + } + } + return 0, 0, false +} + +// Photo 是已存储的 Telegram 照片(头像或图片消息)。 +type Photo struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + FileReference []byte `json:"file_reference,omitempty"` + Date int `json:"date,omitempty"` + DCID int `json:"dc_id,omitempty"` + HasStickers bool `json:"has_stickers,omitempty"` + Sizes []PhotoSize `json:"sizes,omitempty"` +} + +// MessageMediaKind 枚举消息可挂载的媒体载荷。 +type MessageMediaKind string + +const ( + MessageMediaKindNone MessageMediaKind = "" + MessageMediaKindPhoto MessageMediaKind = "photo" + MessageMediaKindDocument MessageMediaKind = "document" +) + +// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。 +type MessageMedia struct { + Kind MessageMediaKind `json:"kind"` + Photo *Photo `json:"photo,omitempty"` + Document *Document `json:"document,omitempty"` + Spoiler bool `json:"spoiler,omitempty"` + TTLSeconds int `json:"ttl_seconds,omitempty"` + Nopremium bool `json:"nopremium,omitempty"` + Voice bool `json:"voice,omitempty"` + Round bool `json:"round,omitempty"` + Video bool `json:"video,omitempty"` +} + +// IsZero 表示无媒体(用于落库时跳过空快照、转换时回退 MessageMediaEmpty)。 +func (m *MessageMedia) IsZero() bool { + return m == nil || m.Kind == MessageMediaKindNone +} + +// StickerPack 是 emoji→文档 id 的映射条目(messages.stickerSet.packs)。 +type StickerPack struct { + Emoticon string `json:"emoticon"` + DocumentIDs []int64 `json:"document_ids"` +} + +// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。 +type StickerSetKind string + +const ( + StickerSetKindStickers StickerSetKind = "stickers" + StickerSetKindEmoji StickerSetKind = "emoji" + StickerSetKindMasks StickerSetKind = "masks" + // StickerSetKindSystem 是 TDesktop 通过 InputStickerSetDice/AnimatedEmoji 等系统集请求的内置集。 + StickerSetKindSystem StickerSetKind = "system" +) + +// StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。 +type StickerSet struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + ShortName string `json:"short_name"` + Title string `json:"title"` + Count int `json:"count"` + Hash int `json:"hash"` + Kind StickerSetKind `json:"set_kind"` + Official bool `json:"official,omitempty"` + Animated bool `json:"animated,omitempty"` + Videos bool `json:"videos,omitempty"` + Emojis bool `json:"emojis,omitempty"` + Masks bool `json:"masks,omitempty"` + Installed bool `json:"installed,omitempty"` + Archived bool `json:"archived,omitempty"` + InstalledDate int `json:"installed_date,omitempty"` + ThumbDocumentID int64 `json:"thumb_document_id,omitempty"` + Thumbs []PhotoSize `json:"thumbs,omitempty"` + ThumbDCID int `json:"thumb_dc_id,omitempty"` + ThumbVersion int `json:"thumb_version,omitempty"` + DocumentIDs []int64 `json:"document_ids,omitempty"` + Packs []StickerPack `json:"packs,omitempty"` + SortOrder int `json:"sort_order,omitempty"` + // SystemKey 是 TDesktop 系统集的稳定标识(如 "animated_emoji"、"dice:🎲"),用于 InputStickerSet* 路由。 + SystemKey string `json:"system_key,omitempty"` +} + +// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile photo)。 +type ProfilePhotoRef struct { + PhotoID int64 + DCID int + Stripped []byte // photoStrippedSize 内联缩略图,可空 +} + +// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。 +func StrippedFromSizes(sizes []PhotoSize) []byte { + for _, s := range sizes { + if s.Kind == PhotoSizeKindStripped { + return s.Bytes + } + } + return nil +} + +// StickerSetRefKind 标识 InputStickerSet 的解析方式。 +type StickerSetRefKind string + +const ( + StickerSetRefByID StickerSetRefKind = "id" + StickerSetRefByShortName StickerSetRefKind = "short_name" + StickerSetRefBySystem StickerSetRefKind = "system" +) + +// StickerSetRef 是 rpc 层从 tg.InputStickerSet 转换得到的贴纸集引用。 +type StickerSetRef struct { + Kind StickerSetRefKind + ID int64 + AccessHash int64 + ShortName string + SystemKey string +} + +// AvailableReaction 描述 messages.getAvailableReactions 的一项(真实资源由文档 id 引用)。 +type AvailableReaction struct { + Reaction string `json:"reaction"` + Title string `json:"title"` + Inactive bool `json:"inactive,omitempty"` + Premium bool `json:"premium,omitempty"` + StaticIconID int64 `json:"static_icon_id,omitempty"` + AppearAnimationID int64 `json:"appear_animation_id,omitempty"` + SelectAnimationID int64 `json:"select_animation_id,omitempty"` + ActivateAnimationID int64 `json:"activate_animation_id,omitempty"` + EffectAnimationID int64 `json:"effect_animation_id,omitempty"` + AroundAnimationID int64 `json:"around_animation_id,omitempty"` + CenterIconID int64 `json:"center_icon_id,omitempty"` + Order int `json:"order,omitempty"` +} + +// DocumentIDs 收集该 reaction 引用的全部文档 id(去零去重,便于批量加载)。 +func (r AvailableReaction) DocumentIDs() []int64 { + raw := []int64{ + r.StaticIconID, r.AppearAnimationID, r.SelectAnimationID, + r.ActivateAnimationID, r.EffectAnimationID, r.AroundAnimationID, r.CenterIconID, + } + out := make([]int64, 0, len(raw)) + seen := make(map[int64]struct{}, len(raw)) + for _, id := range raw { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/internal/domain/media_errors.go b/internal/domain/media_errors.go new file mode 100644 index 00000000..003ed2e8 --- /dev/null +++ b/internal/domain/media_errors.go @@ -0,0 +1,16 @@ +package domain + +import "errors" + +// 媒体 / 文件相关业务错误。rpc 层据此映射为对应 rpc_error(见 internal/rpc/errors.go)。 +var ( + ErrFilePartInvalid = errors.New("file part invalid") + ErrFilePartsInvalid = errors.New("file parts invalid") + ErrFilePartTooBig = errors.New("file part too big") + ErrFileReference = errors.New("file reference invalid") + ErrMediaInvalid = errors.New("media invalid") + ErrMediaEmpty = errors.New("media empty") + ErrPhotoInvalid = errors.New("photo invalid") + ErrStickersetInvalid = errors.New("stickerset invalid") + ErrDocumentInvalid = errors.New("document invalid") +) diff --git a/internal/domain/message.go b/internal/domain/message.go new file mode 100644 index 00000000..6462081c --- /dev/null +++ b/internal/domain/message.go @@ -0,0 +1,352 @@ +package domain + +// MessageEntityType 标识消息实体类型。 +type MessageEntityType string + +const ( + MessageEntityBold MessageEntityType = "bold" +) + +const ( + // MaxMessageTextLength matches the first-stage text message limit exposed to Telegram clients. + MaxMessageTextLength = 4096 + // MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default. + MaxMessageReplyQuoteLength = 1024 + // MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id. + MaxMessageReplyQuoteOffset = MaxMessageTextLength + // MaxMessageEntityCount limits styled text entity vectors in message text and quotes. + MaxMessageEntityCount = 256 + // MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。 + MaxMessageBoxID = 1<<31 - 1 + // MaxDeleteMessageIDs 限制单次 deleteMessages/updateDeleteMessages 的 owner 视角 id 数量。 + // 大批量历史清理走 deleteHistory 分批推进,避免单个 RPC 构造超大数组或 durable payload。 + MaxDeleteMessageIDs = 1000 + // MaxGetMessageIDs 限制 getMessages / channels.getMessages 精确 ID 批量。 + MaxGetMessageIDs = 100 + // MaxDeleteHistoryBatch 限制单次 deleteHistory 实际清理的 message box 数量。 + // affectedHistory.Offset > 0 时客户端可继续调用,服务端不一次性 RETURNING 全历史。 + MaxDeleteHistoryBatch = 1000 + // MaxForwardMessageIDs 限制单次 forwardMessages 的 owner 视角 id 数量。 + MaxForwardMessageIDs = 100 + // MaxMessageHistoryAddOffset 限制 history/search 的 add_offset 绝对值。 + // TDesktop 正常只使用小窗口偏移;服务端必须拒绝把客户端传入的超大值变成 SQL OFFSET 或 slice capacity。 + MaxMessageHistoryAddOffset = 100 +) + +// ClampMessageHistoryAddOffset bounds Telegram history/search add_offset to a small local window. +func ClampMessageHistoryAddOffset(v int) int { + if v > MaxMessageHistoryAddOffset { + return MaxMessageHistoryAddOffset + } + if v < -MaxMessageHistoryAddOffset { + return -MaxMessageHistoryAddOffset + } + return v +} + +// ValidateMessageReplyBounds validates reply fields that are independent of peer visibility. +func ValidateMessageReplyBounds(reply *MessageReply) error { + if reply == nil { + return nil + } + if reply.MessageID < 0 || reply.MessageID > MaxMessageBoxID { + return ErrReplyMessageIDInvalid + } + if reply.TopMessageID < 0 || reply.TopMessageID > MaxMessageBoxID { + return ErrReplyMessageIDInvalid + } + if reply.MessageID == 0 && reply.TopMessageID == 0 { + return ErrReplyMessageIDInvalid + } + if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset { + return ErrReplyMessageIDInvalid + } + return nil +} + +// MessageEntity 是业务层消息实体,不依赖 TL 类型。 +type MessageEntity struct { + Type MessageEntityType + Offset int + Length int +} + +// Message 是账号视角下的一条私聊消息。 +type Message struct { + ID int // 当前 owner 视角下的 message box id,暴露给 Telegram 客户端。 + UID int64 // 共享私聊消息主体 id,不暴露给客户端。 + RandomID int64 + OwnerUserID int64 + Peer Peer + From Peer + Date int + EditDate int + Out bool + Silent bool + NoForwards bool + Body string + Entities []MessageEntity + ReplyTo *MessageReply + Forward *MessageForward + Reactions *ChannelMessageReactions + Pts int + Media *MessageMedia +} + +// MessageReply describes a message reply/thread header without depending on TL types. +type MessageReply struct { + MessageID int + Peer Peer + TopMessageID int + ForumTopic bool + QuoteText string + QuoteEntities []MessageEntity + QuoteOffset int +} + +// MessageForward 描述一条转发消息的原始作者信息。 +type MessageForward struct { + From Peer + FromName string + Date int + ChannelPost int + SavedFrom Peer + SavedFromMsgID int +} + +// MessageList 是账号视角下的消息查询结果。 +type MessageList struct { + Messages []Message + Users []User + Count int + Hash int64 +} + +// MessageFilter 描述历史/搜索查询条件。 +type MessageFilter struct { + HasPeer bool + Peer Peer + Query string + OffsetID int + OffsetDate int + AddOffset int + Limit int + MaxID int + MinID int + Hash int64 + NeedTotalCount bool +} + +// SendPrivateTextRequest 是私聊文本/媒体发送命令。 +type SendPrivateTextRequest struct { + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + Message string + Entities []MessageEntity + Media *MessageMedia + Silent bool + NoForwards bool + ReplyTo *MessageReply + Forward *MessageForward + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// SendPrivateTextResult 描述一次私聊文本发送的双端结果。 +type SendPrivateTextResult struct { + SenderMessage Message + RecipientMessage Message + SenderEvent UpdateEvent + RecipientEvent UpdateEvent + Duplicate bool +} + +// SetPrivateMessageReactionsRequest replaces the current user's reactions for one private message. +type SetPrivateMessageReactionsRequest struct { + UserID int64 + Peer Peer + MessageID int + Reactions []MessageReaction + Big bool + AddToRecent bool + Date int +} + +// PrivateMessageReactionsRequest fetches reaction summaries for exact private message ids. +type PrivateMessageReactionsRequest struct { + OwnerUserID int64 + Peer Peer + IDs []int +} + +// PrivateMessageReactionsResult describes private reaction updates in owner-visible boxes. +type PrivateMessageReactionsResult struct { + Messages []Message + Reactions ChannelMessageReactions +} + +// ForwardPrivateMessagesRequest 是私聊文本消息转发命令。 +type ForwardPrivateMessagesRequest struct { + OwnerUserID int64 + FromPeer Peer + ToUserID int64 + MessageIDs []int + RandomIDs []int64 + Silent bool + NoForwards bool + DropAuthor bool + ReplyTo *MessageReply + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。 +type ForwardPrivateMessagesResult struct { + OwnerUserID int64 + SenderMessages []Message + RecipientMessages []Message + SenderEvents []UpdateEvent + RecipientEvents []UpdateEvent + Duplicates []bool +} + +// ReadHistoryRequest 是账号视角的 messages.readHistory 命令。 +type ReadHistoryRequest struct { + OwnerUserID int64 + Peer Peer + MaxID int + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// ReadHistoryResult 描述一次会话已读操作的业务结果。 +type ReadHistoryResult struct { + OwnerUserID int64 + Peer Peer + MaxID int + StillUnreadCount int + Changed bool + InboxEvent UpdateEvent + OutboxChanged bool + OutboxUserID int64 + OutboxEvent UpdateEvent +} + +// ReadMessageContentsRequest marks media/mention contents as read for exact owner-visible messages. +type ReadMessageContentsRequest struct { + OwnerUserID int64 + IDs []int +} + +// ReadMessageContentsResult contains owner-visible message IDs that existed and can be synced. +type ReadMessageContentsResult struct { + OwnerUserID int64 + MessageIDs []int +} + +// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。 +type OutboxReadDateRequest struct { + OwnerUserID int64 + Peer Peer + ID int +} + +// EditMessageRequest 是账号视角下编辑一条已发送私聊文本消息的命令。 +type EditMessageRequest struct { + OwnerUserID int64 + Peer Peer + ID int + Message string + Entities []MessageEntity + EditDate int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// EditedMessageForUser 描述一次编辑对某个 owner 视角造成的影响。 +type EditedMessageForUser struct { + UserID int64 + Message Message + Event UpdateEvent +} + +// EditMessageResult 描述消息编辑后的 owner 维度结果。 +type EditMessageResult struct { + OwnerUserID int64 + Edited []EditedMessageForUser +} + +// Self 返回当前请求账号的编辑结果。 +func (r EditMessageResult) Self() EditedMessageForUser { + for _, item := range r.Edited { + if item.UserID == r.OwnerUserID { + return item + } + } + return EditedMessageForUser{UserID: r.OwnerUserID} +} + +// Changed 表示本次编辑是否实际影响了任何 owner 视角。 +func (r EditMessageResult) Changed() bool { + return len(r.Edited) > 0 +} + +// DeleteMessagesRequest 是账号视角下按消息 ID 删除消息的命令。 +type DeleteMessagesRequest struct { + OwnerUserID int64 + IDs []int + Revoke bool + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// DeleteHistoryRequest 是账号视角下清空某个 peer 历史的命令。 +type DeleteHistoryRequest struct { + OwnerUserID int64 + Peer Peer + MaxID int + JustClear bool + Revoke bool + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// DeletedMessagesForUser 描述一次删除对某个 owner 视角造成的影响。 +type DeletedMessagesForUser struct { + UserID int64 + MessageIDs []int + Event UpdateEvent +} + +// DeleteMessagesResult 描述消息删除后的 owner 维度结果。 +type DeleteMessagesResult struct { + OwnerUserID int64 + Deleted []DeletedMessagesForUser + Offset int +} + +// Self 返回当前请求账号的删除结果。 +func (r DeleteMessagesResult) Self() DeletedMessagesForUser { + for _, item := range r.Deleted { + if item.UserID == r.OwnerUserID { + return item + } + } + return DeletedMessagesForUser{UserID: r.OwnerUserID} +} + +// Changed 表示本次删除是否实际影响了任何 owner 视角。 +func (r DeleteMessagesResult) Changed() bool { + for _, item := range r.Deleted { + if len(item.MessageIDs) > 0 { + return true + } + } + return false +} diff --git a/internal/domain/message_errors.go b/internal/domain/message_errors.go new file mode 100644 index 00000000..6686e2ce --- /dev/null +++ b/internal/domain/message_errors.go @@ -0,0 +1,12 @@ +package domain + +import "errors" + +var ( + ErrMessageIDInvalid = errors.New("message id invalid") + ErrMessageAuthorRequired = errors.New("message author required") + ErrMessageNotModified = errors.New("message not modified") + ErrMessageNotReadYet = errors.New("message not read yet") + ErrReplyMessageIDInvalid = errors.New("reply message id invalid") + ErrChatForwardsRestricted = errors.New("chat forwards restricted") +) diff --git a/internal/domain/message_test.go b/internal/domain/message_test.go new file mode 100644 index 00000000..5e0093b4 --- /dev/null +++ b/internal/domain/message_test.go @@ -0,0 +1,27 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestValidateMessageReplyBoundsRejectsQuoteOffsetAsTextOffset(t *testing.T) { + reply := &MessageReply{ + MessageID: 1, + QuoteText: "hello", + QuoteOffset: MaxMessageReplyQuoteOffset + 1, + } + if err := ValidateMessageReplyBounds(reply); !errors.Is(err, ErrReplyMessageIDInvalid) { + t.Fatalf("ValidateMessageReplyBounds err = %v, want ErrReplyMessageIDInvalid", err) + } +} + +func TestValidateMessageReplyBoundsAllowsForumTopicOnlyHeader(t *testing.T) { + reply := &MessageReply{ + TopMessageID: 10, + ForumTopic: true, + } + if err := ValidateMessageReplyBounds(reply); err != nil { + t.Fatalf("ValidateMessageReplyBounds err = %v, want nil", err) + } +} diff --git a/internal/domain/system.go b/internal/domain/system.go new file mode 100644 index 00000000..1951ac3e --- /dev/null +++ b/internal/domain/system.go @@ -0,0 +1,19 @@ +package domain + +const ( + // OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。 + OfficialSystemUserID int64 = 777000 +) + +// OfficialSystemUser 返回第一阶段内置的官方系统账号。 +func OfficialSystemUser() User { + return User{ + ID: OfficialSystemUserID, + AccessHash: 6599886787491911851, + Phone: "42777", + FirstName: "Telegram", + Username: "telegram", + Verified: true, + Support: true, + } +} diff --git a/internal/domain/temp_auth_key.go b/internal/domain/temp_auth_key.go new file mode 100644 index 00000000..a5da0e10 --- /dev/null +++ b/internal/domain/temp_auth_key.go @@ -0,0 +1,11 @@ +package domain + +// TempAuthKeyBinding 是 auth.bindTempAuthKey 的持久化记录。 +type TempAuthKeyBinding struct { + TempAuthKeyID [8]byte + PermAuthKeyID int64 + Nonce int64 + TempSessionID int64 + ExpiresAt int + EncryptedMessage []byte +} diff --git a/internal/domain/update.go b/internal/domain/update.go new file mode 100644 index 00000000..fb36a8e3 --- /dev/null +++ b/internal/domain/update.go @@ -0,0 +1,10 @@ +package domain + +// UpdateState 是账号的 update 状态(pts/qts/seq/date)。 +// 第一阶段空账号为零值;真实状态机属第二阶段。 +type UpdateState struct { + Pts int + Qts int + Date int + Seq int +} diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go new file mode 100644 index 00000000..32ebdbf4 --- /dev/null +++ b/internal/domain/update_event.go @@ -0,0 +1,58 @@ +package domain + +// UpdateEventType 标识 update 队列事件类型。 +type UpdateEventType string + +const ( + UpdateEventNewMessage UpdateEventType = "new_message" + UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox" + UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox" + UpdateEventEditMessage UpdateEventType = "edit_message" + UpdateEventMessageReactions UpdateEventType = "message_reactions" + UpdateEventContactsReset UpdateEventType = "contacts_reset" + UpdateEventDialogPinned UpdateEventType = "dialog_pinned" + UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs" + UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark" + UpdateEventPeerSettings UpdateEventType = "peer_settings" + UpdateEventDeleteMessages UpdateEventType = "delete_messages" + UpdateEventDialogFilter UpdateEventType = "dialog_filter" + UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order" + UpdateEventDialogFilters UpdateEventType = "dialog_filters" + UpdateEventFolderPeers UpdateEventType = "folder_peers" + UpdateEventChannelAvailable UpdateEventType = "channel_available_messages" + UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages" + UpdateEventNoop UpdateEventType = "noop" +) + +// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。 +type UpdateEvent struct { + UserID int64 + Type UpdateEventType + Pts int + PtsCount int + Date int + Message Message + Peer Peer + Peers []Peer + Bool bool + Settings PeerSettings + MessageIDs []int + MaxID int + StillUnreadCount int + Users []User + Channels []Channel + FilterID int + DialogFilter *DialogFolder + FilterOrder []int + FolderPeers []FolderPeerUpdate + TagsEnabled bool +} + +// UpdateDifference 是 updates.getDifference 的业务层结果。 +type UpdateDifference struct { + State UpdateState + Events []UpdateEvent + // Partial 为 true 表示连续事件被 limit 截断、后面还有(映射 updates.differenceSlice, + // 客户端据 State 继续翻页);false 表示已到当前连续末尾(updates.difference)。 + Partial bool +} diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 00000000..fccf5ea7 --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,63 @@ +package domain + +// UserIDSequenceBase 是普通用户 ID 的起始值。 +// +// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。 +// 777000 等兼容系统账号低于该区间,业务注册用户从这里开始递增。 +const UserIDSequenceBase int64 = 1780243200 + +// User 是一个账号。第一阶段仅保留登录链路必须字段; +// access_hash 为任何 InputUser 校验所必须,不可省。 +type User struct { + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + About string + Username string + CountryCode string + Verified bool + Support bool + Contact bool + Mutual bool + // Profile photo:反范式存于 users 表,便于无 join 渲染头像。PhotoID==0 表示无头像。 + PhotoID int64 + PhotoDCID int + PhotoStripped []byte + LastSeenAt int + Status UserStatus +} + +// UserStatusKind is a protocol-neutral account presence state. +type UserStatusKind int + +const ( + UserStatusUnknown UserStatusKind = iota + UserStatusOnline + UserStatusOffline + UserStatusRecently + UserStatusLastWeek + UserStatusLastMonth + UserStatusEmpty +) + +// UserStatus describes the currently visible presence state for a user. +// +// Expires and WasOnline are absolute Unix timestamps in seconds, matching +// Telegram's UserStatus semantics without leaking tg.* into domain. +type UserStatus struct { + Kind UserStatusKind + Expires int + WasOnline int +} + +// UserProfileUpdate 描述 account.updateProfile 的可选字段更新。 +type UserProfileUpdate struct { + FirstName string + HasFirstName bool + LastName string + HasLastName bool + About string + HasAbout bool +} diff --git a/internal/domain/user_errors.go b/internal/domain/user_errors.go new file mode 100644 index 00000000..8930b15d --- /dev/null +++ b/internal/domain/user_errors.go @@ -0,0 +1,12 @@ +package domain + +import "errors" + +var ( + ErrUsernameInvalid = errors.New("username invalid") + ErrUsernameOccupied = errors.New("username occupied") + ErrUsernameNotOccupied = errors.New("username not occupied") + ErrPhoneNotOccupied = errors.New("phone not occupied") + ErrFirstNameInvalid = errors.New("first name invalid") + ErrAboutTooLong = errors.New("about too long") +) diff --git a/internal/loadtest/doc.go b/internal/loadtest/doc.go new file mode 100644 index 00000000..4bb1f3ca --- /dev/null +++ b/internal/loadtest/doc.go @@ -0,0 +1,13 @@ +// Package loadtest 承载 message 模块的压测基线。 +// +// 这里只放 env-gated 的压测用例(真实 PostgreSQL + Redis),不被生产代码 import。 +// 目标与基线方法见 docs/message-module.md 的「Load Baseline」一节。 +// +// 运行方式(PowerShell): +// +// $env:TELESRV_TEST_POSTGRES_DSN = "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable" +// $env:TELESRV_TEST_REDIS_ADDR = "localhost:6399" +// go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 +// +// 未设置上述两个环境变量时用例直接 Skip,因此对默认 `go test ./...` 无副作用。 +package loadtest diff --git a/internal/loadtest/send_load_test.go b/internal/loadtest/send_load_test.go new file mode 100644 index 00000000..6d836b1f --- /dev/null +++ b/internal/loadtest/send_load_test.go @@ -0,0 +1,478 @@ +package loadtest + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "math" + "os" + "sort" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + messageapp "telesrv/internal/app/messages" + "telesrv/internal/domain" + "telesrv/internal/mtprotoedge" + "telesrv/internal/rpc" + "telesrv/internal/store/postgres" + "telesrv/internal/store/redisstore" +) + +// 第一阶段单机 SLO 目标,来自 docs/message-module.md 的 Next Execution Plan。 +// 默认仅作为信息打印;设 TELESRV_LOAD_ENFORCE_SLO=1 时超标会 fail(用于回归门禁)。 +const ( + sloSendP99 = 150 * time.Millisecond + sloDiffP99 = 100 * time.Millisecond + sloThroughput = 200.0 // msg/s + drainTimeout = 60 * time.Second + diffSampleGoal = 500 +) + +// TestMessageSendBaseline 用真实 PostgreSQL + Redis 压测私聊文本发送热路径, +// 并发跑 outbox dispatcher 排空在线推送,最后采样 getDifference 读路径。 +// +// 这是 closed-loop 饱和压测:concurrency 个 worker 各自不停发,直到发满 messages 条。 +// 吞吐 = messages / wallclock,延迟分位反映该并发下的饱和延迟。 +// 固定到达率(open-loop)的版本留作后续细化(见 docs/message-module.md)。 +func TestMessageSendBaseline(t *testing.T) { + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + redisAddr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if dsn == "" || redisAddr == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN and TELESRV_TEST_REDIS_ADDR to run message load baseline") + } + + // 默认用户池取较大值:用户太少会把写集中到少数 dialog/message_box 行造成行锁争用, + // 拉高 send 尾延迟(这是小池假象,生产 20 万用户分散后争用极低)。 + users := envInt("TELESRV_LOAD_USERS", 1000) + if users < 2 { + users = 2 + } + concurrency := envInt("TELESRV_LOAD_CONCURRENCY", 32) + if concurrency < 1 { + concurrency = 1 + } + totalMsgs := envInt("TELESRV_LOAD_MESSAGES", 5000) + if totalMsgs < 1 { + totalMsgs = 1 + } + poolConns := envInt("TELESRV_LOAD_POOL_CONNS", 64) + workers := envInt("TELESRV_OUTBOX_WORKERS", 8) + outboxBatch := envInt("TELESRV_OUTBOX_BATCH", 100) + outboxInterval := envDuration("TELESRV_OUTBOX_INTERVAL", 50*time.Millisecond) + leaseTimeout := envDuration("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second) + enforceSLO := os.Getenv("TELESRV_LOAD_ENFORCE_SLO") == "1" + deferDispatch := os.Getenv("TELESRV_LOAD_DEFER_DISPATCH") == "1" + + ctx := context.Background() + if err := postgres.Migrate(dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := postgres.Open(ctx, dsn, postgres.WithMaxConns(poolConns)) + if err != nil { + t.Fatalf("open postgres: %v", err) + } + t.Cleanup(pool.Close) + + rdb, err := redisstore.Open(ctx, redisAddr, os.Getenv("TELESRV_TEST_REDIS_PASSWORD"), 0) + if err != nil { + t.Fatalf("open redis: %v", err) + } + t.Cleanup(func() { _ = rdb.Close() }) + + // 装配与 main.go 一致的消息热路径:Redis 分配器 + PG 消息存储 + transactional outbox。 + userStore := postgres.NewUserStore(pool) + updateEventStore := postgres.NewUpdateEventStore(pool) + dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(leaseTimeout)) + dialogStore := postgres.NewDialogStore(pool) + ptsAllocator := redisstore.NewPtsAllocator(rdb, updateEventStore) + boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) + messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator)) + svc := messageapp.NewService(messageStore, dialogStore) + + // 创建独立的测试用户池;用随机 salt 隔离历史残留,结束按 FK 依赖序清理。 + ids := seedUsers(t, ctx, userStore, users) + t.Cleanup(func() { cleanup(t, pool, rdb, ids) }) + + // 在线推送 binder 用真实 SessionManager(零连接),PushToUserExceptSession 返回 0, + // 让 outbox 走完整 claim→ListAfter→MarkDelivered 的 PG 往返,测排空而非网络 fanout。 + binder := mtprotoedge.NewSessionManager(zap.NewNop()) + metrics := &loadMetrics{} + dispatcher := rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, binder, zap.NewNop(), + rpc.WithOutboxWorkers(workers), + rpc.WithOutboxBatch(outboxBatch), + rpc.WithOutboxInterval(outboxInterval), + rpc.WithOutboxMetrics(metrics), + ) + dispCtx, stopDispatcher := context.WithCancel(ctx) + dispDone := make(chan struct{}) + startDispatcher := func() { + go func() { + dispatcher.Run(dispCtx) + close(dispDone) + }() + } + // deferDispatch=1 时先不投递,让发送把积压攒满,再在发送结束后启动 dispatcher, + // 以隔离测量「纯排空上限」(否则 dispatcher 实时跟上发送、积压近 0 量不出天花板)。 + if !deferDispatch { + startDispatcher() + } + + // 后台采样 outbox 积压(pending+dispatching),记录运行期峰值。 + var maxBacklog atomic.Int64 + sampleCtx, stopSampler := context.WithCancel(ctx) + sampleDone := make(chan struct{}) + go func() { + defer close(sampleDone) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-sampleCtx.Done(): + return + case <-ticker.C: + if n := backlog(ctx, pool, ids); n > maxBacklog.Load() { + maxBacklog.Store(n) + } + } + } + }() + + // 随机 RandomID 基址,保证 (sender,random_id) 幂等唯一且跨重跑不撞。 + randBase := int64(randomUint64(t) & 0x7fff_ffff_ffff) + nowUnix := int(time.Now().Unix()) + body := "telesrv load baseline message body" + + perWorkerLat := make([][]time.Duration, concurrency) + var sent, dup, sendErr atomic.Int64 + var counter atomic.Int64 + + // 预热:先并发发一批不计时的消息,热连接池(MinConns→MaxConns)与 PG plan 缓存, + // 让后续计时窗口反映稳态而非冷启动尾延迟(池过冷时首批 32 并发会挤少量连接)。 + warmup := envInt("TELESRV_LOAD_WARMUP", min(users*10, 1000)) + if warmup > 0 { + var wwg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wwg.Add(1) + go func() { + defer wwg.Done() + for { + n := counter.Add(1) + if n > int64(warmup) { + return + } + sid := ids[(n-1)%int64(users)] + rid := ids[n%int64(users)] + _, _ = svc.SendPrivateText(ctx, sid, domain.SendPrivateTextRequest{ + SenderUserID: sid, + RecipientUserID: rid, + RandomID: randBase + n, + Message: body, + Date: nowUnix, + }) + } + }() + } + wwg.Wait() + counter.Store(int64(warmup)) + } + + start := time.Now() + var wg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + lat := make([]time.Duration, 0, totalMsgs/concurrency+1) + for { + n := counter.Add(1) + if n > int64(warmup+totalMsgs) { + break + } + senderID := ids[(n-1)%int64(users)] + recipientID := ids[n%int64(users)] + req := domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: randBase + n, + Message: body, + Date: nowUnix, + } + t0 := time.Now() + res, err := svc.SendPrivateText(ctx, senderID, req) + lat = append(lat, time.Since(t0)) + if err != nil { + sendErr.Add(1) + continue + } + if res.Duplicate { + dup.Add(1) + } + sent.Add(1) + } + perWorkerLat[w] = lat + }(w) + } + wg.Wait() + sendWall := time.Since(start) + deliveredAtSendEnd := metrics.delivered.Load() + if deferDispatch { + // 发送已把全部积压攒满,此刻才启动 dispatcher:drain 阶段即纯排空,drainRate 反映排空上限。 + startDispatcher() + } + + // 等 outbox 排空(积压回 0),记录排空耗时,再停采样和 dispatcher。 + // 用「发送结束后」单独排空的速率隔离 dispatcher 吞吐,去掉发送期对 PG 的争用。 + drainStart := time.Now() + drained := waitDrain(ctx, pool, ids, drainTimeout) + drainWall := time.Since(drainStart) + drainRate := float64(metrics.delivered.Load()-deliveredAtSendEnd) / drainWall.Seconds() + stopSampler() + <-sampleDone + stopDispatcher() + <-dispDone + + // 合并发送延迟样本并排序。 + latencies := make([]time.Duration, 0, totalMsgs) + for _, l := range perWorkerLat { + latencies = append(latencies, l...) + } + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + + // 采样 getDifference 读路径(ListAfter 从 pts=0 拉账号事件)。 + diffLat := sampleGetDifference(t, ctx, updateEventStore, ids) + sort.Slice(diffLat, func(i, j int) bool { return diffLat[i] < diffLat[j] }) + + okSent := sent.Load() + throughput := float64(okSent) / sendWall.Seconds() + sendP99 := percentile(latencies, 99) + diffP99 := percentile(diffLat, 99) + + t.Logf("==== message module load baseline ====") + t.Logf("config: users=%d concurrency=%d messages=%d pool=%d outbox(workers=%d batch=%d interval=%s lease=%s)", + users, concurrency, totalMsgs, poolConns, workers, outboxBatch, outboxInterval, leaseTimeout) + t.Logf("send: %d ok, %d dup, %d err in %s -> %.0f msg/s", + okSent, dup.Load(), sendErr.Load(), sendWall.Round(time.Millisecond), throughput) + t.Logf("send.lat p50=%s p90=%s p99=%s max=%s", + percentile(latencies, 50).Round(time.Microsecond), + percentile(latencies, 90).Round(time.Microsecond), + sendP99.Round(time.Microsecond), + percentile(latencies, 100).Round(time.Microsecond)) + t.Logf("outbox: delivered=%d failed=%d claimed=%d maxBacklog=%d drain=%s drainRate=%.0f rows/s drained=%v", + metrics.delivered.Load(), metrics.failed.Load(), metrics.claimed.Load(), + maxBacklog.Load(), drainWall.Round(time.Millisecond), drainRate, drained) + t.Logf("getDiff: samples=%d p50=%s p90=%s p99=%s max=%s", + len(diffLat), + percentile(diffLat, 50).Round(time.Microsecond), + percentile(diffLat, 90).Round(time.Microsecond), + diffP99.Round(time.Microsecond), + percentile(diffLat, 100).Round(time.Microsecond)) + t.Logf("SLO: send.p99 %s(<%s) %s | getDiff.p99 %s(<%s) %s | throughput %.0f(>=%.0f) %s", + sendP99.Round(time.Millisecond), sloSendP99, pass(sendP99 < sloSendP99), + diffP99.Round(time.Millisecond), sloDiffP99, pass(diffP99 < sloDiffP99), + throughput, sloThroughput, pass(throughput >= sloThroughput)) + t.Logf("=======================================") + + // 正确性硬断言:发送不应出错、不应有意外重复、outbox 必须排空且无终态失败。 + if sendErr.Load() != 0 { + t.Fatalf("send errors = %d, want 0", sendErr.Load()) + } + if dup.Load() != 0 { + t.Fatalf("duplicates = %d, want 0 (random_id 应唯一)", dup.Load()) + } + if okSent != int64(totalMsgs) { + t.Fatalf("sent ok = %d, want %d", okSent, totalMsgs) + } + if !drained { + t.Fatalf("outbox 未在 %s 内排空,残留积压 %d", drainTimeout, backlog(ctx, pool, ids)) + } + if metrics.failed.Load() != 0 { + t.Fatalf("outbox failed = %d, want 0", metrics.failed.Load()) + } + + // 性能 SLO:默认信息化,门禁模式(TELESRV_LOAD_ENFORCE_SLO=1)才硬失败。 + if enforceSLO { + if sendP99 >= sloSendP99 { + t.Errorf("send p99 %s >= SLO %s", sendP99, sloSendP99) + } + if diffP99 >= sloDiffP99 { + t.Errorf("getDifference p99 %s >= SLO %s", diffP99, sloDiffP99) + } + if throughput < sloThroughput { + t.Errorf("throughput %.0f msg/s < SLO %.0f msg/s", throughput, sloThroughput) + } + } +} + +// loadMetrics 实现 rpc.Metrics,统计 outbox claim/deliver/fail。 +type loadMetrics struct { + claimed atomic.Int64 + delivered atomic.Int64 + failed atomic.Int64 +} + +func (m *loadMetrics) MessageSend(time.Duration, bool, error) {} +func (m *loadMetrics) MessageRateLimited(int) {} +func (m *loadMetrics) OutboxClaimed(n int) { m.claimed.Add(int64(n)) } +func (m *loadMetrics) OutboxDelivered(time.Duration) { m.delivered.Add(1) } +func (m *loadMetrics) OutboxFailed(error) { m.failed.Add(1) } + +func seedUsers(t *testing.T, ctx context.Context, store *postgres.UserStore, n int) []int64 { + t.Helper() + salt := randomUint64(t) % 1_000_000 + ids := make([]int64, n) + errs := make([]error, n) + sem := make(chan struct{}, 16) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + sem <- struct{}{} + go func(i int) { + defer wg.Done() + defer func() { <-sem }() + u, err := store.Create(ctx, domain.User{ + AccessHash: int64(i + 1), + Phone: fmt.Sprintf("+1555%06d%05d", salt, i), + FirstName: fmt.Sprintf("Load%05d", i), + }) + if err != nil { + errs[i] = err + return + } + ids[i] = u.ID + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("create load user %d: %v", i, err) + } + } + return ids +} + +// cleanup 按 FK 依赖序删除测试数据:outbox→events→boxes→private_messages→dialogs→users, +// 再清 Redis pts/box_id 计数。message_boxes.from_user_id 为 ON DELETE RESTRICT,必须先删盒子。 +// cleanup 在断言之后运行,出错只告警不影响已得结果。 +func cleanup(t *testing.T, pool *pgxpool.Pool, rdb *redis.Client, ids []int64) { + t.Helper() + ctx := context.Background() + stmts := []string{ + "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", + "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", + "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", + "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", + "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", + "DELETE FROM users WHERE id = ANY($1::bigint[])", + } + for _, sql := range stmts { + if _, err := pool.Exec(ctx, sql, ids); err != nil { + t.Logf("cleanup %q: %v", sql, err) + } + } + keys := make([]string, 0, len(ids)*2) + for _, id := range ids { + keys = append(keys, + fmt.Sprintf("counter:pts:{%d}", id), + fmt.Sprintf("counter:box_id:{%d}", id), + ) + } + if err := rdb.Del(ctx, keys...).Err(); err != nil { + t.Logf("cleanup redis counters: %v", err) + } +} + +// backlog 返回测试用户集当前未投递(pending+dispatching)的 outbox 行数。 +func backlog(ctx context.Context, pool *pgxpool.Pool, ids []int64) int64 { + var n int64 + if err := pool.QueryRow(ctx, + "SELECT count(*) FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[]) AND status IN ('pending','dispatching')", + ids, + ).Scan(&n); err != nil { + return -1 + } + return n +} + +// waitDrain 轮询 backlog 直到归零或超时,返回是否排空。 +func waitDrain(ctx context.Context, pool *pgxpool.Pool, ids []int64, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + if backlog(ctx, pool, ids) == 0 { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(50 * time.Millisecond) + } +} + +func sampleGetDifference(t *testing.T, ctx context.Context, events *postgres.UpdateEventStore, ids []int64) []time.Duration { + t.Helper() + out := make([]time.Duration, 0, diffSampleGoal) + for i := 0; len(out) < diffSampleGoal; i++ { + id := ids[i%len(ids)] + t0 := time.Now() + if _, err := events.ListAfter(ctx, id, 0, 100); err != nil { + t.Fatalf("getDifference ListAfter user %d: %v", id, err) + } + out = append(out, time.Since(t0)) + } + return out +} + +func percentile(sorted []time.Duration, p float64) time.Duration { + if len(sorted) == 0 { + return 0 + } + idx := int(math.Ceil(p/100*float64(len(sorted)))) - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +func pass(ok bool) string { + if ok { + return "PASS" + } + return "WARN" +} + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func envDuration(key string, def time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return def +} + +func randomUint64(t *testing.T) uint64 { + t.Helper() + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatalf("rand: %v", err) + } + return binary.LittleEndian.Uint64(b[:]) +} diff --git a/internal/mtprotoedge/conn.go b/internal/mtprotoedge/conn.go new file mode 100644 index 00000000..fe32acdf --- /dev/null +++ b/internal/mtprotoedge/conn.go @@ -0,0 +1,114 @@ +package mtprotoedge + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/proto" + "github.com/gotd/td/transport" +) + +// Conn 是一个已识别 session 的客户端连接,持有向其加密发送消息所需的全部上下文。 +// 由 SessionManager 管理,供请求响应与主动 push 共用。 +// +// Send 并发安全:所有出站消息先进 per-Conn outbound actor,由它串行分配 msg_id/seq_no、 +// 加密并写 transport,避免高并发 RPC 响应与 push 交错造成 MTProto 顺序错误。 +type outboundWriter interface { + Send(context.Context, *bin.Buffer) error +} + +type Conn struct { + transport transport.Conn + writer outboundWriter + cipher crypto.Cipher + msgID *proto.MessageIDGen + writeTimeout time.Duration + metrics Metrics + + authKeyID [8]byte + sessionID int64 + salt int64 + key crypto.AuthKey + + outbound chan outboundOp + outboundControl chan outboundOp + outboundStop chan struct{} + outboundDone chan struct{} + outboundClose sync.Once + + rpcQueue chan inboundRPC + rpcStop chan struct{} + rpcCancel context.CancelFunc + rpcClose sync.Once + rpcWG sync.WaitGroup + rpcTimeout time.Duration + // inflightRPCBytes 跟踪已入队未完成的 inbound RPC body 总字节,配合 maxInflightRPCBytes + // 给 RPC 队列设字节预算(不止限条数),防对抗客户端发大请求撑内存。 + inflightRPCBytes atomic.Int64 + // RPC worker 懒启动:首个 RPC 入队时才起 worker(ensureInboundRPCWorkers), + // 避免握手后静默 / 纯推送目标连接白白钉住 rpcMaxInflight 个 goroutine。 + rpcRootCtx context.Context + rpcMaxInflight int + rpcWorkersOnce sync.Once + + // sentContentMessages 只由 outbound actor 访问,用于生成 MTProto seq_no。 + sentContentMessages int32 + + identityMu sync.RWMutex + businessAuthKeyID [8]byte + businessAuthKeyResolved bool + userID atomic.Int64 + userIDResolved atomic.Bool + receivesUpdates atomic.Bool +} + +// AuthKeyID 返回连接的 auth_key_id。 +func (c *Conn) AuthKeyID() [8]byte { return c.authKeyID } + +// BusinessAuthKeyID 返回业务视角的 auth_key_id。 +// +// temp auth_key 绑定后解析为 perm auth_key;第二个返回值表示本连接是否已完成解析, +// 即便解析结果等于原始 auth_key_id 也会返回 true,以避免每个 RPC 重复查绑定表。 +func (c *Conn) BusinessAuthKeyID() ([8]byte, bool) { + c.identityMu.RLock() + defer c.identityMu.RUnlock() + return c.businessAuthKeyID, c.businessAuthKeyResolved +} + +// SetBusinessAuthKeyID 缓存业务视角 auth_key_id。 +func (c *Conn) SetBusinessAuthKeyID(id [8]byte) { + c.identityMu.Lock() + changed := !c.businessAuthKeyResolved || c.businessAuthKeyID != id + c.businessAuthKeyID = id + c.businessAuthKeyResolved = true + c.identityMu.Unlock() + if changed { + c.userID.Store(0) + c.userIDResolved.Store(false) + } +} + +// SessionID 返回连接的 session_id。 +func (c *Conn) SessionID() int64 { return c.sessionID } + +// UserID 返回绑定的用户 id;未登录为 0。 +func (c *Conn) UserID() int64 { return c.userID.Load() } + +// UserIDResolved 返回 user_id 授权状态是否已为当前连接解析过。 +// +// resolved=true 且 userID=0 表示该 auth_key 当前未登录;这样登录前的多次 RPC +// 不会反复查询授权表,后续登录成功会由 BindUser 覆盖为真实用户。 +func (c *Conn) UserIDResolved() (userID int64, resolved bool) { + return c.userID.Load(), c.userIDResolved.Load() +} + +// ReceivesUpdates 报告该连接是否接收主动推送的 updates。 +func (c *Conn) ReceivesUpdates() bool { return c.receivesUpdates.Load() } + +// SetReceivesUpdates 设置该连接是否接收主动推送的 updates。 +// 登录后的主连接在 updates.getState/getDifference 建立同步基线后置为 true。 +func (c *Conn) SetReceivesUpdates(v bool) { c.receivesUpdates.Store(v) } diff --git a/internal/mtprotoedge/destroy_auth_key.go b/internal/mtprotoedge/destroy_auth_key.go new file mode 100644 index 00000000..98545ca2 --- /dev/null +++ b/internal/mtprotoedge/destroy_auth_key.go @@ -0,0 +1,33 @@ +package mtprotoedge + +import ( + "fmt" + + "github.com/gotd/td/bin" +) + +const ( + destroyAuthKeyRequestTypeID = 0xd1435160 + destroyAuthKeyOkTypeID = 0xf660e1d4 +) + +type destroyAuthKeyRequest struct{} + +func (*destroyAuthKeyRequest) Encode(b *bin.Buffer) error { + b.PutID(destroyAuthKeyRequestTypeID) + return nil +} + +func (*destroyAuthKeyRequest) Decode(b *bin.Buffer) error { + if err := b.ConsumeID(destroyAuthKeyRequestTypeID); err != nil { + return fmt.Errorf("decode destroy_auth_key: %w", err) + } + return nil +} + +type destroyAuthKeyOk struct{} + +func (*destroyAuthKeyOk) Encode(b *bin.Buffer) error { + b.PutID(destroyAuthKeyOkTypeID) + return nil +} diff --git a/internal/mtprotoedge/doc.go b/internal/mtprotoedge/doc.go new file mode 100644 index 00000000..da227d9f --- /dev/null +++ b/internal/mtprotoedge/doc.go @@ -0,0 +1,6 @@ +// Package mtprotoedge 是 MTProto 连接层:TCP/WS listener、密钥交换、auth key 查找与持久化、 +// 消息加解密、session/server-salt/msg-id/ack/container/gzip,以及 invokeWithLayer、initConnection、 +// invokeWithoutUpdates 等 wrapper 的 unwrap。 +// +// 它只把 MTProto 世界转换成「已解密、已识别 session 的 RPC 请求」,不得包含业务逻辑。 +package mtprotoedge diff --git a/internal/mtprotoedge/e2e_test.go b/internal/mtprotoedge/e2e_test.go new file mode 100644 index 00000000..0d572dce --- /dev/null +++ b/internal/mtprotoedge/e2e_test.go @@ -0,0 +1,90 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/clock" + "github.com/gotd/td/exchange" + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/dcs" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/app/auth" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/rpc" + "telesrv/internal/store/memory" +) + +// TestTelegramClientEndToEnd 是连接层的最强端到端验证:用 gotd/td 的完整 +// telegram.Client(而非底层 cipher)连本地 mtprotoedge,client 自动经 +// invokeWithLayer(initConnection(help.getConfig)) 完成初始化,并取得含本地 DC 的 Config。 +func TestTelegramClientEndToEnd(t *testing.T) { + const dc = 2 + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tcpAddr := ln.Addr().(*net.TCPAddr) + + userStore := memory.NewUserStore() + authzStore := memory.NewAuthorizationStore() + authKeyStore := memory.NewAuthKeyStore() + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), "12345"), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + } + router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router}) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + opts := telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), + DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, + Logger: zaptest.NewLogger(t).Named("client"), + SessionStorage: &session.StorageMemory{}, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + client := telegram.NewClient(1, "hash", opts) + + if err := client.Run(ctx, func(ctx context.Context) error { + cfg, err := tg.NewClient(client).HelpGetConfig(ctx) + if err != nil { + return err + } + if cfg.ThisDC != dc { + t.Errorf("config.ThisDC = %d, want %d", cfg.ThisDC, dc) + } + if len(cfg.DCOptions) == 0 { + t.Error("config.DCOptions is empty") + } + return nil + }); err != nil { + t.Fatalf("telegram client run: %v", err) + } + + cancel() + if err := <-serveErr; err != nil { + t.Errorf("serve: %v", err) + } +} diff --git a/internal/mtprotoedge/encrypted.go b/internal/mtprotoedge/encrypted.go new file mode 100644 index 00000000..399eaf7c --- /dev/null +++ b/internal/mtprotoedge/encrypted.go @@ -0,0 +1,702 @@ +package mtprotoedge + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tgerr" + "github.com/gotd/td/transport" + + "telesrv/internal/store" +) + +// connState 是单连接的 MTProto 运行态。 +type connState struct { + sentCreated bool + seen map[int64]clientMsgRecord // 已处理的 client msg_id,用于幂等和 msgs_state_req + order []int64 + minSeen int64 + maxSeen int64 +} + +type clientMsgRecord struct { + state byte + seqNo int32 + content bool +} + +func newConnState() *connState { + return &connState{ + seen: make(map[int64]clientMsgRecord), + minSeen: math.MaxInt64, + } +} + +const ( + maxTrackedClientMsgIDs = 400 + + msgStateUnknown byte = 1 + msgStateNotReceived byte = 2 + msgStateNotReceivedHigh byte = 3 + msgStateReceived byte = 4 + + badMsgIDTooLow = 16 + badMsgIDTooHigh = 17 + badMsgIDInvalidBits = 18 + badMsgSeqTooLow = 32 + badMsgSeqTooHigh = 33 + badMsgSeqNotEven = 34 + badMsgSeqNotOdd = 35 + badMsgContainer = 64 +) + +// handleEncrypted 解密加密消息,按需注册连接,处理服务消息并分发明文 payload。 +// 返回(可能新建/更新的)当前连接对象,供 serveConn 维护生命周期。 +func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, keyData store.AuthKeyData, b *bin.Buffer) (*Conn, error) { + key := crypto.AuthKey{Value: crypto.Key(keyData.Value), ID: keyData.ID} + + data, err := s.cipher.DecryptFromBuffer(key, b) + if err != nil { + return current, fmt.Errorf("decrypt: %w", err) + } + + if data.Salt != keyData.ServerSalt { + c := current + temp := false + if c == nil || c.sessionID != data.SessionID { + c = s.newConn(tc, key, data.SessionID, keyData.ServerSalt) + temp = true + } + err := s.sendBadServerSalt(ctx, c, data.MessageID, data.SeqNo, keyData.ServerSalt) + if temp { + c.Close() + } + return current, err + } + + // 首个加密消息或 session 变化时(重新)注册连接到 SessionManager。 + if current == nil || current.sessionID != data.SessionID { + if current != nil { + s.conns.Unregister(current) + current.Close() + } + current = s.newConn(tc, key, data.SessionID, keyData.ServerSalt) + s.conns.Register(current) + } + + if err := s.sessions.Save(ctx, store.SessionData{ + ID: data.SessionID, + AuthKeyID: key.ID, + Salt: keyData.ServerSalt, + LastSeen: s.clock.Now().Unix(), + }); err != nil { + return current, fmt.Errorf("save session: %w", err) + } + + body := data.Data() + typeID, err := (&bin.Buffer{Buf: body}).PeekID() + if err != nil { + return current, fmt.Errorf("peek encrypted payload type id: %w", err) + } + if code := validateClientEnvelope(s.clock.Now(), data.MessageID, data.SeqNo, typeID); code != 0 { + s.log.Debug("Sending bad_msg_notification", + zap.Int64("msg_id", data.MessageID), + zap.Int32("seq_no", data.SeqNo), + zap.Uint32("type_id", typeID), + zap.Int("code", code), + ) + return current, s.sendBadMsg(ctx, current, data.MessageID, data.SeqNo, code) + } + + content := clientMessageNeedsAck(typeID) + if record, ok := cs.seenRecord(data.MessageID); ok { + s.log.Debug("Duplicate msg_id; re-ack only", zap.Int64("msg_id", data.MessageID)) + if resent, err := current.ResendByRequest(ctx, data.MessageID); err != nil { + return current, err + } else if resent { + s.log.Debug("Resent cached rpc_result for duplicate msg_id", zap.Int64("msg_id", data.MessageID)) + } + if !record.content { + return current, nil + } + return current, s.sendAck(ctx, current, data.MessageID) + } + if code := cs.validateSeq(data.MessageID, data.SeqNo, content); code != 0 { + s.log.Debug("Sending bad_msg_notification", + zap.Int64("msg_id", data.MessageID), + zap.Int32("seq_no", data.SeqNo), + zap.Uint32("type_id", typeID), + zap.Int("code", code), + ) + return current, s.sendBadMsg(ctx, current, data.MessageID, data.SeqNo, code) + } + cs.track(data.MessageID, data.SeqNo, content, msgStateReceived) + + if !cs.sentCreated { + cs.sentCreated = true + s.log.Debug("Sending new_session_created", zap.Int64("msg_id", data.MessageID), zap.Int32("seq_no", data.SeqNo)) + if err := s.sendNewSessionCreated(ctx, current, data.MessageID); err != nil { + return current, err + } + } + + var acks []int64 + if err := s.dispatch(ctx, cs, current, data.MessageID, data.SeqNo, &bin.Buffer{Buf: body}, &acks); err != nil { + return current, err + } + if len(acks) > 0 { + if err := s.sendAck(ctx, current, acks...); err != nil { + return current, err + } + } + return current, nil +} + +// dispatch 处理一条明文消息:解包 container/gzip,处理服务消息,其余转 RPC 路由。 +// content-related 消息(ping、RPC)的 msg_id 会收集到 acks 以便统一确认。 +func (s *Server) dispatch(ctx context.Context, cs *connState, c *Conn, msgID int64, seqNo int32, b *bin.Buffer, acks *[]int64) error { + id, err := b.PeekID() + if err != nil { + return fmt.Errorf("peek type id: %w", err) + } + ackContent := func() { + if clientMessageNeedsAck(id) { + *acks = append(*acks, msgID) + } + } + + switch id { + case proto.GZIPTypeID: + var gz proto.GZIP + if err := gz.Decode(b); err != nil { + return fmt.Errorf("decode gzip: %w", err) + } + return s.dispatch(ctx, cs, c, msgID, seqNo, &bin.Buffer{Buf: gz.Data}, acks) + + case proto.MessageContainerTypeID: + var container proto.MessageContainer + if err := container.Decode(b); err != nil { + return fmt.Errorf("decode container: %w", err) + } + if code := validateClientContainer(msgID, seqNo, container); code != 0 { + return s.sendBadMsg(ctx, c, msgID, seqNo, code) + } + for i := range container.Messages { + m := container.Messages[i] + typeID, err := (&bin.Buffer{Buf: m.Body}).PeekID() + if err != nil { + return fmt.Errorf("peek container message type id: %w", err) + } + content := clientMessageNeedsAck(typeID) + if record, ok := cs.seenRecord(m.ID); ok { + if record.content { + *acks = append(*acks, m.ID) + } + continue + } + if code := cs.validateSeq(m.ID, int32(m.SeqNo), content); code != 0 { + return s.sendBadMsg(ctx, c, m.ID, int32(m.SeqNo), code) + } + cs.track(m.ID, int32(m.SeqNo), content, msgStateReceived) + if err := s.dispatch(ctx, cs, c, m.ID, int32(m.SeqNo), &bin.Buffer{Buf: m.Body}, acks); err != nil { + return err + } + } + return nil + + case mt.PingRequestTypeID: + var ping mt.PingRequest + if err := ping.Decode(b); err != nil { + return fmt.Errorf("decode ping: %w", err) + } + ackContent() + return s.sendPong(ctx, c, msgID, ping.PingID) + + case mt.PingDelayDisconnectRequestTypeID: + var ping mt.PingDelayDisconnectRequest + if err := ping.Decode(b); err != nil { + return fmt.Errorf("decode ping_delay_disconnect: %w", err) + } + ackContent() + return s.sendPong(ctx, c, msgID, ping.PingID) + + case mt.GetFutureSaltsRequestTypeID: + var req mt.GetFutureSaltsRequest + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode get_future_salts: %w", err) + } + ackContent() + return s.sendFutureSalts(ctx, c, msgID, req.Num) + + case mt.MsgsAckTypeID: + var ack mt.MsgsAck + if err := ack.Decode(b); err != nil { + return fmt.Errorf("decode msgs_ack: %w", err) + } + c.AckServerMessages(ack.MsgIDs) + s.log.Debug("Received msgs_ack", zap.Int64s("msg_ids", ack.MsgIDs)) + return nil + + case mt.MsgsStateReqTypeID: + var req mt.MsgsStateReq + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode msgs_state_req: %w", err) + } + ackContent() + outgoing, err := c.OutgoingStateInfo(ctx, req.MsgIDs) + if err != nil { + return err + } + return s.sendMsgsStateInfo(ctx, c, msgID, mergeStateInfo(outgoing, cs.stateInfo(req.MsgIDs))) + + case mt.MsgResendReqTypeID: + var req mt.MsgResendReq + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode msg_resend_req: %w", err) + } + ackContent() + outgoing, err := c.ResendMessages(ctx, req.MsgIDs) + if err != nil { + return err + } + return s.sendMsgsStateInfo(ctx, c, msgID, mergeStateInfo(outgoing, cs.stateInfo(req.MsgIDs))) + + case mt.MsgsStateInfoTypeID: + var info mt.MsgsStateInfo + if err := info.Decode(b); err != nil { + return fmt.Errorf("decode msgs_state_info: %w", err) + } + s.log.Debug("Received msgs_state_info", zap.Int64("req_msg_id", info.ReqMsgID), zap.Int("len", len(info.Info))) + return nil + + case mt.MsgsAllInfoTypeID: + var info mt.MsgsAllInfo + if err := info.Decode(b); err != nil { + return fmt.Errorf("decode msgs_all_info: %w", err) + } + s.log.Debug("Received msgs_all_info", zap.Int("msg_ids", len(info.MsgIDs)), zap.Int("len", len(info.Info))) + return nil + + case mt.DestroySessionRequestTypeID: + var req mt.DestroySessionRequest + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode destroy_session: %w", err) + } + ackContent() + return s.sendDestroySession(ctx, c, req.SessionID) + + case mt.HTTPWaitRequestTypeID: + var req mt.HTTPWaitRequest + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode http_wait: %w", err) + } + s.log.Debug("Received http_wait", + zap.Int("max_delay", req.MaxDelay), + zap.Int("wait_after", req.WaitAfter), + zap.Int("max_wait", req.MaxWait), + ) + return nil + + case mt.RPCDropAnswerRequestTypeID: + var req mt.RPCDropAnswerRequest + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode rpc_drop_answer: %w", err) + } + ackContent() + s.log.Debug("Received rpc_drop_answer", zap.Int64("req_msg_id", req.ReqMsgID)) + return s.sendResult(ctx, c, msgID, &mt.RPCAnswerUnknown{}) + + case destroyAuthKeyRequestTypeID: + var req destroyAuthKeyRequest + if err := req.Decode(b); err != nil { + return err + } + ackContent() + s.log.Debug("Received destroy_auth_key", zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:]))) + return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyOk{}) + + default: + ackContent() + body := b.Copy() + return s.enqueueRPC(ctx, c, msgID, body) + } +} + +func mergeStateInfo(primary, fallback []byte) []byte { + if len(primary) == 0 { + return fallback + } + info := make([]byte, len(fallback)) + copy(info, fallback) + for i, state := range primary { + if i >= len(info) { + break + } + if state != 0 { + info[i] = state + } + } + return info +} + +func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, body []byte) error { + id, _ := (&bin.Buffer{Buf: body}).PeekID() + method := s.typeName(id) + err := c.enqueueInboundRPC(ctx, inboundRPC{ + method: method, + size: len(body), + run: func(taskCtx context.Context) error { + // body 已是 enqueueRPC 入参的独立副本(dispatch 里 b.Copy()),且每个任务只 run 一次, + // 无需再 append 拷贝;直接复用,省掉一份 inbound 在途内存。 + if err := s.handleRPC(taskCtx, c, msgID, &bin.Buffer{Buf: body}); err != nil { + s.log.Info("RPC async handler failed", + zap.Int64("msg_id", msgID), + zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), + zap.Int64("session_id", c.sessionID), + zap.Error(err), + ) + return err + } + return nil + }, + }) + if errors.Is(err, ErrInboundRPCQueueFull) { + s.log.Debug("Inbound RPC queue full", + zap.String("method", method), + zap.Int64("msg_id", msgID), + zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), + zap.Int64("session_id", c.sessionID), + ) + return s.sendResult(ctx, c, msgID, &mt.RPCError{ + ErrorCode: 420, + ErrorMessage: "FLOOD_WAIT_1", + }) + } + return err +} + +// handleRPC 把明文 RPC 请求交给 RPC 路由,并将结果或错误包成 rpc_result 回发。 +func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, b *bin.Buffer) error { + id, _ := b.PeekID() + method := s.typeName(id) + if s.rpc == nil { + s.log.Warn("No RPC handler configured; dropping request", zap.String("method", method)) + return nil + } + + start := s.clock.Now() + result, err := s.rpc.Dispatch(ctx, c.authKeyID, c.sessionID, b) + dur := s.clock.Now().Sub(start) + s.metrics.RPCHandled(method, dur, err) + + fields := []zap.Field{ + zap.String("method", method), + zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), + zap.Int64("session_id", c.sessionID), + zap.Int64("msg_id", msgID), + zap.Duration("dur", dur), + } + if businessAuthKeyID, ok := c.BusinessAuthKeyID(); ok { + fields = append(fields, zap.String("business_auth_key_id", hex.EncodeToString(businessAuthKeyID[:]))) + } + if userID := c.UserID(); userID != 0 { + fields = append(fields, zap.Int64("user_id", userID)) + } + + if err != nil { + var rpcErr *tgerr.Error + if errors.As(err, &rpcErr) { + s.log.Info("RPC error", append(fields, zap.Int("code", rpcErr.Code), zap.String("error", rpcErr.Message))...) + return s.sendResult(ctx, c, msgID, &mt.RPCError{ + ErrorCode: rpcErr.Code, + ErrorMessage: rpcErr.Message, + }) + } + s.log.Info("RPC internal error", append(fields, zap.Error(err))...) + return s.sendResult(ctx, c, msgID, &mt.RPCError{ + ErrorCode: 500, + ErrorMessage: "INTERNAL", + }) + } + + s.log.Info("RPC handled", fields...) + return s.sendResult(ctx, c, msgID, result) +} + +// sendResult 把 RPC 结果包成 rpc_result 并加密回发。 +func (s *Server) sendResult(ctx context.Context, c *Conn, reqMsgID int64, result bin.Encoder) error { + var buf bin.Buffer + if err := result.Encode(&buf); err != nil { + return fmt.Errorf("encode rpc result: %w", err) + } + return c.Send(ctx, proto.MessageServerResponse, &proto.Result{ + RequestMessageID: reqMsgID, + Result: buf.Raw(), + }) +} + +// sendPong 回复 mt.PingRequest / mt.PingDelayDisconnectRequest。 +func (s *Server) sendPong(ctx context.Context, c *Conn, reqMsgID, pingID int64) error { + return c.SendAsync(ctx, proto.MessageServerResponse, &mt.Pong{MsgID: reqMsgID, PingID: pingID}) +} + +// sendFutureSalts 回复 MTProto get_future_salts。 +// +// 第一阶段只维护当前 auth key 的权威 server_salt,因此返回当前 salt 的有效窗口。 +// 后续如引入 salt rotation,可在这里扩展为多条未来 salt。 +func (s *Server) sendFutureSalts(ctx context.Context, c *Conn, reqMsgID int64, num int) error { + if num < 0 { + num = 0 + } + if num > 1 { + num = 1 + } + now := int(s.clock.Now().Unix()) + salts := make([]mt.FutureSalt, 0, num) + if num == 1 { + salts = append(salts, mt.FutureSalt{ + ValidSince: now - 300, + ValidUntil: now + 24*60*60, + Salt: c.salt, + }) + } + return c.SendAsync(ctx, proto.MessageServerResponse, &mt.FutureSalts{ + ReqMsgID: reqMsgID, + Now: now, + Salts: salts, + }) +} + +// sendNewSessionCreated 在连接首个加密消息后通知客户端新 session 已建立。 +func (s *Server) sendNewSessionCreated(ctx context.Context, c *Conn, firstMsgID int64) error { + return c.SendAsync(ctx, proto.MessageFromServer, &mt.NewSessionCreated{ + FirstMsgID: firstMsgID, + UniqueID: s.sessionUID, + ServerSalt: c.salt, + }) +} + +// sendAck 确认收到客户端 content-related 消息。 +func (s *Server) sendAck(ctx context.Context, c *Conn, ids ...int64) error { + return c.SendAsync(ctx, proto.MessageFromServer, &mt.MsgsAck{MsgIDs: ids}) +} + +// sendMsgsStateInfo 回复 msgs_state_req/msg_resend_req。 +func (s *Server) sendMsgsStateInfo(ctx context.Context, c *Conn, reqMsgID int64, info []byte) error { + return c.SendAsync(ctx, proto.MessageServerResponse, &mt.MsgsStateInfo{ReqMsgID: reqMsgID, Info: info}) +} + +func (s *Server) sendDestroySession(ctx context.Context, c *Conn, sessionID int64) error { + removed := false + if sessionID != c.sessionID { + removed = s.conns.DestroySessionForAuthKey(c.authKeyID, sessionID) + if err := s.sessions.Delete(ctx, sessionID); err != nil { + s.log.Debug("Delete session record failed", + zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), + zap.Int64("session_id", sessionID), + zap.Error(err), + ) + } + } + if removed { + return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionOk{SessionID: sessionID}) + } + return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionNone{SessionID: sessionID}) +} + +// sendBadMsg 通知客户端消息存在协议层错误(msg_id/seqno 非法)。 +func (s *Server) sendBadMsg(ctx context.Context, c *Conn, badMsgID int64, badSeqno int32, code int) error { + return c.SendAsync(ctx, proto.MessageFromServer, &mt.BadMsgNotification{ + BadMsgID: badMsgID, + BadMsgSeqno: int(badSeqno), + ErrorCode: code, + }) +} + +// sendBadServerSalt 通知客户端修正 server_salt(error_code 48)。 +func (s *Server) sendBadServerSalt(ctx context.Context, c *Conn, badMsgID int64, badSeqno int32, newSalt int64) error { + return c.SendPriority(ctx, proto.MessageFromServer, &mt.BadServerSalt{ + BadMsgID: badMsgID, + BadMsgSeqno: int(badSeqno), + ErrorCode: 48, + NewServerSalt: newSalt, + }) +} + +// typeName 返回 TL TypeID 的可读名称,未知时回退到 hex。 +func (s *Server) typeName(id uint32) string { + if name := s.types.Get(id); name != "" { + return name + } + return fmt.Sprintf("%#x", id) +} + +func validateClientEnvelope(now time.Time, msgID int64, seqNo int32, typeID uint32) int { + if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient { + return badMsgIDInvalidBits + } + msgTime := proto.MessageID(msgID).Time() + if msgTime.Before(now.Add(-300 * time.Second)) { + return badMsgIDTooLow + } + if msgTime.After(now.Add(30 * time.Second)) { + return badMsgIDTooHigh + } + if clientMessageNeedsAck(typeID) { + if seqNo%2 == 0 { + return badMsgSeqNotOdd + } + } else if seqNo%2 != 0 { + return badMsgSeqNotEven + } + return 0 +} + +func validateClientContainer(containerMsgID int64, containerSeqNo int32, container proto.MessageContainer) int { + for _, m := range container.Messages { + if m.ID >= containerMsgID || int32(m.SeqNo) > containerSeqNo { + return badMsgContainer + } + typeID, err := (&bin.Buffer{Buf: m.Body}).PeekID() + if err != nil { + return badMsgContainer + } + if typeID == proto.MessageContainerTypeID { + return badMsgContainer + } + if code := validateClientContainerEnvelope(m.ID, int32(m.SeqNo), typeID); code != 0 { + return badMsgContainer + } + } + return 0 +} + +func validateClientContainerEnvelope(msgID int64, seqNo int32, typeID uint32) int { + if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient { + return badMsgIDInvalidBits + } + if clientMessageNeedsAck(typeID) { + if seqNo%2 == 0 { + return badMsgSeqNotOdd + } + } else if seqNo%2 != 0 { + return badMsgSeqNotEven + } + return 0 +} + +func clientMessageNeedsAck(typeID uint32) bool { + switch typeID { + case proto.MessageContainerTypeID, + mt.MsgsAckTypeID, + mt.HTTPWaitRequestTypeID, + mt.BadMsgNotificationTypeID, + mt.BadServerSaltTypeID, + mt.MsgsAllInfoTypeID, + mt.MsgsStateInfoTypeID, + mt.MsgDetailedInfoTypeID, + mt.MsgNewDetailedInfoTypeID: + return false + default: + return true + } +} + +func (cs *connState) seenRecord(msgID int64) (clientMsgRecord, bool) { + record, ok := cs.seen[msgID] + return record, ok +} + +func (cs *connState) validateSeq(msgID int64, seqNo int32, content bool) int { + if !content { + return 0 + } + for seenMsgID, record := range cs.seen { + if !record.content { + continue + } + if seenMsgID < msgID && record.seqNo >= seqNo { + return badMsgSeqTooLow + } + if seenMsgID > msgID && record.seqNo <= seqNo { + return badMsgSeqTooHigh + } + } + return 0 +} + +func (cs *connState) track(msgID int64, seqNo int32, content bool, state byte) { + cs.seen[msgID] = clientMsgRecord{ + state: state, + seqNo: seqNo, + content: content, + } + cs.order = append(cs.order, msgID) + if msgID < cs.minSeen { + cs.minSeen = msgID + } + if msgID > cs.maxSeen { + cs.maxSeen = msgID + } + if len(cs.order) > maxTrackedClientMsgIDs { + oldest := cs.order[0] + cs.order = cs.order[1:] + delete(cs.seen, oldest) + if oldest == cs.minSeen || oldest == cs.maxSeen { + cs.recomputeRange() + } + } +} + +func (cs *connState) stateInfo(msgIDs []int64) []byte { + info := make([]byte, len(msgIDs)) + if len(cs.seen) == 0 { + for i := range info { + info[i] = msgStateUnknown + } + return info + } + for i, id := range msgIDs { + if id < cs.minSeen { + info[i] = msgStateUnknown + continue + } + if id > cs.maxSeen { + info[i] = msgStateNotReceivedHigh + continue + } + record, ok := cs.seen[id] + if !ok { + info[i] = msgStateNotReceived + continue + } + info[i] = record.state + } + return info +} + +func (cs *connState) recomputeRange() { + cs.minSeen = math.MaxInt64 + cs.maxSeen = 0 + for id := range cs.seen { + if id < cs.minSeen { + cs.minSeen = id + } + if id > cs.maxSeen { + cs.maxSeen = id + } + } + if len(cs.seen) == 0 { + cs.minSeen = math.MaxInt64 + } +} diff --git a/internal/mtprotoedge/encrypted_test.go b/internal/mtprotoedge/encrypted_test.go new file mode 100644 index 00000000..248c6c75 --- /dev/null +++ b/internal/mtprotoedge/encrypted_test.go @@ -0,0 +1,435 @@ +package mtprotoedge + +import ( + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" +) + +// TestEncryptedPingPong 验证 M2/M4:握手后 client 加密 ping, +// server 回 new_session_created + pong + msgs_ack。 +func TestEncryptedPingPong(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + const pingID int64 = 0x1234beef + pingMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncrypted(t, conn, cipher, auth, pingMsgID, &mt.PingRequest{PingID: pingID}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) + mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created") + pongBuf := mustHave(t, replies, mt.PongTypeID, "pong") + + var pong mt.Pong + if err := pong.Decode(pongBuf); err != nil { + t.Fatalf("decode pong: %v", err) + } + if pong.PingID != pingID { + t.Fatalf("pong.PingID = %#x, want %#x", pong.PingID, pingID) + } + if pong.MsgID != pingMsgID { + t.Fatalf("pong.MsgID = %d, want %d (req msg id)", pong.MsgID, pingMsgID) + } +} + +// TestDuplicateMsgIDIdempotent 验证 M4:相同 msg_id 的重复 content 请求被幂等处理, +// server 重发已缓存的 rpc_result,并重新 ack,不重复执行业务。 +func TestDuplicateMsgIDIdempotent(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + msgID := clientMsgID.New(proto.MessageFromClient) + + sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4}) + first := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + mustHave(t, first, proto.ResultTypeID, "first rpc_result") + + // 相同 msg_id —— 幂等:重发已有 rpc_result,并重新 ack。 + sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4}) + second := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + mustHave(t, second, proto.ResultTypeID, "resent rpc_result") + mustHave(t, second, mt.MsgsAckTypeID, "second ack") +} + +// TestGetFutureSalts 验证 MTProto service message get_future_salts 由连接层直接响应, +// 不再落到业务 RPC fallback。 +func TestGetFutureSalts(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.GetFutureSaltsRequest{Num: 32}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.FutureSaltsTypeID) + buf := mustHave(t, replies, mt.FutureSaltsTypeID, "future_salts") + + var salts mt.FutureSalts + if err := salts.Decode(buf); err != nil { + t.Fatalf("decode future_salts: %v", err) + } + if salts.ReqMsgID != reqMsgID { + t.Fatalf("future_salts.req_msg_id = %d, want %d", salts.ReqMsgID, reqMsgID) + } + if len(salts.Salts) != 1 { + t.Fatalf("future_salts len = %d, want 1", len(salts.Salts)) + } + if got := salts.Salts[0].Salt; got != auth.ServerSalt { + t.Fatalf("future salt = %#x, want server salt %#x", got, auth.ServerSalt) + } + if salts.Salts[0].ValidSince > salts.Now || salts.Salts[0].ValidUntil <= salts.Now { + t.Fatalf("future salt validity = [%d,%d], now %d", salts.Salts[0].ValidSince, salts.Salts[0].ValidUntil, salts.Now) + } +} + +// TestMsgsStateReq 验证 MTProto service message msgs_state_req 由连接层直接响应, +// 不再落到业务 RPC fallback。 +func TestMsgsStateReq(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + asked := []int64{reqMsgID, reqMsgID - 4, reqMsgID + 4} + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.MsgsStateReq{MsgIDs: asked}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsStateInfoTypeID) + buf := mustHave(t, replies, mt.MsgsStateInfoTypeID, "msgs_state_info") + + var info mt.MsgsStateInfo + if err := info.Decode(buf); err != nil { + t.Fatalf("decode msgs_state_info: %v", err) + } + if info.ReqMsgID != reqMsgID { + t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID) + } + if len(info.Info) != len(asked) { + t.Fatalf("msgs_state_info len = %d, want %d", len(info.Info), len(asked)) + } + want := []byte{4, 1, 3} + for i, b := range info.Info { + if b != want[i] { + t.Fatalf("msgs_state_info[%d] = %d, want %d", i, b, want[i]) + } + } +} + +// TestMsgResendReq 验证 MTProto msg_resend_req 由连接层按状态查询兜底响应, +// 不会落入业务 RPC fallback。 +func TestMsgResendReq(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + asked := []int64{reqMsgID, reqMsgID - 4, reqMsgID + 4} + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.MsgResendReq{MsgIDs: asked}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsStateInfoTypeID) + buf := mustHave(t, replies, mt.MsgsStateInfoTypeID, "msgs_state_info") + + var info mt.MsgsStateInfo + if err := info.Decode(buf); err != nil { + t.Fatalf("decode msgs_state_info: %v", err) + } + if info.ReqMsgID != reqMsgID { + t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID) + } + if len(info.Info) != len(asked) { + t.Fatalf("msgs_state_info len = %d, want %d", len(info.Info), len(asked)) + } + want := []byte{4, 1, 3} + for i, b := range info.Info { + if b != want[i] { + t.Fatalf("msgs_state_info[%d] = %d, want %d", i, b, want[i]) + } + } +} + +// TestDestroySession 验证 destroy_session 返回 raw DestroySessionRes, +// 避免客户端清理旧 session 时掉到 RPC fallback。 +func TestDestroySession(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + targetSessionID := auth.SessionID + 4 + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.DestroySessionRequest{SessionID: targetSessionID}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.DestroySessionNoneTypeID) + buf := mustHave(t, replies, mt.DestroySessionNoneTypeID, "destroy_session_none") + + var res mt.DestroySessionNone + if err := res.Decode(buf); err != nil { + t.Fatalf("decode destroy_session_none: %v", err) + } + if res.SessionID != targetSessionID { + t.Fatalf("destroy_session_none.session_id = %d, want %d", res.SessionID, targetSessionID) + } +} + +// TestRPCDropAnswer 验证 rpc_drop_answer 以 rpc_result 包装 RpcDropAnswer 返回, +// 与 gotd/td 和 TDesktop 的请求/响应模型对齐。 +func TestRPCDropAnswer(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + droppedReqID := reqMsgID - 4 + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.RPCDropAnswerRequest{ReqMsgID: droppedReqID}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID) + buf := mustHave(t, replies, proto.ResultTypeID, "rpc_result") + + var result proto.Result + if err := result.Decode(buf); err != nil { + t.Fatalf("decode rpc_result: %v", err) + } + if result.RequestMessageID != reqMsgID { + t.Fatalf("rpc_result.req_msg_id = %d, want %d", result.RequestMessageID, reqMsgID) + } + answer, err := mt.DecodeRPCDropAnswer(&bin.Buffer{Buf: result.Result}) + if err != nil { + t.Fatalf("decode RpcDropAnswer: %v", err) + } + if _, ok := answer.(*mt.RPCAnswerUnknown); !ok { + t.Fatalf("RpcDropAnswer = %T, want *mt.RPCAnswerUnknown", answer) + } +} + +// TestHTTPWaitInContainerDoesNotNeedAck 验证 http_wait 在 container 中被协议层吞掉, +// 但同 container 内的 ping 仍按 content-related service request 回 ack。 +func TestHTTPWaitInContainerDoesNotNeedAck(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + waitMsgID := clientMsgID.New(proto.MessageFromClient) + pingMsgID := clientMsgID.New(proto.MessageFromClient) + containerMsgID := clientMsgID.New(proto.MessageFromClient) + waitBody := mustEncodeTL(t, &mt.HTTPWaitRequest{MaxDelay: 0, WaitAfter: 0, MaxWait: 25_000}) + pingBody := mustEncodeTL(t, &mt.PingRequest{PingID: 7}) + sendEncrypted(t, conn, cipher, auth, containerMsgID, &proto.MessageContainer{ + Messages: []proto.Message{ + {ID: waitMsgID, SeqNo: 0, Bytes: len(waitBody), Body: waitBody}, + {ID: pingMsgID, SeqNo: 1, Bytes: len(pingBody), Body: pingBody}, + }, + }) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + mustHave(t, replies, mt.PongTypeID, "pong") + ackBuf := mustHave(t, replies, mt.MsgsAckTypeID, "msgs_ack") + var ack mt.MsgsAck + if err := ack.Decode(ackBuf); err != nil { + t.Fatalf("decode msgs_ack: %v", err) + } + if len(ack.MsgIDs) != 1 || ack.MsgIDs[0] != pingMsgID { + t.Fatalf("msgs_ack = %+v, want only ping msg_id %d", ack.MsgIDs, pingMsgID) + } +} + +// TestOldMessageInFreshContainerAccepted verifies TDesktop's bad_msg recovery +// path: an old request can be resent inside a fresh container msg_id. +func TestOldMessageInFreshContainerAccepted(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + oldMsgIDGen := proto.NewMessageIDGen(func() time.Time { + return time.Now().Add(-10 * time.Minute) + }) + freshMsgIDGen := proto.NewMessageIDGen(time.Now) + oldPingMsgID := oldMsgIDGen.New(proto.MessageFromClient) + containerMsgID := freshMsgIDGen.New(proto.MessageFromClient) + pingBody := mustEncodeTL(t, &mt.PingRequest{PingID: 42}) + + sendEncrypted(t, conn, cipher, auth, containerMsgID, &proto.MessageContainer{ + Messages: []proto.Message{ + {ID: oldPingMsgID, SeqNo: 1, Bytes: len(pingBody), Body: pingBody}, + }, + }) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) + buf := mustHave(t, replies, mt.PongTypeID, "pong") + var pong mt.Pong + if err := pong.Decode(buf); err != nil { + t.Fatalf("decode pong: %v", err) + } + if pong.MsgID != oldPingMsgID || pong.PingID != 42 { + t.Fatalf("pong = %+v, want msg_id=%d ping_id=42", pong, oldPingMsgID) + } +} + +func TestPingDelayDisconnectOddSeqAccepted(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, reqMsgID, 1, &mt.PingDelayDisconnectRequest{ + PingID: 9, + DisconnectDelay: 60, + }) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) + buf := mustHave(t, replies, mt.PongTypeID, "pong") + var pong mt.Pong + if err := pong.Decode(buf); err != nil { + t.Fatalf("decode pong: %v", err) + } + if pong.MsgID != reqMsgID || pong.PingID != 9 { + t.Fatalf("pong = %+v, want msg_id=%d ping_id=9", pong, reqMsgID) + } +} + +// TestDestroyAuthKey 验证 MTProto service message destroy_auth_key 由连接层直接响应, +// 避免 TDesktop 清理旧 key 时落到业务 RPC fallback。 +func TestDestroyAuthKey(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncrypted(t, conn, cipher, auth, reqMsgID, &destroyAuthKeyRequest{}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, destroyAuthKeyOkTypeID) + mustHave(t, replies, destroyAuthKeyOkTypeID, "destroy_auth_key_ok") +} + +// TestBadServerSalt 验证客户端带错 server_salt 时 server 返回 bad_server_salt, +// 并携带当前 auth key 的权威 salt。 +func TestBadServerSalt(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + wrongSalt := auth.ServerSalt + 1 + sendEncryptedWithSalt(t, conn, cipher, auth, wrongSalt, reqMsgID, &mt.PingRequest{PingID: 1}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.BadServerSaltTypeID) + buf := mustHave(t, replies, mt.BadServerSaltTypeID, "bad_server_salt") + + var bad mt.BadServerSalt + if err := bad.Decode(buf); err != nil { + t.Fatalf("decode bad_server_salt: %v", err) + } + if bad.BadMsgID != reqMsgID { + t.Fatalf("bad_server_salt.bad_msg_id = %d, want %d", bad.BadMsgID, reqMsgID) + } + if bad.ErrorCode != 48 { + t.Fatalf("bad_server_salt.error_code = %d, want 48", bad.ErrorCode) + } + if bad.NewServerSalt != auth.ServerSalt { + t.Fatalf("bad_server_salt.new_server_salt = %#x, want %#x", bad.NewServerSalt, auth.ServerSalt) + } +} + +func TestBadMsgSeqOddExpected(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, reqMsgID, 0, &tg.HelpGetConfigRequest{}) + + bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey) + if bad.BadMsgID != reqMsgID || bad.BadMsgSeqno != 0 || bad.ErrorCode != badMsgSeqNotOdd { + t.Fatalf("bad_msg = %+v, want msg_id=%d seq=0 code=%d", bad, reqMsgID, badMsgSeqNotOdd) + } +} + +func TestBadMsgSeqEvenExpected(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, reqMsgID, 1, &mt.MsgsAck{MsgIDs: []int64{reqMsgID}}) + + bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey) + if bad.BadMsgID != reqMsgID || bad.BadMsgSeqno != 1 || bad.ErrorCode != badMsgSeqNotEven { + t.Fatalf("bad_msg = %+v, want msg_id=%d seq=1 code=%d", bad, reqMsgID, badMsgSeqNotEven) + } +} + +func TestBadMsgSeqTooLow(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + firstMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, firstMsgID, 3, &tg.HelpGetConfigRequest{}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + + secondMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, secondMsgID, 1, &tg.HelpGetConfigRequest{}) + + bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey) + if bad.BadMsgID != secondMsgID || bad.BadMsgSeqno != 1 || bad.ErrorCode != badMsgSeqTooLow { + t.Fatalf("bad_msg = %+v, want msg_id=%d seq=1 code=%d", bad, secondMsgID, badMsgSeqTooLow) + } +} + +func TestBadMsgSeqTooHigh(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + lowMsgID := clientMsgID.New(proto.MessageFromClient) + highMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, highMsgID, 1, &tg.HelpGetConfigRequest{}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + + sendEncryptedWithSeq(t, conn, cipher, auth, lowMsgID, 3, &tg.HelpGetConfigRequest{}) + + bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey) + if bad.BadMsgID != lowMsgID || bad.BadMsgSeqno != 3 || bad.ErrorCode != badMsgSeqTooHigh { + t.Fatalf("bad_msg = %+v, want msg_id=%d seq=3 code=%d", bad, lowMsgID, badMsgSeqTooHigh) + } +} + +func readBadMsgNotification(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) mt.BadMsgNotification { + t.Helper() + replies := collectReplies(t, conn, cipher, key, mt.BadMsgNotificationTypeID) + buf := mustHave(t, replies, mt.BadMsgNotificationTypeID, "bad_msg_notification") + var bad mt.BadMsgNotification + if err := bad.Decode(buf); err != nil { + t.Fatalf("decode bad_msg_notification: %v", err) + } + return bad +} + +func mustEncodeTL(t *testing.T, msg bin.Encoder) []byte { + t.Helper() + var b bin.Buffer + if err := msg.Encode(&b); err != nil { + t.Fatalf("encode TL: %v", err) + } + return b.Copy() +} diff --git a/internal/mtprotoedge/exchange.go b/internal/mtprotoedge/exchange.go new file mode 100644 index 00000000..d9703c50 --- /dev/null +++ b/internal/mtprotoedge/exchange.go @@ -0,0 +1,152 @@ +package mtprotoedge + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/exchange" + "github.com/gotd/td/proto/codec" + "github.com/gotd/td/transport" + + "telesrv/internal/store" +) + +// emptyAuthKeyID 是未加密消息(密钥交换)的 auth_key_id(全零)。 +var emptyAuthKeyID [8]byte + +// peekAuthKeyID 读取消息前 8 字节的 auth_key_id,不消费 buffer。 +func peekAuthKeyID(b *bin.Buffer) (id [8]byte, err error) { + err = b.PeekN(id[:], len(id)) + return id, err +} + +// handleExchange 在收到 auth_key_id==0 的首帧后执行服务端 MTProto 密钥交换。 +// +// first 是已读取的首帧(req_pq*),通过 bufferedConn 交还给 exchange 流程, +// 使其能从头读取握手消息。成功后将 auth key + server salt 落入 AuthKeyStore。 +func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first *bin.Buffer) (*bin.Buffer, error) { + if s.key.Zero() { + s.log.Error("Key exchange requested but server RSA key is not configured") + return nil, s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound) + } + + buffered := newBufferedConn(conn) + buffered.push(first) + + start := s.clock.Now() + res, err := exchange.NewExchanger(buffered, s.dc). + WithClock(s.clock). + WithRand(s.rand). + WithLogger(s.log.Named("exchange")). + Server(s.key). + Run(ctx) + if err != nil { + if isEncryptedFrameDuringExchange(err) { + replay := buffered.lastFrame() + if replay != nil { + s.log.Debug("Key exchange interrupted by encrypted frame; replaying as existing session") + return replay, nil + } + } + var exErr *exchange.ServerExchangeError + if errors.As(err, &exErr) { + s.log.Info("Key exchange rejected", zap.Int32("code", exErr.Code), zap.Error(err)) + return nil, s.sendProtoError(ctx, conn, exErr.Code) + } + return nil, fmt.Errorf("key exchange: %w", err) + } + + s.metrics.HandshakeDone(s.clock.Now().Sub(start)) + s.log.Info("Key exchange completed", + zap.Object("auth_key", res.Key), + zap.Int64("server_salt", res.ServerSalt), + zap.Duration("dur", s.clock.Now().Sub(start)), + ) + + return nil, s.authKeys.Save(ctx, authKeyData(res.Key, res.ServerSalt, s.clock.Now().Unix())) +} + +func isEncryptedFrameDuringExchange(err error) bool { + msg := err.Error() + return strings.Contains(msg, "unexpected auth_key_id") && strings.Contains(msg, "plaintext message") +} + +// authKeyData 把握手结果转换为 store 记录。 +func authKeyData(key crypto.AuthKey, salt, createdAt int64) store.AuthKeyData { + return store.AuthKeyData{ + ID: key.ID, + Value: [256]byte(key.Value), + ServerSalt: salt, + CreatedAt: createdAt, + } +} + +// sendProtoError 向客户端发送 transport 级协议错误(-code)。 +func (s *Server) sendProtoError(ctx context.Context, conn transport.Conn, code int32) error { + var buf bin.Buffer + buf.PutInt32(-code) + + ctx, cancel := context.WithTimeout(ctx, s.writeTimeout) + defer cancel() + if err := conn.Send(ctx, &buf); err != nil { + return fmt.Errorf("send proto error %d: %w", code, err) + } + return nil +} + +// bufferedConn 包装 transport.Conn,可把已读取的帧重新交给后续 Recv。 +// +// 用于密钥交换:serveConn 已读首帧用于 peek auth_key_id,再 push 回来交给 exchange。 +type bufferedConn struct { + transport.Conn + mu sync.Mutex + pending []bin.Buffer + last bin.Buffer +} + +func newBufferedConn(conn transport.Conn) *bufferedConn { + return &bufferedConn{Conn: conn} +} + +func (c *bufferedConn) push(b *bin.Buffer) { + c.mu.Lock() + c.pending = append(c.pending, bin.Buffer{Buf: b.Copy()}) + c.mu.Unlock() +} + +// Recv 优先返回已 push 的帧(FIFO),耗尽后读取底层连接。 +func (c *bufferedConn) Recv(ctx context.Context, b *bin.Buffer) error { + c.mu.Lock() + if len(c.pending) > 0 { + e := c.pending[0] + c.pending = c.pending[1:] + c.last.ResetTo(e.Copy()) + c.mu.Unlock() + b.ResetTo(e.Buf) + return nil + } + c.mu.Unlock() + if err := c.Conn.Recv(ctx, b); err != nil { + return err + } + c.mu.Lock() + c.last.ResetTo(b.Copy()) + c.mu.Unlock() + return nil +} + +func (c *bufferedConn) lastFrame() *bin.Buffer { + c.mu.Lock() + defer c.mu.Unlock() + if c.last.Len() == 0 { + return nil + } + return &bin.Buffer{Buf: c.last.Copy()} +} diff --git a/internal/mtprotoedge/exchange_test.go b/internal/mtprotoedge/exchange_test.go new file mode 100644 index 00000000..d03c885e --- /dev/null +++ b/internal/mtprotoedge/exchange_test.go @@ -0,0 +1,171 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/exchange" + "github.com/gotd/td/mt" + tgproto "github.com/gotd/td/proto" + "github.com/gotd/td/transport" + + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +// TestKeyExchange 验证 M1:client 用 server 公钥完成 MTProto 密钥交换, +// 双方得到一致的 auth key 与 server salt,且 server 将其存入 AuthKeyStore。 +func TestKeyExchange(t *testing.T) { + const dc = 2 + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + keys := memory.NewAuthKeyStore() + srv := New(Options{ + Logger: zaptest.NewLogger(t), + DC: dc, + RSAKey: rsaKey, + AuthKeys: keys, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + // client:TCP 拨号 + intermediate 握手,跑 client 端密钥交换。 + raw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn, err := transport.Intermediate.Handshake(raw) + if err != nil { + t.Fatalf("transport handshake: %v", err) + } + + pub := exchange.PublicKey{RSA: &rsaKey.PublicKey} + exchCtx, ec := context.WithTimeout(context.Background(), 10*time.Second) + defer ec() + res, err := exchange.NewExchanger(conn, dc). + WithRand(rand.Reader). + WithLogger(zaptest.NewLogger(t).Named("client")). + Client([]exchange.PublicKey{pub}). + Run(exchCtx) + if err != nil { + t.Fatalf("client exchange: %v", err) + } + + // server 在 Run 返回后落库,轮询等待。 + var saved store.AuthKeyData + found := false + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + saved, found, _ = keys.Get(context.Background(), res.AuthKey.ID) + if found { + break + } + time.Sleep(20 * time.Millisecond) + } + if !found { + t.Fatalf("server did not store auth key %x", res.AuthKey.ID) + } + if saved.Value != [256]byte(res.AuthKey.Value) { + t.Fatal("server auth key value mismatch") + } + if saved.ServerSalt != res.ServerSalt { + t.Fatalf("server salt mismatch: server=%d client=%d", saved.ServerSalt, res.ServerSalt) + } + + cancel() + select { + case err := <-serveErr: + if err != nil { + t.Fatalf("serve: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not stop after ctx cancel") + } +} + +func TestReconnectFakeReqPQThenEncryptedFrame(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + + firstConn, auth, cipher := dialHandshake(t, addr, dc, pub) + _ = firstConn.Close() + + raw, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial reconnect: %v", err) + } + conn, err := transport.Intermediate.Handshake(raw) + if err != nil { + t.Fatalf("transport reconnect: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + var reqPayload bin.Buffer + nonce, err := randInt128ForTest() + if err != nil { + t.Fatalf("nonce: %v", err) + } + if err := (&mt.ReqPqMultiRequest{Nonce: nonce}).Encode(&reqPayload); err != nil { + t.Fatalf("encode req_pq_multi: %v", err) + } + var fakeReq bin.Buffer + if err := (tgproto.UnencryptedMessage{ + MessageID: int64(tgproto.NewMessageID(time.Now(), tgproto.MessageFromClient)), + MessageData: reqPayload.Raw(), + }).Encode(&fakeReq); err != nil { + t.Fatalf("encode fake req_pq: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := conn.Send(ctx, &fakeReq); err != nil { + cancel() + t.Fatalf("send fake req_pq: %v", err) + } + cancel() + + msgGen := tgproto.NewMessageIDGen(time.Now) + sendEncrypted(t, conn, cipher, auth, msgGen.New(tgproto.MessageFromClient), &mt.PingRequest{PingID: 7}) + + var resPQFrame bin.Buffer + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + err = conn.Recv(ctx, &resPQFrame) + cancel() + if err != nil { + t.Fatalf("recv resPQ: %v", err) + } + var plain tgproto.UnencryptedMessage + if err := plain.Decode(&resPQFrame); err != nil { + t.Fatalf("decode resPQ frame: %v", err) + } + if id, err := (&bin.Buffer{Buf: plain.MessageData}).PeekID(); err != nil || id != mt.ResPQTypeID { + t.Fatalf("resPQ payload id = %#x err=%v, want %#x", id, err, mt.ResPQTypeID) + } + + got := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) + mustHave(t, got, mt.PongTypeID, "pong after fake req_pq reconnect") +} + +func randInt128ForTest() (v bin.Int128, err error) { + _, err = rand.Read(v[:]) + return v, err +} diff --git a/internal/mtprotoedge/helpers_test.go b/internal/mtprotoedge/helpers_test.go new file mode 100644 index 00000000..88a384be --- /dev/null +++ b/internal/mtprotoedge/helpers_test.go @@ -0,0 +1,209 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/exchange" + "github.com/gotd/td/proto" + "github.com/gotd/td/transport" +) + +// startTestServer 生成 RSA key、监听随机端口并启动 Server,返回监听地址与公钥。 +// 通过 t.Cleanup 自动取消并校验优雅退出。opts 的 RSAKey/Logger/DC 会被补默认。 +func startTestServer(t *testing.T, opts Options) (addr string, pub exchange.PublicKey, srv *Server) { + t.Helper() + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + opts.RSAKey = rsaKey + if opts.Logger == nil { + opts.Logger = zaptest.NewLogger(t) + } + if opts.DC == 0 { + opts.DC = 2 + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + srv = New(opts) + ctx, cancel := context.WithCancel(context.Background()) + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + t.Cleanup(func() { + cancel() + select { + case err := <-serveErr: + if err != nil { + t.Errorf("serve: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("server did not stop after ctx cancel") + } + }) + + return ln.Addr().String(), exchange.PublicKey{RSA: &rsaKey.PublicKey}, srv +} + +// dialHandshake 建立 TCP 连接、完成 intermediate 协商与 MTProto 密钥交换, +// 返回连接、握手结果与 client 端 cipher。连接通过 t.Cleanup 自动关闭。 +func dialHandshake(t *testing.T, addr string, dc int, pub exchange.PublicKey) (transport.Conn, exchange.ClientExchangeResult, crypto.Cipher) { + t.Helper() + raw, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn, err := transport.Intermediate.Handshake(raw) + if err != nil { + t.Fatalf("transport handshake: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + auth, err := exchange.NewExchanger(conn, dc). + WithRand(rand.Reader). + WithLogger(zaptest.NewLogger(t).Named("client")). + Client([]exchange.PublicKey{pub}). + Run(ctx) + if err != nil { + t.Fatalf("client exchange: %v", err) + } + return conn, auth, crypto.NewClientCipher(rand.Reader) +} + +// sendEncrypted 用 client cipher 加密并发送一条带 msgID 的消息。 +func sendEncrypted(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, msgID int64, msg bin.Encoder) { + t.Helper() + sendEncryptedWithSalt(t, conn, cipher, auth, auth.ServerSalt, msgID, msg) +} + +// sendEncryptedWithSalt 用指定 salt 加密并发送一条消息。 +func sendEncryptedWithSalt(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, salt, msgID int64, msg bin.Encoder) { + t.Helper() + body, seqNo := encodeClientMessageForTest(t, msg) + sendEncryptedWithSaltAndSeq(t, conn, cipher, auth, salt, msgID, seqNo, body) +} + +func sendEncryptedWithSeq(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, msgID int64, seqNo int32, msg bin.Encoder) { + t.Helper() + body := encodeClientMessageBodyForTest(t, msg) + sendEncryptedWithSaltAndSeq(t, conn, cipher, auth, auth.ServerSalt, msgID, seqNo, body) +} + +func sendEncryptedWithSaltAndSeq(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, salt, msgID int64, seqNo int32, body []byte) { + t.Helper() + var buf bin.Buffer + if err := cipher.Encrypt(auth.AuthKey, crypto.EncryptedMessageData{ + Salt: salt, + SessionID: auth.SessionID, + MessageID: msgID, + SeqNo: seqNo, + MessageDataLen: int32(len(body)), + MessageDataWithPadding: body, + }, &buf); err != nil { + t.Fatalf("encrypt: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := conn.Send(ctx, &buf); err != nil { + t.Fatalf("send: %v", err) + } +} + +func encodeClientMessageForTest(t *testing.T, msg bin.Encoder) ([]byte, int32) { + t.Helper() + raw := encodeClientMessageBodyForTest(t, msg) + typeID, err := (&bin.Buffer{Buf: raw}).PeekID() + if err != nil { + t.Fatalf("peek encrypted message type: %v", err) + } + if container, ok := msg.(*proto.MessageContainer); ok { + return raw, clientContainerSeqNoForTest(container) + } + if clientMessageNeedsAck(typeID) { + return raw, 1 + } + return raw, 0 +} + +func encodeClientMessageBodyForTest(t *testing.T, msg bin.Encoder) []byte { + t.Helper() + var body bin.Buffer + if err := msg.Encode(&body); err != nil { + t.Fatalf("encode encrypted message: %v", err) + } + return body.Copy() +} + +func clientContainerSeqNoForTest(container *proto.MessageContainer) int32 { + var maxSeq int32 + for _, msg := range container.Messages { + if seq := int32(msg.SeqNo); seq > maxSeq { + maxSeq = seq + } + } + if maxSeq%2 != 0 { + maxSeq++ + } + return maxSeq +} + +// collectReplies 读取并解密 server 回发的消息,按 TypeID 收集明文 buffer, +// 直到见到 wantID(含)或达到上限。用于断言一次请求触发的多条响应 +// (new_session_created / 业务响应 / msgs_ack)。 +func collectReplies(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey, wantID uint32) map[uint32]*bin.Buffer { + t.Helper() + got := make(map[uint32]*bin.Buffer) + for i := 0; i < 8; i++ { + _, id, plain := readServerMessage(t, conn, cipher, key) + got[id] = plain + if id == wantID { + break + } + } + return got +} + +func readServerMessage(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) (*crypto.EncryptedMessageData, uint32, *bin.Buffer) { + t.Helper() + var buf bin.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := conn.Recv(ctx, &buf) + cancel() + if err != nil { + t.Fatalf("recv server message: %v", err) + } + data, err := cipher.DecryptFromBuffer(key, &buf) + if err != nil { + t.Fatalf("decrypt server message: %v", err) + } + plain := append([]byte(nil), data.Data()...) + id, err := (&bin.Buffer{Buf: plain}).PeekID() + if err != nil { + t.Fatalf("peek server message: %v", err) + } + return data, id, &bin.Buffer{Buf: plain} +} + +// mustHave 断言 replies 含指定 TypeID 的消息并返回其 buffer。 +func mustHave(t *testing.T, replies map[uint32]*bin.Buffer, id uint32, name string) *bin.Buffer { + t.Helper() + b, ok := replies[id] + if !ok { + t.Fatalf("missing %s (%#x)", name, id) + } + return b +} diff --git a/internal/mtprotoedge/inbound_rpc.go b/internal/mtprotoedge/inbound_rpc.go new file mode 100644 index 00000000..b2c6bd9b --- /dev/null +++ b/internal/mtprotoedge/inbound_rpc.go @@ -0,0 +1,195 @@ +package mtprotoedge + +import ( + "context" + "errors" + "time" +) + +// ErrInboundRPCQueueFull 表示单连接 RPC 队列已满。 +var ErrInboundRPCQueueFull = errors.New("inbound rpc queue full") + +// maxInflightRPCBytes 是单连接已入队未完成 inbound RPC body 的总字节上限。 +// 队列除按条数(queueSize)限制外,再按字节预算兜底:对抗客户端发满大请求时按字节先拒绝。 +const maxInflightRPCBytes = 32 << 20 // 32 MiB + +// rpcCloseWaitTimeout 是连接关闭时等待 inbound RPC worker 退出的上限。 +const rpcCloseWaitTimeout = 5 * time.Second + +type inboundRPC struct { + ctx context.Context + method string + enqueuedAt time.Time + size int + run func(context.Context) error +} + +func (c *Conn) startInboundRPCScheduler(maxInflight, queueSize int, timeout time.Duration) { + if c.metrics == nil { + c.metrics = NopMetrics{} + } + if maxInflight <= 0 { + maxInflight = 1 + } + if queueSize <= 0 { + queueSize = 1 + } + rootCtx, cancel := context.WithCancel(context.Background()) + c.rpcQueue = make(chan inboundRPC, queueSize) + c.rpcStop = make(chan struct{}) + c.rpcCancel = cancel + c.rpcTimeout = timeout + c.rpcRootCtx = rootCtx + c.rpcMaxInflight = maxInflight + // worker 懒启动:不在此处起 worker;首个 RPC 入队时由 ensureInboundRPCWorkers 起, + // 避免握手后静默 / 纯推送目标连接白白钉住 maxInflight 个 goroutine。 +} + +// ensureInboundRPCWorkers 懒启动 maxInflight 个 RPC worker(仅一次),在 enqueueInboundRPC +// 入队成功后调用。从不发 RPC 的连接(半开 / 纯推送)由此完全不起 worker。 +func (c *Conn) ensureInboundRPCWorkers() { + c.rpcWorkersOnce.Do(func() { + c.rpcWG.Add(c.rpcMaxInflight) + for i := 0; i < c.rpcMaxInflight; i++ { + go c.inboundRPCWorker(c.rpcRootCtx) + } + }) +} + +func (c *Conn) enqueueInboundRPC(ctx context.Context, task inboundRPC) error { + if ctx == nil { + ctx = context.Background() + } + if c.rpcQueue == nil || c.rpcStop == nil { + c.metrics.InboundRPCDropped(task.method, "scheduler_closed") + return ErrConnClosed + } + task.ctx = ctx + task.enqueuedAt = time.Now() + select { + case <-ctx.Done(): + c.metrics.InboundRPCDropped(task.method, "context_done") + return ctx.Err() + case <-c.rpcStop: + c.metrics.InboundRPCDropped(task.method, "scheduler_closed") + return ErrConnClosed + default: + } + // 字节预算:先预扣 size,超 maxInflightRPCBytes 则回滚并拒绝(与条数上限并列的第二道闸)。 + if task.size > 0 { + if c.inflightRPCBytes.Add(int64(task.size)) > maxInflightRPCBytes { + c.inflightRPCBytes.Add(-int64(task.size)) + c.metrics.InboundRPCDropped(task.method, "byte_budget") + return ErrInboundRPCQueueFull + } + } + select { + case c.rpcQueue <- task: + c.ensureInboundRPCWorkers() + c.metrics.InboundRPCQueued(task.method, len(c.rpcQueue), cap(c.rpcQueue)) + return nil + case <-ctx.Done(): + c.releaseInflightRPCBytes(task.size) + c.metrics.InboundRPCDropped(task.method, "context_done") + return ctx.Err() + case <-c.rpcStop: + c.releaseInflightRPCBytes(task.size) + c.metrics.InboundRPCDropped(task.method, "scheduler_closed") + return ErrConnClosed + default: + c.releaseInflightRPCBytes(task.size) + c.metrics.InboundRPCDropped(task.method, "queue_full") + return ErrInboundRPCQueueFull + } +} + +// releaseInflightRPCBytes 归还字节预算。与 enqueueInboundRPC 的预扣严格配对: +// 入队失败时回滚、worker 执行完(runInboundRPC)或排空丢弃(drainInboundRPCQueue)时释放。 +func (c *Conn) releaseInflightRPCBytes(size int) { + if size > 0 { + c.inflightRPCBytes.Add(-int64(size)) + } +} + +func (c *Conn) inboundRPCWorker(rootCtx context.Context) { + defer c.rpcWG.Done() + for { + select { + case <-c.rpcStop: + return + default: + } + select { + case task := <-c.rpcQueue: + c.runInboundRPC(rootCtx, task) + case <-c.rpcStop: + return + } + } +} + +func (c *Conn) runInboundRPC(rootCtx context.Context, task inboundRPC) { + defer c.releaseInflightRPCBytes(task.size) + queueWait := time.Since(task.enqueuedAt) + c.metrics.InboundRPCStarted(task.method, queueWait) + ctx := task.ctx + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stopRoot := context.AfterFunc(rootCtx, cancel) + defer stopRoot() + if c.rpcTimeout > 0 { + var timeoutCancel context.CancelFunc + ctx, timeoutCancel = context.WithTimeout(ctx, c.rpcTimeout) + defer timeoutCancel() + } + _ = task.run(ctx) +} + +func (c *Conn) closeInboundRPCScheduler() { + if c.rpcStop == nil { + return + } + c.rpcClose.Do(func() { + if c.rpcCancel != nil { + c.rpcCancel() + } + close(c.rpcStop) + // 抢占懒启动 Once:若 worker 尚未起,封住其启动,避免后续 ensureInboundRPCWorkers 的 + // rpcWG.Add 与下面的 rpcWG.Wait 并发(WaitGroup 误用)。Once 互斥保证 Add happens-before Wait。 + c.rpcWorkersOnce.Do(func() {}) + c.drainInboundRPCQueue() + // 等 worker 退出,使关闭对 inbound 与 outbound(<-outboundDone)收敛对称;带超时防慢 handler 卡死。 + c.waitInboundWorkers(rpcCloseWaitTimeout) + }) +} + +// waitInboundWorkers 等所有 inbound RPC worker 退出,最长 timeout。超时则放弃等待, +// worker 在其阻塞的底层调用返回后自行退出(rpcCancel 已发,最终收敛)。 +func (c *Conn) waitInboundWorkers(timeout time.Duration) { + done := make(chan struct{}) + go func() { + c.rpcWG.Wait() + close(done) + }() + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + } +} + +func (c *Conn) drainInboundRPCQueue() { + for { + select { + case task := <-c.rpcQueue: + c.releaseInflightRPCBytes(task.size) + c.metrics.InboundRPCDropped(task.method, "connection_closed") + default: + return + } + } +} diff --git a/internal/mtprotoedge/inbound_rpc_test.go b/internal/mtprotoedge/inbound_rpc_test.go new file mode 100644 index 00000000..c085b9ef --- /dev/null +++ b/internal/mtprotoedge/inbound_rpc_test.go @@ -0,0 +1,76 @@ +package mtprotoedge + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +func TestInboundRPCSchedulerBoundsConcurrentWork(t *testing.T) { + c := &Conn{metrics: NopMetrics{}} + c.startInboundRPCScheduler(2, 4, time.Second) + defer c.closeInboundRPCScheduler() + + var active atomic.Int64 + var maxActive atomic.Int64 + var done atomic.Int64 + started := make(chan struct{}, 6) + release := make(chan struct{}) + task := inboundRPC{ + method: "test.method", + run: func(ctx context.Context) error { + cur := active.Add(1) + for { + old := maxActive.Load() + if cur <= old || maxActive.CompareAndSwap(old, cur) { + break + } + } + started <- struct{}{} + select { + case <-release: + case <-ctx.Done(): + } + active.Add(-1) + done.Add(1) + return nil + }, + } + + for i := 0; i < 2; i++ { + if err := c.enqueueInboundRPC(context.Background(), task); err != nil { + t.Fatalf("enqueue active task %d: %v", i, err) + } + } + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for active rpc workers") + } + } + for i := 0; i < 4; i++ { + if err := c.enqueueInboundRPC(context.Background(), task); err != nil { + t.Fatalf("enqueue queued task %d: %v", i, err) + } + } + if err := c.enqueueInboundRPC(context.Background(), task); !errors.Is(err, ErrInboundRPCQueueFull) { + t.Fatalf("enqueue over capacity err = %v, want ErrInboundRPCQueueFull", err) + } + if got := maxActive.Load(); got != 2 { + t.Fatalf("max active = %d, want 2", got) + } + + close(release) + deadline := time.After(2 * time.Second) + for done.Load() != 6 { + select { + case <-deadline: + t.Fatalf("done = %d, want 6", done.Load()) + default: + time.Sleep(10 * time.Millisecond) + } + } +} diff --git a/internal/mtprotoedge/login_e2e_test.go b/internal/mtprotoedge/login_e2e_test.go new file mode 100644 index 00000000..dc991848 --- /dev/null +++ b/internal/mtprotoedge/login_e2e_test.go @@ -0,0 +1,457 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/clock" + "github.com/gotd/td/exchange" + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/dcs" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/app/account" + "telesrv/internal/app/auth" + "telesrv/internal/app/contacts" + "telesrv/internal/app/dialogs" + "telesrv/internal/app/help" + "telesrv/internal/app/langpack" + messageapp "telesrv/internal/app/messages" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/rpc" + "telesrv/internal/store/memory" +) + +// TestLoginRegisterFlow 是登录注册闭环的端到端验证:telegram.Client 连本地 server, +// 依次 sendCode → signIn(需注册) → signUp → getUsers(self),验证注册后能用 self 查回自己。 +func TestLoginRegisterFlow(t *testing.T) { + const ( + dc = 2 + phone = "+8613800138000" + wantPhone = "8613800138000" + code = "12345" + ) + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tcpAddr := ln.Addr().(*net.TCPAddr) + + userStore := memory.NewUserStore() + authzStore := memory.NewAuthorizationStore() + authKeyStore := memory.NewAuthKeyStore() + helpStore := memory.NewHelpStore() + if err := helpStore.UpsertAppConfig(context.Background(), domain.AppConfig{ + Client: "tdesktop", + Hash: 4, + JSON: []byte(`{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373"}`), + }); err != nil { + t.Fatalf("seed app config: %v", err) + } + if err := helpStore.UpsertCountries(context.Background(), []domain.Country{ + {ISO2: "US", DefaultName: "United States", CountryCodes: []domain.CountryCode{{CountryCode: "1", Prefixes: []string{"1"}}}}, + }); err != nil { + t.Fatalf("seed countries: %v", err) + } + langPackStore := memory.NewLangPackStore() + if err := langPackStore.UpsertPack(context.Background(), domain.LangPack{ + LangPack: "tdesktop", + LangCode: "en", + Version: 1, + Strings: []domain.LangPackString{{Key: "lng_language_name", Value: "English"}}, + }); err != nil { + t.Fatalf("seed langpack: %v", err) + } + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code), + Account: account.NewService(memory.NewPasswordStore()), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Contacts: contacts.NewService(memory.NewContactStore()), + Dialogs: dialogs.NewService(memory.NewDialogStore()), + LangPack: langpack.NewService(langPackStore), + } + router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router}) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + opts := telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), + DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, + Logger: zaptest.NewLogger(t).Named("client"), + SessionStorage: &session.StorageMemory{}, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + client := telegram.NewClient(1, "hash", opts) + + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + + // 1) sendCode → phone_code_hash + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{ + PhoneNumber: phone, + APIID: 1, + APIHash: "hash", + Settings: tg.CodeSettings{}, + }) + if err != nil { + return err + } + sentCode, ok := sent.(*tg.AuthSentCode) + if !ok { + t.Fatalf("sendCode result = %T, want *tg.AuthSentCode", sent) + } + hash := sentCode.PhoneCodeHash + + // 2) signIn → 新用户应得 SignUpRequired + signInRes, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + PhoneCode: code, + }) + if err != nil { + return err + } + if _, ok := signInRes.(*tg.AuthAuthorizationSignUpRequired); !ok { + t.Fatalf("signIn result = %T, want *tg.AuthAuthorizationSignUpRequired", signInRes) + } + + // 3) signUp → 创建用户并返回授权 + signUpRes, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + FirstName: "Test", + LastName: "User", + }) + if err != nil { + return err + } + authz, ok := signUpRes.(*tg.AuthAuthorization) + if !ok { + t.Fatalf("signUp result = %T, want *tg.AuthAuthorization", signUpRes) + } + newUser, ok := authz.User.(*tg.User) + if !ok { + t.Fatalf("signUp user = %T, want *tg.User", authz.User) + } + if !newUser.Self || newUser.FirstName != "Test" || newUser.Phone != wantPhone { + t.Fatalf("signUp user = %+v, want self FirstName=Test Phone=%s", newUser, wantPhone) + } + + // 4) getUsers(self) → 注册后能查回自己 + got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}}) + if err != nil { + return err + } + if len(got) != 1 { + t.Fatalf("getUsers returned %d users, want 1", len(got)) + } + self, ok := got[0].(*tg.User) + if !ok { + t.Fatalf("getUsers[0] = %T, want *tg.User", got[0]) + } + if self.ID != newUser.ID || self.FirstName != "Test" || self.Phone != wantPhone { + t.Fatalf("getUsers self = %+v, want id=%d FirstName=Test Phone=%s", self, newUser.ID, wantPhone) + } + + // 5) 启动配置、账号安全和登录后的空账号 RPC 走业务服务并可编码。 + appConfig, err := raw.HelpGetAppConfig(ctx, 0) + if err != nil { + return err + } + if cfg, ok := appConfig.(*tg.HelpAppConfig); !ok || cfg.Hash != 4 { + t.Fatalf("help.getAppConfig = %T %+v, want hash=4 config", appConfig, appConfig) + } + countriesRes, err := raw.HelpGetCountriesList(ctx, &tg.HelpGetCountriesListRequest{LangCode: "en"}) + if err != nil { + return err + } + if countries, ok := countriesRes.(*tg.HelpCountriesList); !ok || len(countries.Countries) != 1 { + t.Fatalf("help.getCountriesList = %T %+v, want 1 country", countriesRes, countriesRes) + } + password, err := raw.AccountGetPassword(ctx) + if err != nil { + return err + } + if password.HasPassword || len(password.SecureRandom) == 0 { + t.Fatalf("account.getPassword = %+v, want no password with secure random", password) + } + state, err := raw.UpdatesGetState(ctx) + if err != nil { + return err + } + if state.Date == 0 { + t.Fatal("updates.getState Date is zero") + } + diff, err := raw.UpdatesGetDifference(ctx, &tg.UpdatesGetDifferenceRequest{ + Pts: state.Pts, + Date: state.Date, + Qts: state.Qts, + }) + if err != nil { + return err + } + if _, ok := diff.(*tg.UpdatesDifferenceEmpty); !ok { + t.Fatalf("updates.getDifference = %T, want *tg.UpdatesDifferenceEmpty", diff) + } + contactsRes, err := raw.ContactsGetContacts(ctx, 0) + if err != nil { + return err + } + if contacts, ok := contactsRes.(*tg.ContactsContacts); !ok || len(contacts.Contacts) != 0 { + t.Fatalf("contacts.getContacts = %T %+v, want empty *tg.ContactsContacts", contactsRes, contactsRes) + } + dialogsRes, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + }) + if err != nil { + return err + } + if dialogs, ok := dialogsRes.(*tg.MessagesDialogs); !ok || len(dialogs.Dialogs) != 0 { + t.Fatalf("messages.getDialogs = %T %+v, want empty *tg.MessagesDialogs", dialogsRes, dialogsRes) + } + pinned, err := raw.MessagesGetPinnedDialogs(ctx, 0) + if err != nil { + return err + } + if len(pinned.Dialogs) != 0 || pinned.State.Date == 0 { + t.Fatalf("messages.getPinnedDialogs = %+v, want empty dialogs with state", pinned) + } + pack, err := raw.LangpackGetLangPack(ctx, &tg.LangpackGetLangPackRequest{ + LangPack: "tdesktop", + LangCode: "en", + }) + if err != nil { + return err + } + if pack.Version != 1 || len(pack.Strings) != 1 { + t.Fatalf("langpack.getLangPack = %+v, want version 1 with 1 string", pack) + } + strings, err := raw.LangpackGetStrings(ctx, &tg.LangpackGetStringsRequest{ + LangPack: "tdesktop", + LangCode: "en", + Keys: []string{"lng_language_name"}, + }) + if err != nil { + return err + } + if len(strings) != 1 { + t.Fatalf("langpack.getStrings returned %d strings, want 1", len(strings)) + } + return nil + }); err != nil { + t.Fatalf("login/register flow: %v", err) + } + + cancel() + if err := <-serveErr; err != nil { + t.Errorf("serve: %v", err) + } +} + +func TestPrivateMessageRoundTripFlow(t *testing.T) { + const ( + dc = 2 + code = "12345" + ) + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tcpAddr := ln.Addr().(*net.TCPAddr) + + userStore := memory.NewUserStore() + authzStore := memory.NewAuthorizationStore() + authKeyStore := memory.NewAuthKeyStore() + helpStore := memory.NewHelpStore() + langPackStore := memory.NewLangPackStore() + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions")) + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code), + Account: account.NewService(memory.NewPasswordStore()), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Contacts: contacts.NewService(memory.NewContactStore()), + Dialogs: dialogs.NewService(dialogStore), + Messages: messageapp.NewService(messageStore, dialogStore), + LangPack: langpack.NewService(langPackStore), + Sessions: activeSessions, + } + router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions}) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + newClient := func(storage *session.StorageMemory) *telegram.Client { + opts := telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), + DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, + Logger: zaptest.NewLogger(t).Named("client"), + SessionStorage: storage, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + return telegram.NewClient(1, "hash", opts) + } + storageA := &session.StorageMemory{} + storageB := &session.StorageMemory{} + + messagesOf := func(history tg.MessagesMessagesClass) []tg.MessageClass { + t.Helper() + switch v := history.(type) { + case *tg.MessagesMessages: + return v.Messages + case *tg.MessagesMessagesSlice: + return v.Messages + default: + t.Fatalf("history = %T %+v, want messages", history, history) + return nil + } + } + + signUp := func(storage *session.StorageMemory, phone, firstName string) tg.User { + t.Helper() + client := newClient(storage) + var out tg.User + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{ + PhoneNumber: phone, + APIID: 1, + APIHash: "hash", + Settings: tg.CodeSettings{}, + }) + if err != nil { + return err + } + hash := sent.(*tg.AuthSentCode).PhoneCodeHash + if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + PhoneCode: code, + }); err != nil { + return err + } + res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + FirstName: firstName, + }) + if err != nil { + return err + } + authz := res.(*tg.AuthAuthorization) + u := authz.User.(*tg.User) + out = *u + return nil + }); err != nil { + t.Fatalf("signUp %s: %v", firstName, err) + } + return out + } + + userA := signUp(storageA, "+15550001001", "Alice") + userB := signUp(storageB, "+15550001002", "Bob") + + sendAndRead := func(storage *session.StorageMemory, to tg.User, body string, randomID int64) { + t.Helper() + client := newClient(storage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + updates, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash}, + Message: body, + RandomID: randomID, + }) + if err != nil { + return err + } + gotUpdates, ok := updates.(*tg.Updates) + if !ok || len(gotUpdates.Updates) < 2 { + t.Fatalf("send updates = %T %+v, want message id + new message", updates, updates) + } + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash}, + Limit: 10, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("history = %T %+v, want messages", history, history) + } + msg, ok := msgs[0].(*tg.Message) + if !ok || msg.Message != body || !msg.Out { + t.Fatalf("latest history message = %#v, want outgoing %q", msgs[0], body) + } + return nil + }); err != nil { + t.Fatalf("send %q: %v", body, err) + } + } + + sendAndRead(storageA, userB, "hello bob", 1001) + + clientB := newClient(storageB) + if err := clientB.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(clientB) + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: userA.ID, AccessHash: userA.AccessHash}, + Limit: 10, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("bob history = %T %+v, want incoming message", history, history) + } + msg, ok := msgs[0].(*tg.Message) + if !ok || msg.Message != "hello bob" || msg.Out { + t.Fatalf("bob latest message = %#v, want incoming hello bob", msgs[0]) + } + return nil + }); err != nil { + t.Fatalf("bob read incoming: %v", err) + } + + sendAndRead(storageB, userA, "hi alice", 2001) + + cancel() + if err := <-serveErr; err != nil { + t.Errorf("serve: %v", err) + } +} diff --git a/internal/mtprotoedge/metrics.go b/internal/mtprotoedge/metrics.go new file mode 100644 index 00000000..74e1e4f3 --- /dev/null +++ b/internal/mtprotoedge/metrics.go @@ -0,0 +1,67 @@ +package mtprotoedge + +import "time" + +// Metrics 接收连接层运行指标。实现可对接 Prometheus 等监控系统; +// 默认 NopMetrics(零开销)。第一阶段仅预留钩子,正式指标后续接入。 +type Metrics interface { + // ConnOpened 在接受一个连接时调用。 + ConnOpened() + // ConnClosed 在一个连接结束时调用。 + ConnClosed() + // HandshakeDone 在一次密钥交换成功完成时调用,d 为握手耗时。 + HandshakeDone(d time.Duration) + // RPCHandled 在一次 RPC 处理完成时调用:method 为 TL 方法名, + // d 为耗时,err 非 nil 表示失败。 + RPCHandled(method string, d time.Duration, err error) + // InboundRPCQueued 在 RPC 成功进入单连接 bounded queue 时调用。 + InboundRPCQueued(method string, len, cap int) + // InboundRPCStarted 在 RPC 从 bounded queue 取出开始执行时调用。 + InboundRPCStarted(method string, queueWait time.Duration) + // InboundRPCDropped 在 RPC 因队列满、连接关闭或调度错误被丢弃时调用。 + InboundRPCDropped(method, reason string) + // OutboundSend 在一条 server 出站消息完成写入或失败时调用。 + OutboundSend(typeID uint32, queueWait time.Duration, bytes int, err error) + // OutboundResend 在一次 msg_resend_req/重复 RPC 触发重发后调用。 + OutboundResend(count int, err error) + // OutboundDropped 在出站队列或状态跟踪因背压丢弃时调用。 + OutboundDropped(reason string) + // OutboundQueueWait 在出站入队等待超过阈值时调用。 + OutboundQueueWait(len, cap int) +} + +// NopMetrics 是 Metrics 的空实现。 +type NopMetrics struct{} + +// ConnOpened 实现 Metrics。 +func (NopMetrics) ConnOpened() {} + +// ConnClosed 实现 Metrics。 +func (NopMetrics) ConnClosed() {} + +// HandshakeDone 实现 Metrics。 +func (NopMetrics) HandshakeDone(time.Duration) {} + +// RPCHandled 实现 Metrics。 +func (NopMetrics) RPCHandled(string, time.Duration, error) {} + +// InboundRPCQueued 实现 Metrics。 +func (NopMetrics) InboundRPCQueued(string, int, int) {} + +// InboundRPCStarted 实现 Metrics。 +func (NopMetrics) InboundRPCStarted(string, time.Duration) {} + +// InboundRPCDropped 实现 Metrics。 +func (NopMetrics) InboundRPCDropped(string, string) {} + +// OutboundSend 实现 Metrics。 +func (NopMetrics) OutboundSend(uint32, time.Duration, int, error) {} + +// OutboundResend 实现 Metrics。 +func (NopMetrics) OutboundResend(int, error) {} + +// OutboundDropped 实现 Metrics。 +func (NopMetrics) OutboundDropped(string) {} + +// OutboundQueueWait 实现 Metrics。 +func (NopMetrics) OutboundQueueWait(int, int) {} diff --git a/internal/mtprotoedge/metrics_test.go b/internal/mtprotoedge/metrics_test.go new file mode 100644 index 00000000..2e9e44ed --- /dev/null +++ b/internal/mtprotoedge/metrics_test.go @@ -0,0 +1,73 @@ +package mtprotoedge + +import ( + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + + "telesrv/internal/rpc" +) + +type countingMetrics struct { + connOpened atomic.Int64 + connClosed atomic.Int64 + handshakes atomic.Int64 + rpcs atomic.Int64 + inbound atomic.Int64 + outbound atomic.Int64 +} + +func (m *countingMetrics) ConnOpened() { m.connOpened.Add(1) } +func (m *countingMetrics) ConnClosed() { m.connClosed.Add(1) } +func (m *countingMetrics) HandshakeDone(time.Duration) { m.handshakes.Add(1) } +func (m *countingMetrics) RPCHandled(string, time.Duration, error) { m.rpcs.Add(1) } +func (m *countingMetrics) InboundRPCQueued(string, int, int) {} +func (m *countingMetrics) InboundRPCStarted(string, time.Duration) { m.inbound.Add(1) } +func (m *countingMetrics) InboundRPCDropped(string, string) {} +func (m *countingMetrics) OutboundSend(uint32, time.Duration, int, error) { + m.outbound.Add(1) +} +func (m *countingMetrics) OutboundResend(int, error) {} +func (m *countingMetrics) OutboundDropped(string) {} +func (m *countingMetrics) OutboundQueueWait(int, int) {} + +// TestMetricsHooks 验证 M5:连接、握手、RPC 的 metrics 钩子被正确调用。 +func TestMetricsHooks(t *testing.T) { + const dc = 2 + m := &countingMetrics{} + router := rpc.New(rpc.Config{DC: dc, IP: "127.0.0.1", Port: 2398}, rpc.Deps{}, zaptest.NewLogger(t), clock.System) + addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: router, Metrics: m}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &tg.HelpGetConfigRequest{}) + collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID) + + if got := m.connOpened.Load(); got < 1 { + t.Errorf("ConnOpened called %d times, want >= 1", got) + } + if got := m.handshakes.Load(); got != 1 { + t.Errorf("HandshakeDone called %d times, want 1", got) + } + if got := m.rpcs.Load(); got != 1 { + t.Errorf("RPCHandled called %d times, want 1", got) + } + if got := m.inbound.Load(); got != 1 { + t.Errorf("InboundRPCStarted called %d times, want 1", got) + } + // new_session_created / ack 走 fire-and-forget(异步),可能在 client 收到 rpc_result 后 + // 才被 outbound actor 处理;轮询等其最终发送完成。M5 验证发送计数,不约束同步时序。 + deadline := time.Now().Add(2 * time.Second) + for m.outbound.Load() < 3 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + if got := m.outbound.Load(); got < 3 { + t.Errorf("OutboundSend called %d times, want >= 3", got) + } +} diff --git a/internal/mtprotoedge/outbound.go b/internal/mtprotoedge/outbound.go new file mode 100644 index 00000000..2fe2a254 --- /dev/null +++ b/internal/mtprotoedge/outbound.go @@ -0,0 +1,647 @@ +package mtprotoedge + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" +) + +var ( + // ErrConnClosed 表示连接的出站 actor 已关闭。 + ErrConnClosed = errors.New("mtproto connection closed") + // ErrOutboundQueueFull 表示 best-effort update push 未能在预算内进入出站队列。 + ErrOutboundQueueFull = errors.New("mtproto outbound queue full") +) + +const ( + maxOutboundQueue = 1024 + maxTrackedServerMsgIDs = 4096 + maxTrackedAckedMsgIDs = 1024 + // maxTrackedServerBytes 是 pending(已发送待 ack、用于 resend)总 body 字节上限。 + // 与 maxTrackedServerMsgIDs 并列:客户端从不 ack 时,大响应体按字节滚动丢弃, + // 防 pending 被「4096 条 × 大 body」撑爆。 + maxTrackedServerBytes = 64 << 20 // 64 MiB +) + +type outboundOpKind byte + +const ( + outboundSend outboundOpKind = iota + 1 + outboundAck + outboundQueryState + outboundResend + outboundResendByRequest +) + +type outboundOp struct { + kind outboundOpKind + control bool + ctx context.Context + msgType proto.MessageType + msg bin.Encoder + ids []int64 + reqMsgID int64 + enqueuedAt time.Time + done chan outboundResult +} + +type outboundResult struct { + info []byte + resent bool + err error +} + +type outboundFrame struct { + msgID int64 + seqNo int32 + typeID uint32 + body []byte + reqMsgID int64 + sentAt time.Time + sends int +} + +type outboundState struct { + pending map[int64]*outboundFrame + order []int64 + byRequest map[int64]int64 + acked map[int64]struct{} + ackOrder []int64 + totalBytes int +} + +func newOutboundState() *outboundState { + return &outboundState{ + pending: make(map[int64]*outboundFrame), + byRequest: make(map[int64]int64), + acked: make(map[int64]struct{}), + } +} + +func (c *Conn) startOutbound() { + if c.metrics == nil { + c.metrics = NopMetrics{} + } + c.outbound = make(chan outboundOp, maxOutboundQueue) + c.outboundControl = make(chan outboundOp, maxOutboundQueue/4) + c.outboundStop = make(chan struct{}) + c.outboundDone = make(chan struct{}) + go c.outboundLoop() +} + +// Close 停止连接的出站 actor。它不关闭底层 transport;transport 生命周期仍由 serveConn 管理。 +func (c *Conn) Close() { + c.closeInboundRPCScheduler() + c.outboundClose.Do(func() { + if c.outboundStop != nil { + close(c.outboundStop) + <-c.outboundDone + } + }) +} + +// Send 加密并发送一条 server 消息。 +func (c *Conn) Send(ctx context.Context, t proto.MessageType, msg bin.Encoder) error { + return c.send(ctx, t, msg, false) +} + +// SendPriority 加密并优先发送一条 server 控制消息。 +func (c *Conn) SendPriority(ctx context.Context, t proto.MessageType, msg bin.Encoder) error { + return c.send(ctx, t, msg, true) +} + +// SendBestEffort 只等待消息进入普通 outbound 队列,不等待网络写完成。 +// 用于 updates fanout:队列拥塞时返回 ErrOutboundQueueFull,durable outbox/getDifference 负责兜底。 +func (c *Conn) SendBestEffort(ctx context.Context, t proto.MessageType, msg bin.Encoder, timeout time.Duration) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + writeCtx := context.Background() + if ctx != nil { + writeCtx = context.WithoutCancel(ctx) + } + op := outboundOp{ + kind: outboundSend, + ctx: writeCtx, + msgType: t, + msg: msg, + enqueuedAt: time.Now(), + } + if timeout == 0 { + select { + case c.outbound <- op: + return nil + case <-c.outboundStop: + return ErrConnClosed + default: + c.metrics.OutboundDropped("push_queue_full") + return ErrOutboundQueueFull + } + } + enqueueCtx := ctx + if enqueueCtx == nil { + enqueueCtx = context.Background() + } + var cancel context.CancelFunc + if timeout > 0 { + enqueueCtx, cancel = context.WithTimeout(enqueueCtx, timeout) + defer cancel() + } + if err := c.enqueueOutbound(enqueueCtx, op); err != nil { + if errors.Is(err, context.DeadlineExceeded) && timeout > 0 { + c.metrics.OutboundDropped("push_queue_timeout") + return ErrOutboundQueueFull + } + return err + } + return nil +} + +func (c *Conn) send(ctx context.Context, t proto.MessageType, msg bin.Encoder, control bool) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + op := outboundOp{ + kind: outboundSend, + control: control, + ctx: ctx, + msgType: t, + msg: msg, + enqueuedAt: time.Now(), + done: make(chan outboundResult, 1), + } + if err := c.enqueueOutbound(ctx, op); err != nil { + return err + } + select { + case res := <-op.done: + return res.err + case <-ctx.Done(): + return ctx.Err() + case <-c.outboundStop: + return ErrConnClosed + } +} + +// SendAsync 入队一条 server 消息但不等待发送结果(fire-and-forget),用于读循环里的控制消息 +// (ack/pong/new_session_created/bad_msg/future_salts/state_info):避免读循环被 outbound 写 +// 阻塞而连带卡死。走优先(control)队列保证不被普通 push 拖后;队列满时丢弃并记 metrics——此时 +// 连接多已严重拥塞,控制消息丢失由客户端重传 / 读写超时兜底。返回非 nil 仅表示连接已关闭。 +func (c *Conn) SendAsync(ctx context.Context, t proto.MessageType, msg bin.Encoder) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + op := outboundOp{ + kind: outboundSend, + control: true, + ctx: ctx, + msgType: t, + msg: msg, + enqueuedAt: time.Now(), + // done 为 nil:fire-and-forget,handleOutboundSend 的 finish 对 nil done 安全跳过。 + } + select { + case c.outboundControl <- op: + return nil + case <-c.outboundStop: + return ErrConnClosed + default: + c.metrics.OutboundDropped("control_queue_full") + return nil + } +} + +// AckServerMessages 接收客户端 msgs_ack,释放已确认的 server 出站消息。 +func (c *Conn) AckServerMessages(ids []int64) { + if len(ids) == 0 || c.outbound == nil || c.outboundControl == nil { + return + } + copied := append([]int64(nil), ids...) + op := outboundOp{kind: outboundAck, control: true, ids: copied} + select { + case c.outboundControl <- op: + case <-c.outboundStop: + default: + c.metrics.OutboundDropped("ack_queue_full") + } +} + +// OutgoingStateInfo 返回本连接出站消息的状态。返回值中 0 表示无出站侧意见, +// 调用方可继续用入站 connState 兜底。 +func (c *Conn) OutgoingStateInfo(ctx context.Context, ids []int64) ([]byte, error) { + if c.outbound == nil { + return nil, ErrConnClosed + } + op := outboundOp{ + kind: outboundQueryState, + control: true, + ctx: ctx, + ids: append([]int64(nil), ids...), + done: make(chan outboundResult, 1), + } + if err := c.enqueueOutbound(ctx, op); err != nil { + return nil, err + } + select { + case res := <-op.done: + return res.info, res.err + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.outboundStop: + return nil, ErrConnClosed + } +} + +// ResendMessages 重发仍在 outgoing queue 中的 server 消息,并返回对应状态。 +func (c *Conn) ResendMessages(ctx context.Context, ids []int64) ([]byte, error) { + if c.outbound == nil { + return nil, ErrConnClosed + } + op := outboundOp{ + kind: outboundResend, + control: true, + ctx: ctx, + ids: append([]int64(nil), ids...), + done: make(chan outboundResult, 1), + } + if err := c.enqueueOutbound(ctx, op); err != nil { + return nil, err + } + select { + case res := <-op.done: + return res.info, res.err + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.outboundStop: + return nil, ErrConnClosed + } +} + +// ResendByRequest 在重复 RPC 请求到达时,按原 client msg_id 找到并重发已有 rpc_result。 +func (c *Conn) ResendByRequest(ctx context.Context, reqMsgID int64) (bool, error) { + if c.outbound == nil { + return false, ErrConnClosed + } + op := outboundOp{ + kind: outboundResendByRequest, + control: true, + ctx: ctx, + reqMsgID: reqMsgID, + done: make(chan outboundResult, 1), + } + if err := c.enqueueOutbound(ctx, op); err != nil { + return false, err + } + select { + case res := <-op.done: + return res.resent, res.err + case <-ctx.Done(): + return false, ctx.Err() + case <-c.outboundStop: + return false, ErrConnClosed + } +} + +func (c *Conn) enqueueOutbound(ctx context.Context, op outboundOp) error { + if ctx == nil { + ctx = context.Background() + } + q := c.outbound + if op.control { + q = c.outboundControl + } + select { + case q <- op: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-c.outboundStop: + return ErrConnClosed + default: + } + c.metrics.OutboundQueueWait(len(q), cap(q)) + select { + case q <- op: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-c.outboundStop: + return ErrConnClosed + } +} + +func (c *Conn) outboundLoop() { + defer close(c.outboundDone) + state := newOutboundState() + for { + select { + case op := <-c.outboundControl: + c.handleOutboundOp(state, op) + continue + default: + } + select { + case <-c.outboundStop: + c.drainOutbound() + return + case op := <-c.outboundControl: + c.handleOutboundOp(state, op) + case op := <-c.outbound: + c.handleOutboundOp(state, op) + } + } +} + +func (c *Conn) drainOutbound() { + for { + select { + case op := <-c.outboundControl: + op.finish(outboundResult{err: ErrConnClosed}) + case op := <-c.outbound: + op.finish(outboundResult{err: ErrConnClosed}) + default: + return + } + } +} + +func (c *Conn) handleOutboundOp(state *outboundState, op outboundOp) { + switch op.kind { + case outboundSend: + c.handleOutboundSend(state, op) + case outboundAck: + state.ack(op.ids) + case outboundQueryState: + op.finish(outboundResult{info: state.stateInfo(op.ids)}) + case outboundResend: + info, err := c.handleOutboundResend(state, op.ctx, op.ids) + op.finish(outboundResult{info: info, err: err}) + case outboundResendByRequest: + resent, err := c.handleOutboundResendByRequest(state, op.ctx, op.reqMsgID) + op.finish(outboundResult{resent: resent, err: err}) + default: + op.finish(outboundResult{err: fmt.Errorf("unknown outbound op %d", op.kind)}) + } +} + +func (c *Conn) handleOutboundSend(state *outboundState, op outboundOp) { + frame, err := c.buildFrame(op.msgType, op.msg) + if err == nil { + err = c.writeFrame(op.ctx, frame) + } + if err == nil && frameNeedsAck(frame.typeID) { + if dropped := state.add(frame); dropped > 0 { + for i := 0; i < dropped; i++ { + c.metrics.OutboundDropped("tracked_queue_overflow") + } + } + } + queueWait := time.Since(op.enqueuedAt) + bytes := 0 + typeID := uint32(0) + if frame != nil { + bytes = len(frame.body) + typeID = frame.typeID + } + c.metrics.OutboundSend(typeID, queueWait, bytes, err) + op.finish(outboundResult{err: err}) +} + +func (c *Conn) handleOutboundResend(state *outboundState, ctx context.Context, ids []int64) ([]byte, error) { + info := make([]byte, len(ids)) + resent := 0 + for i, id := range ids { + if state.isKnown(id) { + info[i] = msgStateReceived + } + frame, ok := state.pending[id] + if !ok { + continue + } + if err := c.writeFrame(ctx, frame); err != nil { + c.metrics.OutboundResend(resent, err) + return info, err + } + frame.sentAt = time.Now() + frame.sends++ + resent++ + } + c.metrics.OutboundResend(resent, nil) + return info, nil +} + +func (c *Conn) handleOutboundResendByRequest(state *outboundState, ctx context.Context, reqMsgID int64) (bool, error) { + msgID, ok := state.byRequest[reqMsgID] + if !ok { + return false, nil + } + frame, ok := state.pending[msgID] + if !ok { + return false, nil + } + if err := c.writeFrame(ctx, frame); err != nil { + c.metrics.OutboundResend(0, err) + return false, err + } + frame.sentAt = time.Now() + frame.sends++ + c.metrics.OutboundResend(1, nil) + return true, nil +} + +func (op outboundOp) finish(res outboundResult) { + if op.done == nil { + return + } + select { + case op.done <- res: + default: + } +} + +func (c *Conn) buildFrame(t proto.MessageType, msg bin.Encoder) (*outboundFrame, error) { + if msg == nil { + return nil, errors.New("nil outbound message") + } + var body bin.Buffer + if err := msg.Encode(&body); err != nil { + return nil, fmt.Errorf("encode outbound: %w", err) + } + typeID, err := (&bin.Buffer{Buf: body.Raw()}).PeekID() + if err != nil { + return nil, fmt.Errorf("peek outbound type id: %w", err) + } + content := frameNeedsAck(typeID) + msgID := c.msgID.New(t) + return &outboundFrame{ + msgID: msgID, + seqNo: c.nextSeqNo(content), + typeID: typeID, + body: body.Copy(), + reqMsgID: outboundRequestMsgID(msg), + }, nil +} + +func (c *Conn) nextSeqNo(content bool) int32 { + seqNo := c.sentContentMessages * 2 + if content { + seqNo++ + c.sentContentMessages++ + } + return seqNo +} + +func (c *Conn) writeFrame(ctx context.Context, frame *outboundFrame) error { + if ctx == nil { + ctx = context.Background() + } + var out bin.Buffer + if err := c.cipher.Encrypt(c.key, crypto.EncryptedMessageData{ + Salt: c.salt, + SessionID: c.sessionID, + MessageID: frame.msgID, + SeqNo: frame.seqNo, + MessageDataLen: int32(len(frame.body)), + MessageDataWithPadding: frame.body, + }, &out); err != nil { + return fmt.Errorf("encrypt: %w", err) + } + + sendCtx := ctx + cancel := func() {} + if c.writeTimeout > 0 { + sendCtx, cancel = context.WithTimeout(ctx, c.writeTimeout) + } + defer cancel() + writer := c.writer + if writer == nil { + writer = c.transport + } + if err := writer.Send(sendCtx, &out); err != nil { + return fmt.Errorf("send: %w", err) + } + if frame.sentAt.IsZero() { + frame.sentAt = time.Now() + frame.sends = 1 + } + return nil +} + +func frameNeedsAck(typeID uint32) bool { + switch typeID { + case mt.MsgsAckTypeID, + mt.BadMsgNotificationTypeID, + mt.BadServerSaltTypeID, + mt.MsgsStateInfoTypeID, + mt.MsgsAllInfoTypeID, + mt.MsgDetailedInfoTypeID, + mt.MsgNewDetailedInfoTypeID, + proto.MessageContainerTypeID: + return false + default: + return true + } +} + +func outboundRequestMsgID(msg bin.Encoder) int64 { + switch v := msg.(type) { + case *proto.Result: + return v.RequestMessageID + default: + return 0 + } +} + +func (s *outboundState) add(frame *outboundFrame) int { + s.pending[frame.msgID] = frame + s.order = append(s.order, frame.msgID) + s.totalBytes += len(frame.body) + if frame.reqMsgID != 0 { + s.byRequest[frame.reqMsgID] = frame.msgID + } + return s.shrinkPending() +} + +func (s *outboundState) ack(ids []int64) { + for _, id := range ids { + frame, ok := s.pending[id] + if !ok { + continue + } + delete(s.pending, id) + s.totalBytes -= len(frame.body) + if frame.reqMsgID != 0 { + delete(s.byRequest, frame.reqMsgID) + } + s.markAcked(id) + } + if len(s.order) > maxTrackedServerMsgIDs*2 { + s.compactOrder() + } +} + +func (s *outboundState) stateInfo(ids []int64) []byte { + info := make([]byte, len(ids)) + for i, id := range ids { + if s.isKnown(id) { + info[i] = msgStateReceived + } + } + return info +} + +func (s *outboundState) isKnown(id int64) bool { + if _, ok := s.pending[id]; ok { + return true + } + _, ok := s.acked[id] + return ok +} + +func (s *outboundState) markAcked(id int64) { + if _, ok := s.acked[id]; ok { + return + } + s.acked[id] = struct{}{} + s.ackOrder = append(s.ackOrder, id) + for len(s.ackOrder) > maxTrackedAckedMsgIDs { + oldest := s.ackOrder[0] + s.ackOrder = s.ackOrder[1:] + delete(s.acked, oldest) + } +} + +func (s *outboundState) shrinkPending() int { + dropped := 0 + for (len(s.pending) > maxTrackedServerMsgIDs || s.totalBytes > maxTrackedServerBytes) && len(s.order) > 0 { + oldest := s.order[0] + s.order = s.order[1:] + frame, ok := s.pending[oldest] + if !ok { + continue + } + delete(s.pending, oldest) + s.totalBytes -= len(frame.body) + if frame.reqMsgID != 0 { + delete(s.byRequest, frame.reqMsgID) + } + dropped++ + } + return dropped +} + +func (s *outboundState) compactOrder() { + filtered := s.order[:0] + for _, id := range s.order { + if _, ok := s.pending[id]; ok { + filtered = append(filtered, id) + } + } + s.order = filtered +} diff --git a/internal/mtprotoedge/outbound_test.go b/internal/mtprotoedge/outbound_test.go new file mode 100644 index 00000000..4e2e2d75 --- /dev/null +++ b/internal/mtprotoedge/outbound_test.go @@ -0,0 +1,130 @@ +package mtprotoedge + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +func TestOutboundActorSerializesConcurrentSends(t *testing.T) { + const dc = 2 + addr, pub, srv := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + srv.Conns().SetReceivesUpdates(auth.SessionID, true) + + const sends = 64 + var wg sync.WaitGroup + errs := make(chan error, sends) + for i := 0; i < sends; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + errs <- srv.Conns().PushToSession(ctx, auth.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("push: %v", err) + } + } + + var prevMsgID int64 + var prevSeqNo int32 = -1 + for i := 0; i < sends; i++ { + data, id, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if id != tg.UpdatesTooLongTypeID { + t.Fatalf("message %d type = %#x, want updatesTooLong", i, id) + } + if i > 0 && data.MessageID <= prevMsgID { + t.Fatalf("message %d msg_id = %d after %d, want strictly increasing", i, data.MessageID, prevMsgID) + } + if data.SeqNo%2 != 1 { + t.Fatalf("message %d seq_no = %d, want odd content-related seq_no", i, data.SeqNo) + } + if i > 0 && data.SeqNo <= prevSeqNo { + t.Fatalf("message %d seq_no = %d after %d, want increasing", i, data.SeqNo, prevSeqNo) + } + prevMsgID = data.MessageID + prevSeqNo = data.SeqNo + } +} + +func TestOutboundResendAndAckState(t *testing.T) { + const dc = 2 + addr, pub, srv := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + srv.Conns().SetReceivesUpdates(auth.SessionID, true) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := srv.Conns().PushToSession(ctx, auth.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil { + cancel() + t.Fatalf("push: %v", err) + } + cancel() + + original, id, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if id != tg.UpdatesTooLongTypeID { + t.Fatalf("pushed type = %#x, want updatesTooLong", id) + } + + resendReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, resendReqID, 3, &mt.MsgResendReq{MsgIDs: []int64{original.MessageID}}) + resent, resentType, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if resentType != tg.UpdatesTooLongTypeID { + t.Fatalf("resent type = %#x, want updatesTooLong", resentType) + } + if resent.MessageID != original.MessageID || resent.SeqNo != original.SeqNo { + t.Fatalf("resent frame = (msg_id=%d seq=%d), want original (msg_id=%d seq=%d)", + resent.MessageID, resent.SeqNo, original.MessageID, original.SeqNo) + } + _, stateType, stateBuf := readServerMessage(t, conn, cipher, auth.AuthKey) + if stateType != mt.MsgsStateInfoTypeID { + t.Fatalf("state type = %#x, want msgs_state_info", stateType) + } + assertStateInfo(t, stateBuf, resendReqID, []byte{msgStateReceived}) + _, ackType, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if ackType != mt.MsgsAckTypeID { + t.Fatalf("ack type = %#x, want msgs_ack", ackType) + } + + sendEncryptedWithSeq(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), 4, &mt.MsgsAck{MsgIDs: []int64{original.MessageID}}) + ackedResendReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, ackedResendReqID, 5, &mt.MsgResendReq{MsgIDs: []int64{original.MessageID}}) + _, ackedStateType, ackedStateBuf := readServerMessage(t, conn, cipher, auth.AuthKey) + if ackedStateType != mt.MsgsStateInfoTypeID { + t.Fatalf("after ack type = %#x, want msgs_state_info without resend", ackedStateType) + } + assertStateInfo(t, ackedStateBuf, ackedResendReqID, []byte{msgStateReceived}) +} + +func assertStateInfo(t *testing.T, b *bin.Buffer, reqMsgID int64, want []byte) { + t.Helper() + var info mt.MsgsStateInfo + if err := info.Decode(b); err != nil { + t.Fatalf("decode msgs_state_info: %v", err) + } + if info.ReqMsgID != reqMsgID { + t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID) + } + if string(info.Info) != string(want) { + t.Fatalf("msgs_state_info.info = %v, want %v", []byte(info.Info), want) + } +} diff --git a/internal/mtprotoedge/rpc_test.go b/internal/mtprotoedge/rpc_test.go new file mode 100644 index 00000000..cd089b88 --- /dev/null +++ b/internal/mtprotoedge/rpc_test.go @@ -0,0 +1,145 @@ +package mtprotoedge + +import ( + "context" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/crypto" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/rpc" +) + +// TestRPCGetConfig 验证 M3:握手后 client 加密 help.getConfig, +// server 经 tg.ServerDispatcher 路由并回 rpc_result(含本地 DC),外加 new_session_created + ack。 +func TestRPCGetConfig(t *testing.T) { + const ( + dc = 2 + advIP = "127.0.0.1" + advPort = 12345 + ) + router := rpc.New(rpc.Config{DC: dc, IP: advIP, Port: advPort}, rpc.Deps{}, zaptest.NewLogger(t), clock.System) + addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: router}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncrypted(t, conn, cipher, auth, reqMsgID, &tg.HelpGetConfigRequest{}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID) + if _, ok := replies[mt.MsgsAckTypeID]; !ok { + for id, b := range collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) { + replies[id] = b + } + } + mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created") + mustHave(t, replies, mt.MsgsAckTypeID, "msgs_ack") + resultBuf := mustHave(t, replies, proto.ResultTypeID, "rpc_result") + + var res proto.Result + if err := res.Decode(resultBuf); err != nil { + t.Fatalf("decode rpc_result: %v", err) + } + if res.RequestMessageID != reqMsgID { + t.Fatalf("rpc_result req_msg_id = %d, want %d", res.RequestMessageID, reqMsgID) + } + + var cfg tg.Config + if err := cfg.Decode(&bin.Buffer{Buf: res.Result}); err != nil { + t.Fatalf("decode config: %v", err) + } + if cfg.ThisDC != dc { + t.Fatalf("config.ThisDC = %d, want %d", cfg.ThisDC, dc) + } + if len(cfg.DCOptions) != 1 { + t.Fatalf("config.DCOptions count = %d, want 1", len(cfg.DCOptions)) + } + if got := cfg.DCOptions[0]; got.ID != dc || got.IPAddress != advIP || got.Port != advPort { + t.Fatalf("DCOption = %+v, want id=%d ip=%s port=%d", got, dc, advIP, advPort) + } +} + +func TestInboundRPCQueueFullReturnsFloodWait(t *testing.T) { + const dc = 2 + handler := &blockingRPC{ + started: make(chan struct{}, 1), + release: make(chan struct{}), + } + addr, pub, _ := startTestServer(t, Options{ + DC: dc, + RPC: handler, + RPCMaxInflight: 1, + RPCQueueSize: 1, + RPCTimeout: 5 * time.Second, + }) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + firstReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, firstReqID, 1, &tg.HelpGetConfigRequest{}) + select { + case <-handler.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for first rpc to start") + } + + secondReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, secondReqID, 3, &tg.HelpGetConfigRequest{}) + thirdReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, thirdReqID, 5, &tg.HelpGetConfigRequest{}) + + result := readRPCResultForRequest(t, conn, cipher, auth.AuthKey, thirdReqID) + var rpcErr mt.RPCError + if err := rpcErr.Decode(&bin.Buffer{Buf: result.Result}); err != nil { + t.Fatalf("decode rpc_error: %v", err) + } + if rpcErr.ErrorCode != 420 || rpcErr.ErrorMessage != "FLOOD_WAIT_1" { + t.Fatalf("rpc_error = %d %q, want 420 FLOOD_WAIT_1", rpcErr.ErrorCode, rpcErr.ErrorMessage) + } + close(handler.release) +} + +type blockingRPC struct { + started chan struct{} + release chan struct{} +} + +func (h *blockingRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) { + select { + case h.started <- struct{}{}: + default: + } + select { + case <-h.release: + return &tg.Config{ThisDC: 2}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func readRPCResultForRequest(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey, reqMsgID int64) proto.Result { + t.Helper() + for i := 0; i < 12; i++ { + _, id, plain := readServerMessage(t, conn, cipher, key) + if id != proto.ResultTypeID { + continue + } + var result proto.Result + if err := result.Decode(plain); err != nil { + t.Fatalf("decode rpc_result: %v", err) + } + if result.RequestMessageID == reqMsgID { + return result + } + } + t.Fatalf("missing rpc_result for req_msg_id %d", reqMsgID) + return proto.Result{} +} diff --git a/internal/mtprotoedge/rsakey.go b/internal/mtprotoedge/rsakey.go new file mode 100644 index 00000000..e33e9266 --- /dev/null +++ b/internal/mtprotoedge/rsakey.go @@ -0,0 +1,63 @@ +package mtprotoedge + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" +) + +// rsaKeyBits 是 server RSA 私钥位数。MTProto 要求 2048-bit。 +const rsaKeyBits = 2048 + +// LoadOrGenerateRSAKey 从 path 加载 PEM 编码的 server RSA 私钥; +// 不存在则生成 2048-bit 新密钥并持久化(含父目录)。 +// +// server RSA 私钥用于 MTProto 密钥交换;其公钥 fingerprint 需 patch 进 TDesktop +// (记录于 docs/tdesktop-patch-notes.md)。 +func LoadOrGenerateRSAKey(path string) (*rsa.PrivateKey, error) { + data, err := os.ReadFile(path) + switch { + case err == nil: + key, perr := parseRSAKeyPEM(data) + if perr != nil { + return nil, fmt.Errorf("parse %q: %w", path, perr) + } + return key, nil + case errors.Is(err, os.ErrNotExist): + // 继续生成。 + default: + return nil, fmt.Errorf("read %q: %w", path, err) + } + + key, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) + if err != nil { + return nil, fmt.Errorf("generate rsa key: %w", err) + } + + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create key dir %q: %w", dir, err) + } + } + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + if err := os.WriteFile(path, pemBytes, 0o600); err != nil { + return nil, fmt.Errorf("write key %q: %w", path, err) + } + return key, nil +} + +func parseRSAKeyPEM(data []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("no PEM block found") + } + return x509.ParsePKCS1PrivateKey(block.Bytes) +} diff --git a/internal/mtprotoedge/server.go b/internal/mtprotoedge/server.go new file mode 100644 index 00000000..5cbcf0c0 --- /dev/null +++ b/internal/mtprotoedge/server.go @@ -0,0 +1,364 @@ +package mtprotoedge + +import ( + "context" + "crypto/rsa" + "errors" + "fmt" + "io" + "net" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/crypto" + "github.com/gotd/td/exchange" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/proto/codec" + "github.com/gotd/td/tg" + "github.com/gotd/td/tmap" + "github.com/gotd/td/transport" + + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +// RPCHandler 把解密后的 RPC 请求体路由到响应。由 internal/rpc 实现。 +// +// b 是明文 RPC 请求(已剥离 MTProto 外壳);返回的 bin.Encoder 会被包成 rpc_result。 +// 返回 *tgerr.Error 时连接层将其转为 rpc_error 回发;其他 error 视为连接级故障。 +type RPCHandler interface { + Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error) +} + +// Options 配置 Server。 +type Options struct { + // Logger 日志器。默认 zap.NewNop()。 + Logger *zap.Logger + // Codec 传输 codec 构造器。nil 表示自动探测(intermediate/abridged/full)。 + Codec func() transport.Codec + // ObfuscatedTCP 先按 MTProto TCP obfuscation 解包,再自动探测 codec。 + // Telegram Desktop 的 tcpo_only endpoint 会走这个 64 字节前缀流程。 + ObfuscatedTCP bool + // ReadTimeout 单次读取超时。默认 5m。 + ReadTimeout time.Duration + // HandshakeIdleTimeout 是连接「建立 session 前」(握手 + 首个加密消息之前)的读超时, + // 比 ReadTimeout 短,用于快速回收握手后静默的半开 / 异常连接。默认 60s。 + HandshakeIdleTimeout time.Duration + // WriteTimeout 单次写入超时。默认 30s。 + WriteTimeout time.Duration + // RPCMaxInflight 是单连接同时处理的 RPC 上限。默认 32。 + RPCMaxInflight int + // RPCQueueSize 是单连接等待处理的 RPC 队列长度。默认 256。 + RPCQueueSize int + // RPCTimeout 是单个 RPC 在连接层的最大处理时长。默认 30s。 + RPCTimeout time.Duration + + // DC 是本 server 的 DC ID。默认 2。 + DC int + // RSAKey 是 server RSA 私钥,用于密钥交换。nil 时无法完成握手。 + RSAKey *rsa.PrivateKey + // AuthKeys 持久化 auth key。默认内存实现。 + AuthKeys store.AuthKeyStore + // Sessions 记录在线 MTProto session(持久化数据)。默认内存实现。 + Sessions store.SessionStore + // ActiveSessions 管理活跃连接。默认新建;传入时可让 RPC 层共享同一注册表。 + ActiveSessions *SessionManager + // RPC 是 typed RPC 路由。nil 时加密 RPC 被丢弃并记录。 + RPC RPCHandler + // Metrics 接收连接层指标。默认 NopMetrics。 + Metrics Metrics + // Clock 用于消息 ID 与时间戳。默认 clock.System。 + Clock clock.Clock + // Rand 随机源。默认 crypto.DefaultRand()。 + Rand io.Reader +} + +func (o *Options) setDefaults() { + if o.Logger == nil { + o.Logger = zap.NewNop() + } + if o.ReadTimeout == 0 { + o.ReadTimeout = 5 * time.Minute + } + if o.HandshakeIdleTimeout == 0 { + o.HandshakeIdleTimeout = 60 * time.Second + } + if o.WriteTimeout == 0 { + o.WriteTimeout = 30 * time.Second + } + if o.RPCMaxInflight <= 0 { + o.RPCMaxInflight = 32 + } + if o.RPCQueueSize <= 0 { + o.RPCQueueSize = 256 + } + if o.RPCTimeout == 0 { + o.RPCTimeout = 30 * time.Second + } + if o.DC == 0 { + o.DC = 2 + } + if o.AuthKeys == nil { + o.AuthKeys = memory.NewAuthKeyStore() + } + if o.Sessions == nil { + o.Sessions = memory.NewSessionStore() + } + if o.Metrics == nil { + o.Metrics = NopMetrics{} + } + if o.Clock == nil { + o.Clock = clock.System + } + if o.Rand == nil { + o.Rand = crypto.DefaultRand() + } +} + +// Server 是 MTProto 连接层(mtprotoedge)。 +// +// 职责见 doc.go。它把原始 TCP 字节流转换为「已解密、已识别 session 的 RPC 请求」: +// 接受连接、协商 codec、完成密钥交换、解密并分发加密消息到 RPC 路由,处理服务消息, +// 并把活跃连接注册到 SessionManager 以支持主动推送(updates 等)。不含业务逻辑。 +type Server struct { + log *zap.Logger + codec func() transport.Codec + obfuscated bool + readTimeout time.Duration + handshakeTimeout time.Duration + writeTimeout time.Duration + rpcInflight int + rpcQueueSize int + rpcTimeout time.Duration + + dc int + key exchange.PrivateKey + authKeys store.AuthKeyStore + sessions store.SessionStore + conns *SessionManager + rpc RPCHandler + metrics Metrics + cipher crypto.Cipher + clock clock.Clock + rand io.Reader + types *tmap.Map + + // sessionUID 是本进程 server session 唯一标识,写入 new_session_created。 + sessionUID int64 + + // onFrame 是测试钩子:收到一帧时回调其字节数;生产为 nil。 + onFrame func(n int) +} + +// New 创建 Server。 +func New(opts Options) *Server { + opts.setDefaults() + conns := opts.ActiveSessions + if conns == nil { + conns = NewSessionManager(opts.Logger.Named("sessions")) + } + return &Server{ + log: opts.Logger, + codec: opts.Codec, + obfuscated: opts.ObfuscatedTCP, + readTimeout: opts.ReadTimeout, + handshakeTimeout: opts.HandshakeIdleTimeout, + writeTimeout: opts.WriteTimeout, + rpcInflight: opts.RPCMaxInflight, + rpcQueueSize: opts.RPCQueueSize, + rpcTimeout: opts.RPCTimeout, + dc: opts.DC, + key: exchange.PrivateKey{RSA: opts.RSAKey}, + authKeys: opts.AuthKeys, + sessions: opts.Sessions, + conns: conns, + rpc: opts.RPC, + metrics: opts.Metrics, + cipher: crypto.NewServerCipher(opts.Rand), + clock: opts.Clock, + rand: opts.Rand, + types: tmap.New(tg.TypesMap(), mt.TypesMap(), proto.TypesMap()), + sessionUID: opts.Clock.Now().UnixNano(), + } +} + +// Conns 返回活跃连接注册表,供业务层主动推送(updates 等)。 +func (s *Server) Conns() *SessionManager { + return s.conns +} + +// newConn 基于一次解密结果创建一个可发送的连接对象。 +func (s *Server) newConn(tc transport.Conn, key crypto.AuthKey, sessionID, salt int64) *Conn { + c := &Conn{ + transport: tc, + writer: tc, + cipher: s.cipher, + msgID: proto.NewMessageIDGen(s.clock.Now), + writeTimeout: s.writeTimeout, + metrics: s.metrics, + authKeyID: key.ID, + sessionID: sessionID, + salt: salt, + key: key, + } + c.startOutbound() + c.startInboundRPCScheduler(s.rpcInflight, s.rpcQueueSize, s.rpcTimeout) + return c +} + +// Serve 在 ln 上运行 MTProto 连接循环,直到 ctx 取消或发生不可恢复错误。 +// ctx 取消时优雅退出:关闭 listener 并等待在途连接处理结束。 +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + transportListener := ln + if s.obfuscated { + transportListener = transport.ObfuscatedListener(ln) + } + l := transport.ListenCodec(s.codec, transportListener) + s.log.Info("Serving", zap.String("addr", ln.Addr().String()), zap.Int("dc", s.dc), zap.Bool("obfuscated_tcp", s.obfuscated)) + defer s.log.Info("Stopped") + + // ctx 取消时关闭 listener,解除 Accept 阻塞。 + go func() { + <-ctx.Done() + _ = l.Close() + }() + + var wg sync.WaitGroup + defer wg.Wait() + + for { + conn, err := l.Accept() + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return nil + } + if s.obfuscated && isClientDisconnect(err) { + s.log.Debug("Ignoring failed obfuscated accept", zap.Error(err)) + continue + } + return fmt.Errorf("accept: %w", err) + } + + wg.Add(1) + go func() { + defer wg.Done() + if err := s.serveConn(ctx, conn); err != nil && !isClientDisconnect(err) { + s.log.Info("Connection closed with error", zap.Error(err)) + } + }() + } +} + +// serveConn 处理单个传输连接:读帧并按 auth_key_id 分流。 +// +// - auth_key_id == 0:未加密的密钥交换起始消息,执行握手并落地 auth key。 +// - auth_key_id 已注册:加密消息,解密、注册连接并分发到 RPC 路由。 +// - auth_key_id 未注册:回 AuthKeyNotFound,促使客户端重新握手。 +// +// 连接建立 session 后注册到 SessionManager,结束时注销。 +func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error) { + s.metrics.ConnOpened() + s.log.Debug("Connection accepted") + + var current *Conn + defer func() { + if current != nil { + s.conns.Unregister(current) + current.Close() + } + s.metrics.ConnClosed() + s.log.Debug("Connection closed", zap.Error(err)) + }() + + // ctx 取消或处理结束时关闭连接,解除 Recv 阻塞。 + ctx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + <-ctx.Done() + _ = conn.Close() + }() + + cs := newConnState() + var b bin.Buffer + var replay *bin.Buffer + for { + if replay != nil { + b.ResetTo(replay.Copy()) + replay = nil + } else { + // 建立 session 前(current==nil,握手 + 首个加密消息之前)用较短的 handshakeTimeout + // 快速回收静默的半开 / 异常连接;建立 session 后用 readTimeout(客户端有 ping 心跳)。 + timeout := s.readTimeout + if current == nil { + timeout = s.handshakeTimeout + } + if err := s.recv(ctx, conn, &b, timeout); err != nil { + return err + } + if s.onFrame != nil { + s.onFrame(b.Len()) + } + } + + authKeyID, err := peekAuthKeyID(&b) + if err != nil { + return fmt.Errorf("peek auth key id: %w", err) + } + + if authKeyID == emptyAuthKeyID { + next, err := s.handleExchange(ctx, conn, &b) + if err != nil { + return err + } + replay = next + continue + } + + data, found, err := s.authKeys.Get(ctx, authKeyID) + if err != nil { + return fmt.Errorf("lookup auth key: %w", err) + } + if !found { + if err := s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound); err != nil { + return err + } + continue + } + + current, err = s.handleEncrypted(ctx, conn, cs, current, data, &b) + if err != nil { + return err + } + } +} + +func (s *Server) recv(ctx context.Context, conn transport.Conn, b *bin.Buffer, timeout time.Duration) error { + b.Reset() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return conn.Recv(ctx, b) +} + +// isClientDisconnect 判断错误是否为正常的客户端断开/服务关闭,不应作为异常记录。 +func isClientDisconnect(err error) bool { + switch { + case errors.Is(err, io.EOF), + errors.Is(err, net.ErrClosed), + errors.Is(err, context.Canceled), + errors.Is(err, context.DeadlineExceeded): + return true + } + var nerr *net.OpError + if errors.As(err, &nerr) && (nerr.Op == "read" || nerr.Op == "write") { + return true + } + return false +} diff --git a/internal/mtprotoedge/server_test.go b/internal/mtprotoedge/server_test.go new file mode 100644 index 00000000..394b3635 --- /dev/null +++ b/internal/mtprotoedge/server_test.go @@ -0,0 +1,165 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/mtproxy" + "github.com/gotd/td/mtproxy/obfuscator" + "github.com/gotd/td/proto/codec" + "github.com/gotd/td/transport" +) + +// TestServerAcceptAndCodec 验证 M0: +// server 能接受连接、自动协商 codec、读到客户端帧,并在 ctx 取消时优雅退出。 +func TestServerAcceptAndCodec(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + frames := make(chan int, 1) + srv := New(Options{Logger: zaptest.NewLogger(t)}) + srv.onFrame = func(n int) { + select { + case frames <- n: + default: + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + // 客户端:TCP 拨号 + intermediate 协议握手 + 发送一帧。 + raw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn, err := transport.Intermediate.Handshake(raw) + if err != nil { + t.Fatalf("handshake: %v", err) + } + + // payload 必须 ≠ 4 字节:codec 把恰好 4 字节的帧当作 transport 协议错误码(checkProtocolError)。 + // 真实 MTProto 帧远大于 4 字节,这里发 8 字节模拟一个普通帧。 + var b bin.Buffer + b.PutInt32(0x12345678) + b.PutInt32(0x0badf00d) + sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second) + defer sc() + if err := conn.Send(sendCtx, &b); err != nil { + t.Fatalf("send: %v", err) + } + + select { + case n := <-frames: + if n <= 0 { + t.Fatalf("received empty frame, len = %d", n) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not receive frame in time") + } + + _ = conn.Close() + + // 验证优雅退出。 + cancel() + select { + case err := <-serveErr: + if err != nil { + t.Fatalf("serve returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not stop after ctx cancel") + } +} + +// TestServerAcceptObfuscatedAbridged 验证 TDesktop tcpo_only 连接形态: +// 先做 MTProto TCP obfuscation,再在解密后的流上使用 abridged codec。 +func TestServerAcceptObfuscatedAbridged(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + frames := make(chan int, 1) + srv := New(Options{Logger: zaptest.NewLogger(t), ObfuscatedTCP: true}) + srv.onFrame = func(n int) { + select { + case frames <- n: + default: + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + bad, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("bad dial: %v", err) + } + _ = bad.Close() + time.Sleep(50 * time.Millisecond) + select { + case err := <-serveErr: + t.Fatalf("server stopped after bad obfuscated accept: %v", err) + default: + } + + raw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + obfs := obfuscator.Obfuscated2(rand.Reader, raw) + if err := obfs.Handshake((codec.Abridged{}).ObfuscatedTag(), 2, mtproxy.Secret{}); err != nil { + t.Fatalf("obfuscated handshake: %v", err) + } + conn, err := transport.NewProtocol(func() transport.Codec { + return transport.Abridged.CodecNoHeader() + }).Handshake(obfs) + if err != nil { + t.Fatalf("transport handshake: %v", err) + } + + var b bin.Buffer + b.PutInt32(0x12345678) + b.PutInt32(0x0badf00d) + sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second) + defer sc() + if err := conn.Send(sendCtx, &b); err != nil { + t.Fatalf("send: %v", err) + } + + select { + case n := <-frames: + if n <= 0 { + t.Fatalf("received empty frame, len = %d", n) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not receive frame in time") + } + + _ = conn.Close() + + cancel() + select { + case err := <-serveErr: + if err != nil { + t.Fatalf("serve returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not stop after ctx cancel") + } +} diff --git a/internal/mtprotoedge/session_manager.go b/internal/mtprotoedge/session_manager.go new file mode 100644 index 00000000..a397b13d --- /dev/null +++ b/internal/mtprotoedge/session_manager.go @@ -0,0 +1,978 @@ +package mtprotoedge + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" +) + +// ErrSessionNotFound 表示目标 session 当前无活跃连接。 +var ErrSessionNotFound = errors.New("session not found") + +// ErrSessionAmbiguous 表示仅用 session_id 无法唯一定位连接。 +var ErrSessionAmbiguous = errors.New("session id is shared by multiple auth keys") + +const ( + maxPendingPushesPerSession = 32 + // pendingPushMaxAge:session 注册后迟迟不调 updates.getState(receivesUpdates 恒 false)时, + // 其暂存的主动推送最长保留时长。超过即丢整批并不再囤——正常 TDesktop 登录后秒级就会 + // getState 建立同步基线;长期不 ready 多为异常/对抗连接。丢弃不丢消息:getDifference 以 + // user_update_events durable log 兜底补齐。 + pendingPushMaxAge = 60 * time.Second +) + +type queuedPush struct { + t proto.MessageType + msg bin.Encoder + at time.Time +} + +type sessionKey struct { + authKeyID [8]byte + sessionID int64 +} + +// SessionLifecycleObserver receives active connection lifecycle events. +type SessionLifecycleObserver interface { + SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) +} + +// SessionManager 是活跃连接注册表,支持按 session / auth-key / user 查找并主动 push。 +// +// 它管理运行态的在线连接,与持久化的 store.SessionStore 互补:后者记录 session 数据, +// 前者持有可发送的活跃连接。所有方法并发安全。 +type SessionManager struct { + mu sync.RWMutex + bySession map[sessionKey]*Conn + bySessionID map[int64]map[[8]byte]*Conn // sessionID → raw authKeyID → Conn,用于兼容旧 API 的唯一性检查 + byAuthKey map[[8]byte]map[int64]*Conn // raw authKeyID → sessionID → Conn + byUser map[int64]map[sessionKey]*Conn + byChannel map[int64]map[sessionKey]int64 // channelID → session → userID,用于频道 active-viewer 临时推送 + bySessionChannels map[sessionKey]map[int64]struct{} + byMemberChannel map[int64]map[sessionKey]int64 // channelID → session → userID,用于已上线成员持久 update 推送 + bySessionMembers map[sessionKey]map[int64]struct{} + pending map[sessionKey][]queuedPush // updates-ready 前暂存的主动推送 + + lifecycle SessionLifecycleObserver + log *zap.Logger +} + +// NewSessionManager 创建空的连接注册表。 +func NewSessionManager(log *zap.Logger) *SessionManager { + if log == nil { + log = zap.NewNop() + } + return &SessionManager{ + bySession: make(map[sessionKey]*Conn), + bySessionID: make(map[int64]map[[8]byte]*Conn), + byAuthKey: make(map[[8]byte]map[int64]*Conn), + byUser: make(map[int64]map[sessionKey]*Conn), + byChannel: make(map[int64]map[sessionKey]int64), + bySessionChannels: make(map[sessionKey]map[int64]struct{}), + byMemberChannel: make(map[int64]map[sessionKey]int64), + bySessionMembers: make(map[sessionKey]map[int64]struct{}), + pending: make(map[sessionKey][]queuedPush), + log: log, + } +} + +// SetLifecycleObserver installs a best-effort active session lifecycle observer. +func (m *SessionManager) SetLifecycleObserver(observer SessionLifecycleObserver) { + m.mu.Lock() + m.lifecycle = observer + m.mu.Unlock() +} + +// Register 注册一个活跃连接。若同 raw auth_key_id + session_id 已存在(重连),旧连接被替换并移除索引。 +func (m *SessionManager) Register(c *Conn) { + m.mu.Lock() + + key := connSessionKey(c) + var replaced *Conn + if old, ok := m.bySession[key]; ok && old != c { + replaced = old + m.removeLocked(old, false) + } + m.bySession[key] = c + addSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID, c) + addConnIndex(m.byAuthKey, c.authKeyID, c.sessionID, c) + if uid := c.userID.Load(); uid != 0 { + c.userIDResolved.Store(true) + addUserIndex(m.byUser, uid, key, c) + } + m.log.Debug("Session registered", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", c.sessionID), + zap.Int("online", len(m.bySession)), + ) + m.mu.Unlock() + + if replaced != nil { + replaced.Close() + } +} + +// Unregister 注销一个连接(仅当它仍是当前注册的同一对象,避免误删重连后的新连接)。 +func (m *SessionManager) Unregister(c *Conn) { + m.mu.Lock() + var ( + observer SessionLifecycleObserver + offlineUser int64 + lastForUser bool + ) + if cur, ok := m.bySession[connSessionKey(c)]; ok && cur == c { + offlineUser = m.removeLocked(c, true) + if offlineUser != 0 { + lastForUser = len(m.byUser[offlineUser]) == 0 + observer = m.lifecycle + } + m.log.Debug("Session unregistered", + zap.String("auth_key_id", sessionKeyLog(c.authKeyID)), + zap.Int64("session_id", c.sessionID), + zap.Int("online", len(m.bySession)), + ) + } + m.mu.Unlock() + if observer != nil && offlineUser != 0 { + observer.SessionOffline(c.authKeyID, c.sessionID, offlineUser, lastForUser) + } +} + +// DestroySession 移除指定 session 的运行态索引,供 MTProto destroy_session 使用。 +func (m *SessionManager) DestroySession(sessionID int64) bool { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous || !ok { + if !ambiguous { + m.dropPendingBySessionLocked(sessionID) + } + m.mu.Unlock() + return false + } + offlineUser := m.removeLocked(c, true) + lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0 + observer := m.lifecycle + m.log.Debug("Session destroyed", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", sessionID), + zap.Int("online", len(m.bySession)), + ) + m.mu.Unlock() + c.Close() + if observer != nil && offlineUser != 0 { + observer.SessionOffline(key.authKeyID, sessionID, offlineUser, lastForUser) + } + return true +} + +// DestroySessionForAuthKey 精确移除某个 raw auth_key_id 下的 session。 +func (m *SessionManager) DestroySessionForAuthKey(authKeyID [8]byte, sessionID int64) bool { + m.mu.Lock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + delete(m.pending, key) + m.mu.Unlock() + return false + } + offlineUser := m.removeLocked(c, true) + lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0 + observer := m.lifecycle + m.log.Debug("Session destroyed", + zap.String("auth_key_id", sessionKeyLog(authKeyID)), + zap.Int64("session_id", sessionID), + zap.Int("online", len(m.bySession)), + ) + m.mu.Unlock() + c.Close() + if observer != nil && offlineUser != 0 { + observer.SessionOffline(authKeyID, sessionID, offlineUser, lastForUser) + } + return true +} + +// BindUser 缓存 session 的授权用户。userID=0 表示当前 auth_key 已确认未登录。 +// 登录后绑定非 0 userID,使其可经 PushToUser 收到推送。 +func (m *SessionManager) BindUser(sessionID, userID int64) { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous || !ok { + if ambiguous { + m.log.Warn("Skip BindUser for ambiguous session_id", zap.Int64("session_id", sessionID)) + } + m.mu.Unlock() + return + } + m.bindUserLocked(c, key, userID) + m.mu.Unlock() +} + +// BindUserForAuthKey 缓存指定 raw auth_key_id + session_id 的授权用户。 +func (m *SessionManager) BindUserForAuthKey(authKeyID [8]byte, sessionID, userID int64) { + m.mu.Lock() + defer m.mu.Unlock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + return + } + m.bindUserLocked(c, key, userID) +} + +func (m *SessionManager) bindUserLocked(c *Conn, key sessionKey, userID int64) { + if old := c.userID.Swap(userID); old != 0 { + removeUserIndex(m.byUser, old, key) + if old != userID { + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + } + } + c.userIDResolved.Store(true) + if userID != 0 { + addUserIndex(m.byUser, userID, key, c) + } else { + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + } +} + +// UserID 返回 session 当前缓存的登录用户 id。未绑定或离线时 ok=false。 +func (m *SessionManager) UserID(sessionID int64) (int64, bool) { + m.mu.RLock() + c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID) + m.mu.RUnlock() + if ambiguous || !ok { + return 0, false + } + userID := c.userID.Load() + if userID == 0 { + return 0, false + } + return userID, true +} + +// UserIDForAuthKey 返回指定 raw auth_key_id + session_id 当前缓存的登录用户 id。 +func (m *SessionManager) UserIDForAuthKey(authKeyID [8]byte, sessionID int64) (int64, bool) { + m.mu.RLock() + c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}] + m.mu.RUnlock() + if !ok { + return 0, false + } + userID := c.userID.Load() + if userID == 0 { + return 0, false + } + return userID, true +} + +// UserIDResolved 返回 session 的 user_id 授权状态是否已经查过。 +// resolved=true 且 userID=0 表示该 session 当前未登录。 +func (m *SessionManager) UserIDResolved(sessionID int64) (int64, bool) { + m.mu.RLock() + c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID) + m.mu.RUnlock() + if ambiguous || !ok { + return 0, false + } + return c.UserIDResolved() +} + +// UserIDResolvedForAuthKey 返回指定 raw auth_key_id + session_id 的 user_id 缓存状态。 +func (m *SessionManager) UserIDResolvedForAuthKey(authKeyID [8]byte, sessionID int64) (int64, bool) { + m.mu.RLock() + c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}] + m.mu.RUnlock() + if !ok { + return 0, false + } + return c.UserIDResolved() +} + +// BindAuthKey 缓存业务视角 auth_key_id(temp auth_key 解析后的 perm auth_key)。 +func (m *SessionManager) BindAuthKey(sessionID int64, authKeyID [8]byte) { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous || !ok { + if ambiguous { + m.log.Warn("Skip BindAuthKey for ambiguous session_id", zap.Int64("session_id", sessionID)) + } + m.mu.Unlock() + return + } + m.bindAuthKeyLocked(c, key, authKeyID) + m.mu.Unlock() +} + +// BindAuthKeyForSession 缓存指定 raw auth_key_id + session_id 的业务 auth_key_id。 +func (m *SessionManager) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) { + m.mu.Lock() + defer m.mu.Unlock() + key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + return + } + m.bindAuthKeyLocked(c, key, authKeyID) +} + +func (m *SessionManager) bindAuthKeyLocked(c *Conn, key sessionKey, authKeyID [8]byte) { + oldAuthKeyID, resolved := c.BusinessAuthKeyID() + changed := !resolved || oldAuthKeyID != authKeyID + oldUserID := c.userID.Load() + c.SetBusinessAuthKeyID(authKeyID) + if changed { + if oldUserID != 0 { + removeUserIndex(m.byUser, oldUserID, key) + } + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + c.userID.Store(0) + c.userIDResolved.Store(false) + } +} + +// AuthKeyID 返回 session 缓存的业务视角 auth_key_id。 +// ok=false 表示该连接尚未完成 temp→perm 解析。 +func (m *SessionManager) AuthKeyID(sessionID int64) ([8]byte, bool) { + m.mu.RLock() + c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID) + m.mu.RUnlock() + if ambiguous || !ok { + return [8]byte{}, false + } + return c.BusinessAuthKeyID() +} + +// AuthKeyIDForSession 返回指定 raw auth_key_id + session_id 缓存的业务 auth_key_id。 +func (m *SessionManager) AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int64) ([8]byte, bool) { + m.mu.RLock() + c, ok := m.bySession[sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}] + m.mu.RUnlock() + if !ok { + return [8]byte{}, false + } + return c.BusinessAuthKeyID() +} + +// UnbindAuthKey 清理某业务 auth_key 下所有活跃连接的登录用户缓存。 +func (m *SessionManager) UnbindAuthKey(authKeyID [8]byte) int { + m.mu.Lock() + defer m.mu.Unlock() + count := 0 + for key, c := range m.bySession { + if !connUsesBusinessAuthKey(c, authKeyID) { + continue + } + if old := c.userID.Swap(0); old != 0 { + removeUserIndex(m.byUser, old, key) + } + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + c.userIDResolved.Store(true) + count++ + } + return count +} + +// SetReceivesUpdates 标记 session 是否已完成 updates 同步入口。 +// +// TDesktop 登录后会先调用 updates.getState/getDifference 建立本地同步基线。 +// 在此之前收到的主动 updates 先暂存,待 session 可接收后再异步下发。 +func (m *SessionManager) SetReceivesUpdates(sessionID int64, receives bool) { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous || !ok { + if ambiguous { + m.log.Warn("Skip SetReceivesUpdates for ambiguous session_id", zap.Int64("session_id", sessionID)) + } + m.mu.Unlock() + return + } + c.receivesUpdates.Store(receives) + if !receives { + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + } + pending := m.takePendingLocked(key, receives) + m.mu.Unlock() + + if len(pending) > 0 { + go m.flushPending(key, pending) + } +} + +// SetReceivesUpdatesForAuthKey 标记指定 raw auth_key_id + session_id 是否接收主动 updates。 +func (m *SessionManager) SetReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID int64, receives bool) { + m.mu.Lock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + m.mu.Unlock() + return + } + c.receivesUpdates.Store(receives) + if !receives { + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + } + pending := m.takePendingLocked(key, receives) + m.mu.Unlock() + + if len(pending) > 0 { + go m.flushPending(key, pending) + } +} + +// PushToSession 向指定 session 推送一条消息。 +func (m *SessionManager) PushToSession(ctx context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous { + m.mu.Unlock() + return ErrSessionAmbiguous + } + if !ok { + m.mu.Unlock() + return ErrSessionNotFound + } + if !c.receivesUpdates.Load() { + m.queueLocked(key, t, msg) + m.mu.Unlock() + return nil + } + m.mu.Unlock() + return c.Send(ctx, t, msg) +} + +// PushToSessionForAuthKey 向指定 raw auth_key_id + session_id 推送一条消息。 +func (m *SessionManager) PushToSessionForAuthKey(ctx context.Context, authKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + m.mu.Lock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + m.mu.Unlock() + return ErrSessionNotFound + } + if !c.receivesUpdates.Load() { + m.queueLocked(key, t, msg) + m.mu.Unlock() + return nil + } + m.mu.Unlock() + return c.Send(ctx, t, msg) +} + +// PushToUser 向某 user 所有活跃连接推送,返回已发送或已暂存的连接数。 +// 发送在释放锁后进行,避免持锁阻塞于网络 IO。 +func (m *SessionManager) PushToUser(ctx context.Context, userID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + return m.PushToUserExceptAuthKeySession(ctx, userID, [8]byte{}, 0, t, msg) +} + +// PushToUserExceptSession 向某 user 所有活跃连接推送,但跳过指定 session。 +// 未完成 updates 同步入口的 session 会先暂存,等 SetReceivesUpdates(true) 后再发。 +func (m *SessionManager) PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + return m.pushToUser(ctx, userID, nil, excludeSessionID, t, msg) +} + +// PushToUserExceptAuthKeySession 向某 user 所有活跃连接推送,跳过指定业务 auth_key + session。 +func (m *SessionManager) PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + return m.pushToUser(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg) +} + +func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, func(c *Conn) error { + return c.Send(ctx, t, msg) + }) +} + +func (m *SessionManager) PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { + return m.pushToUserBestEffort(ctx, userID, nil, excludeSessionID, t, msg, timeout) +} + +func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { + return m.pushToUserBestEffort(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg, timeout) +} + +func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, func(c *Conn) error { + return c.SendBestEffort(ctx, t, msg, timeout) + }) +} + +func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, send func(*Conn) error) (int, error) { + m.mu.Lock() + conns := make([]*Conn, 0, len(m.byUser[userID])) + queued := 0 + for key, c := range m.byUser[userID] { + if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) { + continue + } + if !c.receivesUpdates.Load() { + m.queueLocked(key, t, msg) + queued++ + continue + } + conns = append(conns, c) + } + m.mu.Unlock() + + var firstErr error + sent := 0 + for _, c := range conns { + if err := send(c); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + sent++ + } + return sent + queued, firstErr +} + +// Online 返回当前活跃连接数。 +func (m *SessionManager) Online() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.bySession) +} + +// OnlineUserIDs returns a bounded snapshot of users that currently have active +// sessions. Callers still need to verify business visibility before pushing. +func (m *SessionManager) OnlineUserIDs(limit int) []int64 { + m.mu.RLock() + defer m.mu.RUnlock() + if len(m.byUser) == 0 { + return nil + } + capHint := len(m.byUser) + if limit > 0 && capHint > limit { + capHint = limit + } + ids := make([]int64, 0, capHint) + for userID, conns := range m.byUser { + if userID == 0 || len(conns) == 0 { + continue + } + ids = append(ids, userID) + if limit > 0 && len(ids) >= limit { + break + } + } + return ids +} + +// IsUserOnline returns whether userID has at least one active connection. +func (m *SessionManager) IsUserOnline(userID int64) bool { + if userID == 0 { + return false + } + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.byUser[userID]) > 0 +} + +// OnlineUserIDsForCandidates filters an explicit candidate set against the +// active user index. It avoids exporting or sorting the whole online map. +func (m *SessionManager) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 { + if len(candidateUserIDs) == 0 { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]int64, 0, minInt(len(candidateUserIDs), positiveLimitOrLen(limit, len(candidateUserIDs)))) + seen := make(map[int64]struct{}, len(candidateUserIDs)) + for _, userID := range candidateUserIDs { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + if len(m.byUser[userID]) == 0 { + continue + } + out = append(out, userID) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +// TrackChannelInterest replaces the channel viewer set for one live session. +// Realtime transient fan-out uses this as the current active-viewer candidate +// set; durable channel updates use the broader membership index instead. +func (m *SessionManager) TrackChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) { + if userID == 0 { + return + } + key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID} + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.bySession[key] + if !ok || c.userID.Load() != userID { + return + } + m.clearChannelInterestsLocked(key) + if len(channelIDs) == 0 { + return + } + m.trackChannelIndexLocked(m.byChannel, m.bySessionChannels, key, userID, channelIDs) +} + +// ClearChannelInterest removes the active-viewer channel set for one live +// session while leaving its joined-channel membership index intact. +func (m *SessionManager) ClearChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64) { + if userID == 0 { + return + } + key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID} + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.bySession[key] + if !ok || c.userID.Load() != userID { + return + } + m.clearChannelInterestsLocked(key) +} + +// OnlineChannelUserIDs returns users with active sessions that have recently +// proven current interest in channelID. The result is intentionally unsorted and bounded. +func (m *SessionManager) OnlineChannelUserIDs(channelID int64, limit int) []int64 { + return m.onlineChannelUsers(m.byChannel, channelID, limit) +} + +// SetSessionChannelMemberships replaces the joined-channel index for one +// updates-ready session. This index is broader than TrackChannelInterest and is +// used for durable channel updates such as new/edit/delete message. +func (m *SessionManager) SetSessionChannelMemberships(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) { + key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID} + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.bySession[key] + if !ok { + return + } + m.clearChannelMembershipsLocked(key) + if userID == 0 || c.userID.Load() != userID { + return + } + m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, channelIDs) +} + +// AddUserChannelMembership adds channelID to every live session for userID. +// It is called after successful join/invite approval paths. +func (m *SessionManager) AddUserChannelMembership(userID, channelID int64) { + if userID == 0 || channelID == 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + for key, c := range m.byUser[userID] { + if c == nil || c.userID.Load() != userID { + continue + } + m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, []int64{channelID}) + } +} + +// RemoveUserChannelMembership removes channelID from every live session for userID. +// It is called after leave/kick/ban/delete paths. +func (m *SessionManager) RemoveUserChannelMembership(userID, channelID int64) { + if userID == 0 || channelID == 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + for key := range m.byUser[userID] { + m.removeChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, channelID) + } +} + +// OnlineChannelMemberUserIDs returns users with active sessions that are indexed +// as joined members of channelID. The result is intentionally unsorted; callers +// still verify business membership before pushing. +func (m *SessionManager) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 { + return m.onlineChannelUsers(m.byMemberChannel, channelID, limit) +} + +func (m *SessionManager) onlineChannelUsers(index map[int64]map[sessionKey]int64, channelID int64, limit int) []int64 { + if channelID == 0 { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + sessions := index[channelID] + if len(sessions) == 0 { + return nil + } + out := make([]int64, 0, positiveLimitOrLen(limit, len(sessions))) + seen := make(map[int64]struct{}, len(sessions)) + for key, userID := range sessions { + if userID == 0 { + continue + } + if _, ok := m.bySession[key]; !ok { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + out = append(out, userID) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 { + key := connSessionKey(c) + delete(m.bySession, key) + removeSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID) + removeConnIndex(m.byAuthKey, c.authKeyID, c.sessionID) + uid := c.userID.Load() + if uid != 0 { + removeUserIndex(m.byUser, uid, key) + } + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + if dropPending { + delete(m.pending, key) + } + return uid +} + +func (m *SessionManager) clearChannelInterestsLocked(key sessionKey) { + m.clearChannelIndexLocked(m.byChannel, m.bySessionChannels, key) +} + +func (m *SessionManager) clearChannelMembershipsLocked(key sessionKey) { + m.clearChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key) +} + +func (m *SessionManager) trackChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey, userID int64, channelIDs []int64) { + channels := reverse[key] + if channels == nil { + channels = make(map[int64]struct{}, len(channelIDs)) + reverse[key] = channels + } + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + channels[channelID] = struct{}{} + sessions := index[channelID] + if sessions == nil { + sessions = make(map[sessionKey]int64) + index[channelID] = sessions + } + sessions[key] = userID + } +} + +func (m *SessionManager) clearChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey) { + channels := reverse[key] + if len(channels) == 0 { + delete(reverse, key) + return + } + for channelID := range channels { + sessions := index[channelID] + delete(sessions, key) + if len(sessions) == 0 { + delete(index, channelID) + } + } + delete(reverse, key) +} + +func (m *SessionManager) removeChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey, channelID int64) { + channels := reverse[key] + delete(channels, channelID) + if len(channels) == 0 { + delete(reverse, key) + } + sessions := index[channelID] + delete(sessions, key) + if len(sessions) == 0 { + delete(index, channelID) + } +} + +func positiveLimitOrLen(limit, length int) int { + if limit > 0 && limit < length { + return limit + } + return length +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func (m *SessionManager) takePendingLocked(key sessionKey, ready bool) []queuedPush { + if !ready || len(m.pending[key]) == 0 { + return nil + } + pending := append([]queuedPush(nil), m.pending[key]...) + delete(m.pending, key) + return pending +} + +func (m *SessionManager) queueLocked(key sessionKey, t proto.MessageType, msg bin.Encoder) { + q := m.pending[key] + // 过期保护:最早一条暂存已超过 pendingPushMaxAge(session 迟迟未 ready)时,丢整批并 + // 不再囤这条,记 trace。避免「登录后从不 getState」的连接长期占用 pending 内存。 + if len(q) > 0 && time.Since(q[0].at) > pendingPushMaxAge { + m.log.Debug("Drop stale pending pushes (session not ready in time)", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + zap.Int("dropped", len(q)), + ) + delete(m.pending, key) + return + } + push := queuedPush{t: t, msg: msg, at: time.Now()} + if len(q) >= maxPendingPushesPerSession { + copy(q, q[1:]) + q[len(q)-1] = push + m.pending[key] = q + return + } + m.pending[key] = append(q, push) +} + +func (m *SessionManager) flushPending(key sessionKey, pending []queuedPush) { + for _, item := range pending { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := m.PushToSessionForAuthKey(ctx, key.authKeyID, key.sessionID, item.t, item.msg) + cancel() + if err != nil { + m.log.Debug("Flush pending push failed", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + zap.Error(err), + ) + } + } +} + +func (m *SessionManager) uniqueSessionLocked(sessionID int64) (*Conn, sessionKey, bool, bool) { + set := m.bySessionID[sessionID] + if len(set) == 0 { + return nil, sessionKey{}, false, false + } + if len(set) > 1 { + return nil, sessionKey{}, false, true + } + for authKeyID, c := range set { + return c, sessionKey{authKeyID: authKeyID, sessionID: sessionID}, true, false + } + return nil, sessionKey{}, false, false +} + +func (m *SessionManager) dropPendingBySessionLocked(sessionID int64) { + for key := range m.pending { + if key.sessionID == sessionID { + delete(m.pending, key) + } + } +} + +func addConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64, c *Conn) { + set := idx[key] + if set == nil { + set = make(map[int64]*Conn) + idx[key] = set + } + set[sessionID] = c +} + +func removeConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64) { + if set := idx[key]; set != nil { + delete(set, sessionID) + if len(set) == 0 { + delete(idx, key) + } + } +} + +func addSessionIDIndex(idx map[int64]map[[8]byte]*Conn, sessionID int64, authKeyID [8]byte, c *Conn) { + set := idx[sessionID] + if set == nil { + set = make(map[[8]byte]*Conn) + idx[sessionID] = set + } + set[authKeyID] = c +} + +func removeSessionIDIndex(idx map[int64]map[[8]byte]*Conn, sessionID int64, authKeyID [8]byte) { + if set := idx[sessionID]; set != nil { + delete(set, authKeyID) + if len(set) == 0 { + delete(idx, sessionID) + } + } +} + +func addUserIndex(idx map[int64]map[sessionKey]*Conn, userID int64, key sessionKey, c *Conn) { + set := idx[userID] + if set == nil { + set = make(map[sessionKey]*Conn) + idx[userID] = set + } + set[key] = c +} + +func removeUserIndex(idx map[int64]map[sessionKey]*Conn, userID int64, key sessionKey) { + if set := idx[userID]; set != nil { + delete(set, key) + if len(set) == 0 { + delete(idx, userID) + } + } +} + +func connSessionKey(c *Conn) sessionKey { + return sessionKey{authKeyID: c.authKeyID, sessionID: c.sessionID} +} + +func connUsesBusinessAuthKey(c *Conn, authKeyID [8]byte) bool { + id, resolved := c.BusinessAuthKeyID() + if resolved { + return id == authKeyID + } + return c.authKeyID == authKeyID +} + +func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID int64) bool { + if excludeSessionID == 0 { + return false + } + if c.sessionID != excludeSessionID { + return false + } + if excludeAuthKeyID == nil || *excludeAuthKeyID == ([8]byte{}) { + return true + } + return connUsesBusinessAuthKey(c, *excludeAuthKeyID) +} + +func sessionKeyLog(id [8]byte) string { + return fmt.Sprintf("%x", id) +} diff --git a/internal/mtprotoedge/session_manager_bench_test.go b/internal/mtprotoedge/session_manager_bench_test.go new file mode 100644 index 00000000..c7d54099 --- /dev/null +++ b/internal/mtprotoedge/session_manager_bench_test.go @@ -0,0 +1,215 @@ +package mtprotoedge + +import ( + "context" + "fmt" + "os" + "runtime" + "sort" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// 连接层 fan-out / churn 压测:聚焦 SessionManager 的锁争用,不走真实 socket / 加密。 +// +// 构造的 Conn 故意不 startOutbound:pushToUser 持锁快照 byUser 后,锁外对每个 conn 调 c.Send, +// 此时 c.outbound==nil 立即返回 ErrConnClosed(见 outbound.go),因此测量集中在「持锁段 + 分发开销」, +// 即分片要消除的全局锁热点。每条连接仅结构体内存(无 1024 容量的 outbound channel、无 goroutine), +// 故可注册到 20 万规模。 +// +// 用法: +// +// go test ./internal/mtprotoedge/ -run '^$' -bench BenchmarkSessionManager -benchmem -cpu 1,4,8 +// go test ./internal/mtprotoedge/ -run '^$' -bench BenchmarkSessionManagerPushConcurrent -mutexprofile mu.out +// TELESRV_LOAD_CONNS=200000 go test ./internal/mtprotoedge/ -run TestSessionManagerFanoutThroughput -v -timeout 300s + +func benchConn(sessionID int64, authKeyID [8]byte, userID int64) *Conn { + c := &Conn{sessionID: sessionID, authKeyID: authKeyID} + if userID != 0 { + c.userID.Store(userID) + c.userIDResolved.Store(true) + } + c.receivesUpdates.Store(true) // 走 fanout 的「收集 conns→锁外 Send」分支,而非 pending 暂存 + return c +} + +func authKeyIDFromInt(v uint64) [8]byte { + var id [8]byte + for i := 0; i < 8; i++ { + id[i] = byte(v >> (8 * i)) + } + return id +} + +// seedSessions 注册 conns 个连接,每个 user 绑定 connsPerUser 个连接(模拟多设备)。 +// 返回注册的 userID 列表(去重、有序范围 [1, userCount])。 +func seedSessions(sm *SessionManager, conns, connsPerUser int) (userCount int) { + if connsPerUser < 1 { + connsPerUser = 1 + } + for i := 0; i < conns; i++ { + userID := int64(i/connsPerUser) + 1 + sm.Register(benchConn(int64(i)+1, authKeyIDFromInt(uint64(i)+1), userID)) + } + return (conns + connsPerUser - 1) / connsPerUser +} + +// BenchmarkSessionManagerPushConcurrent 模拟 20 万在线下的真实热点:大量 goroutine 并发对 +// 不同 user pushToUser,全部抢同一把全局锁。-mutexprofile 会把 SessionManager.mu 顶上来。 +func BenchmarkSessionManagerPushConcurrent(b *testing.B) { + const conns = 200_000 + const connsPerUser = 2 + sm := NewSessionManager(zap.NewNop()) + userCount := seedSessions(sm, conns, connsPerUser) + msg := &tg.UpdatesTooLong{} + ctx := context.Background() + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + var n uint64 + for pb.Next() { + n++ + userID := int64(n%uint64(userCount)) + 1 + _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) + } + }) +} + +// BenchmarkSessionManagerRegisterChurn 测连接建立/断开的锁成本:并发 Register+Unregister。 +// 20 万在线意味着持续的 connect/disconnect churn,每次都抢全局写锁。 +func BenchmarkSessionManagerRegisterChurn(b *testing.B) { + sm := NewSessionManager(zap.NewNop()) + var seq atomic.Uint64 + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + id := seq.Add(1) + c := benchConn(int64(id), authKeyIDFromInt(id), int64(id)) + sm.Register(c) + sm.Unregister(c) + } + }) +} + +// BenchmarkSessionManagerPushFanoutWidth 测单次 push 的 fanout 广度成本:一个 user 绑定很多连接, +// 单次 PushToUser 要持锁遍历全部。真实私聊 user 设备数少(2-4),此为上界参考。 +func BenchmarkSessionManagerPushFanoutWidth(b *testing.B) { + for _, width := range []int{1, 4, 16, 64} { + b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) { + sm := NewSessionManager(zap.NewNop()) + const userID = 1 + for i := 0; i < width; i++ { + sm.Register(benchConn(int64(i)+1, authKeyIDFromInt(uint64(i)+1), userID)) + } + msg := &tg.UpdatesTooLong{} + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) + } + }) + } +} + +// TestSessionManagerFanoutThroughput 是数据驱动吞吐测:注册 N 连接后,P 个 goroutine 持续并发 +// push,测全局锁下的实际 push 吞吐与 p99。默认小规模冒烟;设 TELESRV_LOAD_CONNS 放大到 20 万。 +func TestSessionManagerFanoutThroughput(t *testing.T) { + conns := envIntDefault("TELESRV_LOAD_CONNS", 20_000) + connsPerUser := envIntDefault("TELESRV_LOAD_CONNS_PER_USER", 2) + workers := envIntDefault("TELESRV_LOAD_PUSH_WORKERS", 0) // 0 → GOMAXPROCS + duration := time.Duration(envIntDefault("TELESRV_LOAD_SECONDS", 3)) * time.Second + if workers <= 0 { + workers = runtime.GOMAXPROCS(0) + } + + sm := NewSessionManager(zap.NewNop()) + t0 := time.Now() + userCount := seedSessions(sm, conns, connsPerUser) + seedWall := time.Since(t0) + if got := sm.Online(); got != conns { + t.Fatalf("online = %d, want %d", got, conns) + } + + msg := &tg.UpdatesTooLong{} + ctx := context.Background() + var ops atomic.Int64 + perWorkerLat := make([][]time.Duration, workers) + + deadline := time.Now().Add(duration) + var wg sync.WaitGroup + start := time.Now() + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + lat := make([]time.Duration, 0, 1<<16) + var n uint64 + for time.Now().Before(deadline) { + // 批量 256 次再查一次时钟,降低 time.Now 占比。 + for j := 0; j < 256; j++ { + n++ + userID := int64(n%uint64(userCount)) + 1 + s := time.Now() + _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) + lat = append(lat, time.Since(s)) + } + ops.Add(256) + } + perWorkerLat[w] = lat + }(w) + } + wg.Wait() + wall := time.Since(start) + + all := make([]time.Duration, 0, ops.Load()) + for _, l := range perWorkerLat { + all = append(all, l...) + } + sortDurations(all) + total := ops.Load() + thr := float64(total) / wall.Seconds() + + t.Logf("==== session_manager fan-out throughput ====") + t.Logf("config: conns=%d connsPerUser=%d users=%d pushWorkers=%d dur=%s seed=%s", + conns, connsPerUser, userCount, workers, duration, seedWall.Round(time.Millisecond)) + t.Logf("push: %d ops in %s -> %.0f push/s", total, wall.Round(time.Millisecond), thr) + t.Logf("push.lat p50=%s p90=%s p99=%s max=%s", + pct(all, 50), pct(all, 90), pct(all, 99), pct(all, 100)) + t.Logf("=============================================") +} + +func pct(sorted []time.Duration, p int) time.Duration { + if len(sorted) == 0 { + return 0 + } + idx := (p*len(sorted))/100 - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +func sortDurations(d []time.Duration) { + sort.Slice(d, func(i, j int) bool { return d[i] < d[j] }) +} + +func envIntDefault(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/internal/mtprotoedge/session_manager_test.go b/internal/mtprotoedge/session_manager_test.go new file mode 100644 index 00000000..14070e97 --- /dev/null +++ b/internal/mtprotoedge/session_manager_test.go @@ -0,0 +1,296 @@ +package mtprotoedge + +import ( + "context" + "errors" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// TestSessionManagerRegistry 验证注册表的注册/注销/查找语义(不涉及网络发送)。 +func TestSessionManagerRegistry(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + c := &Conn{sessionID: 42, authKeyID: [8]byte{1, 2, 3}} + c.receivesUpdates.Store(true) + + sm.Register(c) + if got := sm.Online(); got != 1 { + t.Fatalf("online = %d, want 1", got) + } + sm.BindAuthKey(42, [8]byte{1, 2, 3}) + sm.BindUser(42, 100) + if userID, ok := sm.UserID(42); !ok || userID != 100 { + t.Fatalf("cached user = %d ok %v, want 100/true", userID, ok) + } + sm.BindAuthKey(42, [8]byte{9}) + if userID, ok := sm.UserID(42); ok || userID != 0 { + t.Fatalf("cached user after auth key switch = %d ok %v, want 0/false", userID, ok) + } + if userID, resolved := sm.UserIDResolved(42); resolved || userID != 0 { + t.Fatalf("resolved user after auth key switch = %d resolved %v, want unresolved", userID, resolved) + } + sm.BindUser(42, 0) + if userID, resolved := sm.UserIDResolved(42); !resolved || userID != 0 { + t.Fatalf("negative user cache = %d resolved %v, want 0/true", userID, resolved) + } + + sm.Unregister(c) + if got := sm.Online(); got != 0 { + t.Fatalf("online after unregister = %d, want 0", got) + } + + err := sm.PushToSession(context.Background(), 42, proto.MessageFromServer, &tg.UpdatesTooLong{}) + if !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("push to missing session err = %v, want ErrSessionNotFound", err) + } +} + +func TestSessionManagerScopesSameSessionIDByAuthKey(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + raw1 := [8]byte{1} + raw2 := [8]byte{2} + perm1 := [8]byte{9} + c1 := &Conn{sessionID: 42, authKeyID: raw1} + c2 := &Conn{sessionID: 42, authKeyID: raw2} + + sm.Register(c1) + sm.Register(c2) + if got := sm.Online(); got != 2 { + t.Fatalf("online = %d, want 2", got) + } + + sm.BindAuthKeyForSession(raw1, 42, perm1) + sm.BindUserForAuthKey(raw1, 42, 100) + sm.BindUserForAuthKey(raw2, 42, 200) + + if userID, ok := sm.UserIDForAuthKey(raw1, 42); !ok || userID != 100 { + t.Fatalf("scoped user raw1 = %d ok %v, want 100/true", userID, ok) + } + if userID, ok := sm.UserIDForAuthKey(raw2, 42); !ok || userID != 200 { + t.Fatalf("scoped user raw2 = %d ok %v, want 200/true", userID, ok) + } + if _, ok := sm.UserID(42); ok { + t.Fatal("legacy UserID unexpectedly resolved ambiguous session_id") + } + if err := sm.PushToSession(context.Background(), 42, proto.MessageFromServer, &tg.UpdatesTooLong{}); !errors.Is(err, ErrSessionAmbiguous) { + t.Fatalf("ambiguous push err = %v, want ErrSessionAmbiguous", err) + } + + sm.BindUserForAuthKey(raw1, 42, 300) + sm.BindUserForAuthKey(raw2, 42, 300) + sent, err := sm.PushToUserExceptAuthKeySession(context.Background(), 300, perm1, 42, proto.MessageFromServer, &tg.UpdatesTooLong{}) + if err != nil { + t.Fatalf("push except scoped session: %v", err) + } + if sent != 1 { + t.Fatalf("pushed to %d sessions, want 1", sent) + } + if _, ok := sm.pending[sessionKey{authKeyID: raw1, sessionID: 42}]; ok { + t.Fatal("excluded session received pending push") + } + if got := len(sm.pending[sessionKey{authKeyID: raw2, sessionID: 42}]); got != 1 { + t.Fatalf("raw2 pending pushes = %d, want 1", got) + } +} + +func TestSessionManagerChannelInterestIndex(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + raw := [8]byte{1, 2, 3} + c := &Conn{sessionID: 42, authKeyID: raw} + sm.Register(c) + sm.BindUserForAuthKey(raw, 42, 100) + + sm.TrackChannelInterest(raw, 42, 100, []int64{10, 10, 20}) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel 10 online users = %v, want [100]", got) + } + sm.TrackChannelInterest(raw, 42, 100, []int64{20}) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel 10 after viewer switch = %v, want empty", got) + } + if got := sm.OnlineChannelUserIDs(20, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel 20 after viewer switch = %v, want [100]", got) + } + sm.TrackChannelInterest(raw, 42, 100, []int64{10}) + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel 10 online members before membership sync = %v, want empty", got) + } + sm.SetSessionChannelMemberships(raw, 42, 100, []int64{10, 30}) + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel 10 online members = %v, want [100]", got) + } + if got := sm.OnlineChannelUserIDs(30, 10); len(got) != 0 { + t.Fatalf("channel 30 viewers = %v, want empty", got) + } + if got := sm.OnlineUserIDsForCandidates([]int64{0, 200, 100, 100}, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("candidate online users = %v, want [100]", got) + } + + sm.BindUserForAuthKey(raw, 42, 200) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel interest after user switch = %v, want empty", got) + } + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel membership after user switch = %v, want empty", got) + } + sm.TrackChannelInterest(raw, 42, 200, []int64{10}) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 200 { + t.Fatalf("channel 10 after re-track = %v, want [200]", got) + } + sm.AddUserChannelMembership(200, 10) + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 200 { + t.Fatalf("channel 10 membership after add = %v, want [200]", got) + } + sm.RemoveUserChannelMembership(200, 10) + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel membership after remove = %v, want empty", got) + } + sm.ClearChannelInterest(raw, 42, 200) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel interest after explicit clear = %v, want empty", got) + } + + sm.Unregister(c) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel interest after unregister = %v, want empty", got) + } + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel membership after unregister = %v, want empty", got) + } +} + +func TestSessionManagerClearsChannelIndexesOnAuthAndReadinessChanges(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + raw := [8]byte{1, 2, 3} + business := [8]byte{8} + c := &Conn{sessionID: 42, authKeyID: raw} + sm.Register(c) + sm.BindAuthKeyForSession(raw, 42, business) + sm.BindUserForAuthKey(raw, 42, 100) + + track := func() { + sm.TrackChannelInterest(raw, 42, 100, []int64{10}) + sm.SetSessionChannelMemberships(raw, 42, 100, []int64{10}) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel viewers before cleanup = %v, want [100]", got) + } + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel members before cleanup = %v, want [100]", got) + } + } + assertCleared := func(label string) { + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("%s viewers = %v, want empty", label, got) + } + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("%s members = %v, want empty", label, got) + } + } + + track() + sm.SetReceivesUpdatesForAuthKey(raw, 42, false) + assertCleared("after receivesUpdates=false") + + track() + sm.BindAuthKeyForSession(raw, 42, [8]byte{9}) + assertCleared("after business auth key change") + + sm.BindAuthKeyForSession(raw, 42, business) + sm.BindUserForAuthKey(raw, 42, 100) + track() + if n := sm.UnbindAuthKey(business); n != 1 { + t.Fatalf("UnbindAuthKey count = %d, want 1", n) + } + assertCleared("after unbind auth key") +} + +// TestSessionManagerPush 验证主动推送端到端:两个 client 连接握手并建立 session 后, +// server 经 PushToSession / PushToUser 主动向其推送,client 收到。 +func TestSessionManagerPush(t *testing.T) { + const dc = 2 + addr, pub, srv := startTestServer(t, Options{DC: dc}) + + conn1, auth1, cipher1 := dialHandshake(t, addr, dc, pub) + conn2, auth2, cipher2 := dialHandshake(t, addr, dc, pub) + + // 各发一个 ping 建立 session,触发注册(并清掉 new_session_created/pong/ack)。 + msgGen := proto.NewMessageIDGen(time.Now) + sendEncrypted(t, conn1, cipher1, auth1, msgGen.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1}) + collectReplies(t, conn1, cipher1, auth1.AuthKey, mt.PongTypeID) + sendEncrypted(t, conn2, cipher2, auth2, msgGen.New(proto.MessageFromClient), &mt.PingRequest{PingID: 2}) + collectReplies(t, conn2, cipher2, auth2.AuthKey, mt.PongTypeID) + + if got := srv.Conns().Online(); got != 2 { + t.Fatalf("online = %d, want 2", got) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 1) PushToSession:session2 尚未进入 updates 同步入口时先暂存,ready 后下发。 + if err := srv.Conns().PushToSession(ctx, auth2.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil { + t.Fatalf("push to session: %v", err) + } + srv.Conns().SetReceivesUpdates(auth2.SessionID, true) + r2 := collectReplies(t, conn2, cipher2, auth2.AuthKey, tg.UpdatesTooLongTypeID) + mustHave(t, r2, tg.UpdatesTooLongTypeID, "pushed updates on conn2") + + // 2) BindUser + PushToUser:按 user 维度推送给 conn1。 + srv.Conns().BindUser(auth1.SessionID, 100) + srv.Conns().SetReceivesUpdates(auth1.SessionID, true) + sent, err := srv.Conns().PushToUser(ctx, 100, proto.MessageFromServer, &tg.UpdatesTooLong{}) + if err != nil { + t.Fatalf("push to user: %v", err) + } + if sent != 1 { + t.Fatalf("pushed to %d conns, want 1", sent) + } + r1 := collectReplies(t, conn1, cipher1, auth1.AuthKey, tg.UpdatesTooLongTypeID) + mustHave(t, r1, tg.UpdatesTooLongTypeID, "pushed updates on conn1") + + // 3) PushToUserExceptSession:模拟 SyncUpdatesNotMe,跳过当前 session。 + srv.Conns().BindUser(auth1.SessionID, 200) + srv.Conns().BindUser(auth2.SessionID, 200) + sent, err = srv.Conns().PushToUserExceptSession(ctx, 200, auth2.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}) + if err != nil { + t.Fatalf("push to user except session: %v", err) + } + if sent != 1 { + t.Fatalf("pushed to %d conns, want 1 after excluding current session", sent) + } + r1 = collectReplies(t, conn1, cipher1, auth1.AuthKey, tg.UpdatesTooLongTypeID) + mustHave(t, r1, tg.UpdatesTooLongTypeID, "pushed not-me updates on conn1") +} + +func BenchmarkSessionManagerOnlineCandidateFilter(b *testing.B) { + sm := NewSessionManager(zaptest.NewLogger(b)) + const online = 200_000 + rawPrefix := [8]byte{9} + for i := 1; i <= online; i++ { + raw := rawPrefix + raw[1] = byte(i) + raw[2] = byte(i >> 8) + raw[3] = byte(i >> 16) + raw[4] = byte(i >> 24) + c := &Conn{sessionID: int64(i), authKeyID: raw} + sm.Register(c) + sm.BindUserForAuthKey(raw, int64(i), int64(i)) + } + candidates := make([]int64, 0, 500) + for i := 0; i < 500; i++ { + candidates = append(candidates, int64(i*97+1)) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + got := sm.OnlineUserIDsForCandidates(candidates, 500) + if len(got) == 0 { + b.Fatal("no candidates matched") + } + } +} diff --git a/internal/rpc/account.go b/internal/rpc/account.go new file mode 100644 index 00000000..3b2937b2 --- /dev/null +++ b/internal/rpc/account.go @@ -0,0 +1,292 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// registerAccount 注册 account.* RPC handler。 +func (r *Router) registerAccount(d *tg.ServerDispatcher) { + d.OnAccountCheckUsername(r.onAccountCheckUsername) + d.OnAccountUpdateProfile(r.onAccountUpdateProfile) + d.OnAccountUpdateUsername(r.onAccountUpdateUsername) + d.OnAccountGetPassword(func(ctx context.Context) (*tg.AccountPassword, error) { + if r.deps.Account == nil { + return tgPassword(domain.PasswordSettings{SecureRandom: []byte("telesrv-tdesktop-dev-secure-rand")}), nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + settings, err := r.deps.Account.GetPassword(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgPassword(settings), nil + }) + d.OnAccountGetNotifySettings(func(ctx context.Context, peer tg.InputNotifyPeerClass) (*tg.PeerNotifySettings, error) { + return tdesktop.NotifySettings(), nil + }) + d.OnAccountUpdateNotifySettings(func(ctx context.Context, req *tg.AccountUpdateNotifySettingsRequest) (bool, error) { + return true, nil + }) + d.OnAccountGetPrivacy(func(ctx context.Context, key tg.InputPrivacyKeyClass) (*tg.AccountPrivacyRules, error) { + return tdesktop.PrivacyRules(key), nil + }) + d.OnAccountGetAuthorizations(func(ctx context.Context) (*tg.AccountAuthorizations, error) { + return tdesktop.Authorizations(), nil + }) + d.OnAccountGetDefaultEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { + return tdesktop.DefaultEmojiStatuses(), nil + }) + d.OnAccountGetCollectibleEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { + return tdesktop.CollectibleEmojiStatuses(), nil + }) + d.OnAccountGetDefaultGroupPhotoEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) { + return tdesktop.DefaultGroupPhotoEmojis(), nil + }) + d.OnAccountGetConnectedBots(func(ctx context.Context) (*tg.AccountConnectedBots, error) { + return tdesktop.ConnectedBots(), nil + }) + d.OnAccountGetReactionsNotifySettings(r.onAccountGetReactionsNotifySettings) + d.OnAccountSetReactionsNotifySettings(r.onAccountSetReactionsNotifySettings) + d.OnAccountGetContactSignUpNotification(func(ctx context.Context) (bool, error) { + return false, nil + }) + d.OnAccountGetThemes(func(ctx context.Context, req *tg.AccountGetThemesRequest) (tg.AccountThemesClass, error) { + return tdesktop.AccountThemes(), nil + }) + d.OnAccountGetContentSettings(func(ctx context.Context) (*tg.AccountContentSettings, error) { + return tdesktop.ContentSettings(), nil + }) + d.OnAccountGetGlobalPrivacySettings(func(ctx context.Context) (*tg.GlobalPrivacySettings, error) { + return tdesktop.GlobalPrivacySettings(), nil + }) + d.OnAccountGetPasskeys(func(ctx context.Context) (*tg.AccountPasskeys, error) { + return tdesktop.Passkeys(), nil + }) + d.OnAccountGetSavedMusicIDs(func(ctx context.Context, hash int64) (tg.AccountSavedMusicIDsClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.AccountSavedMusicIDs{IDs: []int64{}}, nil + }) + d.OnAccountGetAccountTTL(r.onAccountGetAccountTTL) + d.OnAccountUpdateStatus(r.onAccountUpdateStatus) +} + +func (r *Router) onAccountGetAccountTTL(ctx context.Context) (*tg.AccountDaysTTL, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.AccountDaysTTL{Days: 365}, nil +} + +func (r *Router) onAccountUpdateStatus(ctx context.Context, offline bool) (bool, error) { + userID, authorized, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if !authorized || userID == 0 { + return true, nil + } + status := r.setPresenceFromContext(ctx, userID, offline) + r.pushUserStatus(ctx, userID, status) + return true, nil +} + +type accountReactionSettingsService interface { + GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) + SetReactionsNotifySettings(ctx context.Context, userID int64, settings domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error) +} + +func (r *Router) onAccountGetReactionsNotifySettings(ctx context.Context) (*tg.ReactionsNotifySettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if svc, ok := r.deps.Account.(accountReactionSettingsService); ok { + settings, err := svc.GetReactionSettings(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgReactionsNotifySettings(settings.Notify), nil + } + return tgReactionsNotifySettings(domain.DefaultAccountReactionSettings().Notify), nil +} + +func (r *Router) onAccountSetReactionsNotifySettings(ctx context.Context, settings tg.ReactionsNotifySettings) (*tg.ReactionsNotifySettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + notify := domainReactionsNotifySettings(settings) + if svc, ok := r.deps.Account.(accountReactionSettingsService); ok { + next, err := svc.SetReactionsNotifySettings(ctx, userID, notify) + if err != nil { + return nil, internalErr() + } + return tgReactionsNotifySettings(next.Notify), nil + } + return tgReactionsNotifySettings(notify), nil +} + +func domainReactionsNotifySettings(settings tg.ReactionsNotifySettings) domain.ReactionsNotifySettings { + return domain.ReactionsNotifySettings{ + MessagesFrom: domainReactionNotifyFrom(settings.GetMessagesNotifyFrom), + StoriesFrom: domainReactionNotifyFrom(settings.GetStoriesNotifyFrom), + PollVotesFrom: domainReactionNotifyFrom(settings.GetPollVotesNotifyFrom), + ShowPreviews: settings.ShowPreviews, + } +} + +func domainReactionNotifyFrom(get func() (tg.ReactionNotificationsFromClass, bool)) domain.ReactionNotifyFrom { + if get == nil { + return domain.ReactionNotifyFromNone + } + value, ok := get() + if !ok || value == nil { + return domain.ReactionNotifyFromNone + } + switch value.(type) { + case *tg.ReactionNotificationsFromAll: + return domain.ReactionNotifyFromAll + case *tg.ReactionNotificationsFromContacts: + return domain.ReactionNotifyFromContacts + default: + return domain.ReactionNotifyFromNone + } +} + +func tgReactionsNotifySettings(settings domain.ReactionsNotifySettings) *tg.ReactionsNotifySettings { + out := &tg.ReactionsNotifySettings{ + Sound: &tg.NotificationSoundDefault{}, + ShowPreviews: settings.ShowPreviews, + } + if value := tgReactionNotifyFrom(settings.MessagesFrom); value != nil { + out.SetMessagesNotifyFrom(value) + } + if value := tgReactionNotifyFrom(settings.StoriesFrom); value != nil { + out.SetStoriesNotifyFrom(value) + } + if value := tgReactionNotifyFrom(settings.PollVotesFrom); value != nil { + out.SetPollVotesNotifyFrom(value) + } + return out +} + +func tgReactionNotifyFrom(value domain.ReactionNotifyFrom) tg.ReactionNotificationsFromClass { + switch value { + case domain.ReactionNotifyFromAll: + return &tg.ReactionNotificationsFromAll{} + case domain.ReactionNotifyFromContacts: + return &tg.ReactionNotificationsFromContacts{} + default: + return nil + } +} + +func (r *Router) onAccountUpdateProfile(ctx context.Context, req *tg.AccountUpdateProfileRequest) (tg.UserClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return nil, internalErr() + } + firstName, hasFirstName := req.GetFirstName() + lastName, hasLastName := req.GetLastName() + about, hasAbout := req.GetAbout() + u, err := svc.UpdateProfile(ctx, userID, domain.UserProfileUpdate{ + FirstName: firstName, + HasFirstName: hasFirstName, + LastName: lastName, + HasLastName: hasLastName, + About: about, + HasAbout: hasAbout, + }) + if err != nil { + return nil, profileErr(err) + } + r.pushUsernameUpdate(ctx, u) + return r.tgSelfUser(u), nil +} + +func (r *Router) onAccountCheckUsername(ctx context.Context, username string) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return false, internalErr() + } + okUsername, err := svc.CheckUsername(ctx, userID, username) + if err != nil { + return false, usernameErr(err) + } + return okUsername, nil +} + +func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) (tg.UserClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return nil, internalErr() + } + u, err := svc.UpdateUsername(ctx, userID, username) + if err != nil { + return nil, usernameErr(err) + } + r.pushUsernameUpdate(ctx, u) + return r.tgSelfUser(u), nil +} + +func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) { + if u.ID == 0 { + return + } + r.pushUserUpdates(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUserName{ + UserID: u.ID, + FirstName: u.FirstName, + LastName: u.LastName, + Usernames: tgUsernames(u.Username), + }}, + Users: []tg.UserClass{r.tgSelfUser(u)}, + Date: int(r.clock.Now().Unix()), + }) +} + +func usernameErr(err error) error { + switch { + case errors.Is(err, domain.ErrUsernameInvalid): + return usernameInvalidErr() + case errors.Is(err, domain.ErrUsernameOccupied): + return usernameOccupiedErr() + case errors.Is(err, domain.ErrUsernameNotOccupied): + return usernameNotOccupiedErr() + default: + return internalErr() + } +} + +func profileErr(err error) error { + switch { + case errors.Is(err, domain.ErrFirstNameInvalid): + return firstNameInvalidErr() + case errors.Is(err, domain.ErrAboutTooLong): + return aboutTooLongErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/aicompose.go b/internal/rpc/aicompose.go new file mode 100644 index 00000000..0fbf96fd --- /dev/null +++ b/internal/rpc/aicompose.go @@ -0,0 +1,16 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" +) + +// registerAiCompose 注册第一阶段 TDesktop 启动所需 aicompose.* RPC 兼容响应。 +func (r *Router) registerAiCompose(d *tg.ServerDispatcher) { + d.OnAicomposeGetTones(func(ctx context.Context, hash int64) (tg.AicomposeTonesClass, error) { + return tdesktop.AiComposeTones(), nil + }) +} diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go new file mode 100644 index 00000000..d69adb8e --- /dev/null +++ b/internal/rpc/auth.go @@ -0,0 +1,347 @@ +package rpc + +import ( + "context" + "encoding/binary" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// devCodeLength 是开发固定验证码长度,写入 auth.sentCode 的 type.length。 +const devCodeLength = 5 + +const loginMessagePushDelay = 2 * time.Second + +// registerAuth 注册 auth.* RPC handler。 +func (r *Router) registerAuth(d *tg.ServerDispatcher) { + d.OnAuthBindTempAuthKey(r.onAuthBindTempAuthKey) + d.OnAuthExportLoginToken(r.onAuthExportLoginToken) + d.OnAuthSendCode(r.onAuthSendCode) + d.OnAuthSignIn(r.onAuthSignIn) + d.OnAuthSignUp(r.onAuthSignUp) + d.OnAuthLogOut(r.onAuthLogOut) +} + +// onAuthBindTempAuthKey 记录 TDesktop 的 PFS temp→perm auth key 绑定。 +func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTempAuthKeyRequest) (bool, error) { + if r.deps.Auth == nil { + return true, nil + } + id, _ := RawAuthKeyIDFrom(ctx) + if id == ([8]byte{}) { + id, _ = AuthKeyIDFrom(ctx) + } + sessionID, _ := SessionIDFrom(ctx) + if err := r.deps.Auth.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{ + TempAuthKeyID: id, + PermAuthKeyID: req.PermAuthKeyID, + Nonce: req.Nonce, + ExpiresAt: req.ExpiresAt, + EncryptedMessage: append([]byte(nil), req.EncryptedMessage...), + }); err != nil { + return false, bindTempAuthKeyErr(err) + } + if r.deps.Sessions != nil { + if scoped, ok := r.scopedSessions(); ok { + rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx) + scoped.BindAuthKeyForSession(rawAuthKeyID, sessionID, authKeyIDFromInt64(req.PermAuthKeyID)) + } else { + r.deps.Sessions.BindAuthKey(sessionID, authKeyIDFromInt64(req.PermAuthKeyID)) + } + } + r.invalidateAuthUserCache(id) + return true, nil +} + +// onAuthExportLoginToken 给 TDesktop QR 登录页返回一个短期占位 token。 +func (r *Router) onAuthExportLoginToken(ctx context.Context, _ *tg.AuthExportLoginTokenRequest) (tg.AuthLoginTokenClass, error) { + id, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil +} + +// onAuthSendCode 处理 auth.sendCode:生成 phone_code_hash 并返回 sentCode。 +func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) { + hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber) + if err != nil { + return nil, internalErr() + } + return &tg.AuthSentCode{ + Type: &tg.AuthSentCodeTypeApp{Length: devCodeLength}, + PhoneCodeHash: hash, + }, nil +} + +// onAuthSignIn 处理 auth.signIn:校验验证码;用户不存在时返回 SignUpRequired。 +func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (tg.AuthAuthorizationClass, error) { + u, loginMessage, needSignUp, err := r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode) + if err != nil { + return nil, signInErr(err) + } + if needSignUp { + return &tg.AuthAuthorizationSignUpRequired{}, nil + } + if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil { + return nil, internalErr() + } + if id, ok := AuthKeyIDFrom(ctx); ok { + r.setAuthUserCache(id, u.ID, true) + } + r.bindSessionUser(ctx, u.ID) + r.recordAndScheduleLoginMessagePush(ctx, loginMessage) + r.pushSignInServiceNotificationToOthers(ctx, u) + return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil +} + +// onAuthSignUp 处理 auth.signUp:创建用户并绑定授权。 +func (r *Router) onAuthSignUp(ctx context.Context, req *tg.AuthSignUpRequest) (tg.AuthAuthorizationClass, error) { + u, loginMessage, err := r.deps.Auth.SignUp(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.FirstName, req.LastName) + if err != nil { + return nil, signInErr(err) + } + if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil { + return nil, internalErr() + } + if id, ok := AuthKeyIDFrom(ctx); ok { + r.setAuthUserCache(id, u.ID, true) + } + r.bindSessionUser(ctx, u.ID) + r.recordAndScheduleLoginMessagePush(ctx, loginMessage) + return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil +} + +// onAuthLogOut 处理 auth.logOut:解绑当前 auth_key 的授权。 +func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, authorized, userErr := r.currentUserID(ctx) + if err := r.deps.Auth.LogOut(ctx, id); err != nil { + return nil, internalErr() + } + r.invalidateAuthUserCache(id) + r.unbindAuthKey(id) + if userErr == nil && authorized && userID != 0 { + status := r.setPresenceFromContext(ctx, userID, true) + r.pushUserStatus(ctx, userID, status) + } + if err := r.clearAuthKeyState(ctx, id); err != nil { + return nil, internalErr() + } + return &tg.AuthLoggedOut{}, nil +} + +func (r *Router) clearAuthKeyStateOnUserChange(ctx context.Context, newUserID int64) error { + oldUserID, ok := UserIDFrom(ctx) + if !ok || oldUserID == 0 || oldUserID == newUserID { + return nil + } + id, ok := AuthKeyIDFrom(ctx) + if !ok { + return nil + } + return r.clearAuthKeyState(ctx, id) +} + +func (r *Router) clearAuthKeyState(ctx context.Context, authKeyID [8]byte) error { + if r.deps.Updates == nil { + return nil + } + return r.deps.Updates.ClearAuthKey(ctx, authKeyID) +} + +func (r *Router) bindSessionUser(ctx context.Context, userID int64) { + if r.deps.Sessions == nil { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + if scoped, ok := r.scopedSessions(); ok { + rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx) + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID) + r.announceSessionOnline(ctx, userID) + return + } + r.deps.Sessions.BindUser(sessionID, userID) + r.announceSessionOnline(ctx, userID) +} + +func (r *Router) unbindAuthKey(authKeyID [8]byte) { + if r.deps.Sessions == nil { + return + } + r.deps.Sessions.UnbindAuthKey(authKeyID) +} + +func (r *Router) pushSignInServiceNotificationToOthers(ctx context.Context, u domain.User) { + if r.deps.Sessions == nil || u.ID == 0 { + return + } + authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx) + sessionID, hasSessionID := SessionIDFrom(ctx) + if !hasAuthKeyID || !hasSessionID { + return + } + notification := r.tgSignInServiceNotification(ctx, u, authKeyID) + go func() { + pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if scoped, ok := r.scopedSessions(); ok { + if sent, err := scoped.PushToUserExceptAuthKeySession(pushCtx, u.ID, authKeyID, sessionID, proto.MessageFromServer, notification); err != nil { + r.log.Debug("push sign-in service notification", zap.Int64("user_id", u.ID), zap.Int("sent", sent), zap.Error(err)) + } + return + } + if sent, err := r.deps.Sessions.PushToUserExceptSession(pushCtx, u.ID, sessionID, proto.MessageFromServer, notification); err != nil { + r.log.Debug("push sign-in service notification", zap.Int64("user_id", u.ID), zap.Int("sent", sent), zap.Error(err)) + } + }() +} + +func (r *Router) recordAndScheduleLoginMessagePush(ctx context.Context, msg domain.Message) { + authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx) + sessionID, hasSessionID := SessionIDFrom(ctx) + if !hasAuthKeyID || !hasSessionID || msg.ID == 0 { + return + } + event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, Date: msg.Date, Message: msg} + state := domain.UpdateState{Pts: 1, Date: msg.Date, Seq: 0} + if r.deps.Updates != nil { + recorded, st, err := r.deps.Updates.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg) + if err != nil { + r.log.Warn("record login message update", zap.Error(err)) + return + } + event = recorded + state = st + } + if r.deps.Sessions == nil { + return + } + // 提前从请求 ctx 取出 rawAuthKeyID(值类型),闭包只捕获该值、不捕获请求 ctx—— + // 避免延迟推送的 AfterFunc 在 loginMessagePushDelay 期间延长请求 ctx 链路的存活。 + rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx) + time.AfterFunc(loginMessagePushDelay, func() { + pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + r.pushLoginMessage(pushCtx, rawAuthKeyID, sessionID, event, state) + }) +} + +func (r *Router) pushLoginMessage(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, event domain.UpdateEvent, state domain.UpdateState) { + if r.deps.Sessions == nil || event.Message.ID == 0 { + return + } + updates := tgLoginMessageUpdates(event, state) + if updates == nil { + return + } + var err error + if scoped, ok := r.scopedSessions(); ok && rawAuthKeyID != ([8]byte{}) { + err = scoped.PushToSessionForAuthKey(ctx, rawAuthKeyID, sessionID, proto.MessageFromServer, updates) + } else { + err = r.deps.Sessions.PushToSession(ctx, sessionID, proto.MessageFromServer, updates) + } + if err != nil { + r.log.Debug("push login message", zap.Int64("session_id", sessionID), zap.Error(err)) + return + } + r.log.Debug("pushed login message", + zap.Int64("session_id", sessionID), + zap.Int("message_id", event.Message.ID), + zap.Int("pts", event.Pts), + zap.Int("seq", state.Seq), + ) +} + +func tgLoginMessageUpdates(event domain.UpdateEvent, state domain.UpdateState) *tg.Updates { + item := tgMessage(event.Message) + if item == nil { + return nil + } + if state.Date == 0 { + state.Date = event.Date + } + return &tg.Updates{ + Updates: []tg.UpdateClass{ + &tg.UpdateNewMessage{ + Message: item, + Pts: event.Pts, + PtsCount: event.PtsCount, + }, + }, + Users: []tg.UserClass{tgUser(domain.OfficialSystemUser())}, + Date: state.Date, + Seq: state.Seq, + } +} + +func (r *Router) tgSignInServiceNotification(ctx context.Context, u domain.User, authKeyID [8]byte) *tg.Updates { + now := r.clock.Now() + client := "Unknown device" + if ci, ok := ClientInfoFrom(ctx); ok { + parts := []string{} + if ci.DeviceModel != "" { + parts = append(parts, ci.DeviceModel) + } + if ci.SystemVersion != "" { + parts = append(parts, ci.SystemVersion) + } + if ci.AppVersion != "" { + parts = append(parts, ci.AppVersion) + } + if len(parts) > 0 { + client = strings.Join(parts, " / ") + } + } + name := strings.TrimSpace(strings.TrimSpace(u.FirstName + " " + u.LastName)) + if name == "" { + name = u.Phone + } + if name == "" { + name = "there" + } + message := fmt.Sprintf("New login.\nDear %s, we detected a login into your account from a new device on %s.\n\nDevice: %s\nLocation: Unknown\n\nIf this wasn't you, you can terminate that session in Settings > Devices (or Privacy & Security > Active Sessions).", + name, + now.UTC().Format(time.RFC1123), + client, + ) + authID := int64(binary.LittleEndian.Uint64(authKeyID[:])) + update := &tg.UpdateServiceNotification{ + InboxDate: int(now.Unix()), + Type: fmt.Sprintf("auth%d_%d", authID, now.Unix()), + Message: message, + Media: &tg.MessageMediaEmpty{}, + Entities: signInNotificationEntities(message), + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: int(now.Unix()), + } +} + +func signInNotificationEntities(message string) []tg.MessageEntityClass { + terms := []string{"New login.", "Settings > Devices", "Privacy & Security > Active Sessions"} + out := make([]tg.MessageEntityClass, 0, len(terms)) + for _, term := range terms { + if offset := strings.Index(message, term); offset >= 0 { + out = append(out, &tg.MessageEntityBold{Offset: offset, Length: len(term)}) + } + } + return out +} + +func authKeyIDFromInt64(v int64) [8]byte { + var id [8]byte + binary.LittleEndian.PutUint64(id[:], uint64(v)) + return id +} diff --git a/internal/rpc/channel_interest.go b/internal/rpc/channel_interest.go new file mode 100644 index 00000000..1ba2a88a --- /dev/null +++ b/internal/rpc/channel_interest.go @@ -0,0 +1,182 @@ +package rpc + +import ( + "context" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const channelMembershipSyncPageSize = domain.MaxSynchronousChannelDialogFanout + +func (r *Router) trackChannelInterest(ctx context.Context, userID int64, channelIDs ...int64) { + if userID == 0 || r.deps.Sessions == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + if len(channelIDs) == 0 { + provider.ClearChannelInterest(rawAuthKeyID, sessionID, userID) + return + } + provider.TrackChannelInterest(rawAuthKeyID, sessionID, userID, channelIDs) +} + +func (r *Router) clearChannelInterest(ctx context.Context, userID int64) { + r.trackChannelInterest(ctx, userID) +} + +func (r *Router) syncSessionChannelMemberships(ctx context.Context, userID int64) { + if userID == 0 || r.deps.Sessions == nil || r.deps.Channels == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + channelIDs := make([]int64, 0, channelMembershipSyncPageSize) + after := int64(0) + for { + page, err := r.deps.Channels.ActiveChannelIDsForUser(ctx, userID, after, channelMembershipSyncPageSize) + if err != nil { + r.log.Warn("sync session channel memberships failed", + zap.Int64("user_id", userID), + zap.Int64("after_channel_id", after), + zap.Error(err)) + return + } + if len(page) == 0 { + break + } + progressed := false + for _, channelID := range page { + if channelID == 0 { + continue + } + channelIDs = append(channelIDs, channelID) + if channelID > after { + after = channelID + progressed = true + } + } + if !progressed || len(page) < channelMembershipSyncPageSize { + break + } + } + provider.SetSessionChannelMemberships(rawAuthKeyID, sessionID, userID, channelIDs) +} + +func (r *Router) addOnlineChannelMemberships(channelID int64, userIDs ...int64) { + if channelID == 0 || len(userIDs) == 0 || r.deps.Sessions == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + provider.AddUserChannelMembership(userID, channelID) + } +} + +func (r *Router) removeOnlineChannelMemberships(channelID int64, userIDs ...int64) { + if channelID == 0 || len(userIDs) == 0 || r.deps.Sessions == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + provider.RemoveUserChannelMembership(userID, channelID) + } +} + +func (r *Router) removeOnlineChannelMembershipsForOnlineMembers(channelID int64) { + if channelID == 0 || r.deps.Sessions == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + r.removeOnlineChannelMemberships(channelID, provider.OnlineChannelMemberUserIDs(channelID, 0)...) +} + +func channelMemberUserIDs(members []domain.ChannelMember) []int64 { + if len(members) == 0 { + return nil + } + ids := make([]int64, 0, len(members)) + for _, member := range members { + if member.UserID == 0 || member.Status != domain.ChannelMemberActive { + continue + } + ids = append(ids, member.UserID) + } + return ids +} + +func channelIDsFromDialogs(list domain.DialogList) []int64 { + if len(list.Dialogs) == 0 && len(list.Channels) == 0 { + return nil + } + ids := make([]int64, 0, len(list.Dialogs)+len(list.Channels)) + seen := make(map[int64]struct{}, len(list.Dialogs)+len(list.Channels)) + for _, d := range list.Dialogs { + if d.Peer.Type != domain.PeerTypeChannel || d.Peer.ID == 0 { + continue + } + if _, ok := seen[d.Peer.ID]; ok { + continue + } + seen[d.Peer.ID] = struct{}{} + ids = append(ids, d.Peer.ID) + } + for _, ch := range list.Channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + ids = append(ids, ch.ID) + } + return ids +} diff --git a/internal/rpc/channels.go b/internal/rpc/channels.go new file mode 100644 index 00000000..5a56c7f4 --- /dev/null +++ b/internal/rpc/channels.go @@ -0,0 +1,4017 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const ( + maxChannelTitleLength = 128 + maxChannelAboutLength = 255 + maxChannelUsernameOrder = 32 + maxChannelReportMessageIDs = 100 + maxChannelSearchPostsLimit = 50 + maxChannelSearchPostsQuery = 256 + maxChannelPaidMessageStars = 10000 + maxChannelBoostsToUnblockRestrictions = 8 + maxChatAvailableReactions = 64 + maxChatInviteListLimit = 100 + maxChatInviteLinkLength = 256 + maxChatInviteSearchLength = 256 +) + +// registerChannels 注册超级群/频道相关 RPC。messages.createChat 在这里注册, +// 因为 telesrv 将普通群创建直接实现为 megagroup。 +func (r *Router) registerChannels(d *tg.ServerDispatcher) { + d.OnMessagesCreateChat(r.onMessagesCreateChat) + d.OnMessagesMigrateChat(r.onMessagesMigrateChat) + d.OnMessagesGetChats(r.onMessagesGetChats) + d.OnMessagesGetFullChat(r.onMessagesGetFullChat) + d.OnMessagesAddChatUser(r.onMessagesAddChatUser) + d.OnMessagesDeleteChatUser(r.onMessagesDeleteChatUser) + d.OnMessagesEditChatTitle(r.onMessagesEditChatTitle) + d.OnMessagesEditChatPhoto(r.onMessagesEditChatPhoto) + d.OnMessagesEditChatAdmin(r.onMessagesEditChatAdmin) + d.OnMessagesEditChatAbout(r.onMessagesEditChatAbout) + d.OnMessagesEditChatDefaultBannedRights(r.onMessagesEditChatDefaultBannedRights) + d.OnMessagesEditChatCreator(r.onMessagesEditChatCreator) + d.OnMessagesEditChatParticipantRank(r.onMessagesEditChatParticipantRank) + d.OnMessagesSetChatTheme(r.onMessagesSetChatTheme) + d.OnMessagesToggleNoForwards(r.onMessagesToggleNoForwards) + d.OnMessagesSetChatAvailableReactions(r.onMessagesSetChatAvailableReactions) + d.OnChannelsCreateChannel(r.onChannelsCreateChannel) + d.OnChannelsGetChannels(r.onChannelsGetChannels) + d.OnChannelsGetFullChannel(r.onChannelsGetFullChannel) + d.OnChannelsGetParticipants(r.onChannelsGetParticipants) + d.OnChannelsGetParticipant(r.onChannelsGetParticipant) + d.OnChannelsGetSendAs(r.onChannelsGetSendAs) + d.OnChannelsCheckUsername(r.onChannelsCheckUsername) + d.OnChannelsUpdateUsername(r.onChannelsUpdateUsername) + d.OnChannelsGetAdminedPublicChannels(r.onChannelsGetAdminedPublicChannels) + d.OnChannelsExportMessageLink(r.onChannelsExportMessageLink) + d.OnChannelsToggleSignatures(r.onChannelsToggleSignatures) + d.OnChannelsTogglePreHistoryHidden(r.onChannelsTogglePreHistoryHidden) + d.OnChannelsToggleSlowMode(r.onChannelsToggleSlowMode) + d.OnChannelsSetStickers(r.onChannelsSetStickers) + d.OnChannelsSetEmojiStickers(r.onChannelsSetEmojiStickers) + d.OnChannelsReorderUsernames(r.onChannelsReorderUsernames) + d.OnChannelsToggleUsername(r.onChannelsToggleUsername) + d.OnChannelsDeactivateAllUsernames(r.onChannelsDeactivateAllUsernames) + d.OnChannelsUpdateColor(r.onChannelsUpdateColor) + d.OnChannelsUpdateEmojiStatus(r.onChannelsUpdateEmojiStatus) + d.OnChannelsReadMessageContents(r.onChannelsReadMessageContents) + d.OnChannelsReportSpam(r.onChannelsReportSpam) + d.OnChannelsGetLeftChannels(r.onChannelsGetLeftChannels) + d.OnChannelsGetInactiveChannels(r.onChannelsGetInactiveChannels) + d.OnChannelsGetGroupsForDiscussion(r.onChannelsGetGroupsForDiscussion) + d.OnChannelsSetDiscussionGroup(r.onChannelsSetDiscussionGroup) + d.OnChannelsEditLocation(r.onChannelsEditLocation) + d.OnChannelsConvertToGigagroup(r.onChannelsConvertToGigagroup) + d.OnChannelsDeleteParticipantHistory(r.onChannelsDeleteParticipantHistory) + d.OnChannelsToggleJoinToSend(r.onChannelsToggleJoinToSend) + d.OnChannelsToggleJoinRequest(r.onChannelsToggleJoinRequest) + d.OnChannelsToggleForum(r.onChannelsToggleForum) + d.OnChannelsToggleAntiSpam(r.onChannelsToggleAntiSpam) + d.OnChannelsReportAntiSpamFalsePositive(r.onChannelsReportAntiSpamFalsePositive) + d.OnChannelsToggleParticipantsHidden(r.onChannelsToggleParticipantsHidden) + d.OnChannelsToggleViewForumAsMessages(r.onChannelsToggleViewForumAsMessages) + d.OnChannelsGetChannelRecommendations(r.onChannelsGetChannelRecommendations) + d.OnChannelsSetBoostsToUnblockRestrictions(r.onChannelsSetBoostsToUnblockRestrictions) + d.OnChannelsRestrictSponsoredMessages(r.onChannelsRestrictSponsoredMessages) + d.OnChannelsSearchPosts(r.onChannelsSearchPosts) + d.OnChannelsUpdatePaidMessagesPrice(r.onChannelsUpdatePaidMessagesPrice) + d.OnChannelsToggleAutotranslation(r.onChannelsToggleAutotranslation) + d.OnChannelsGetMessageAuthor(r.onChannelsGetMessageAuthor) + d.OnChannelsCheckSearchPostsFlood(r.onChannelsCheckSearchPostsFlood) + d.OnChannelsSetMainProfileTab(r.onChannelsSetMainProfileTab) + d.OnChannelsInviteToChannel(r.onChannelsInviteToChannel) + d.OnChannelsJoinChannel(r.onChannelsJoinChannel) + d.OnChannelsLeaveChannel(r.onChannelsLeaveChannel) + d.OnChannelsEditAdmin(r.onChannelsEditAdmin) + d.OnChannelsEditBanned(r.onChannelsEditBanned) + d.OnChannelsEditTitle(r.onChannelsEditTitle) + d.OnChannelsEditPhoto(r.onChannelsEditPhoto) + d.OnChannelsDeleteChannel(r.onChannelsDeleteChannel) + d.OnChannelsGetAdminLog(r.onChannelsGetAdminLog) + d.OnChannelsReadHistory(r.onChannelsReadHistory) + d.OnChannelsGetMessages(r.onChannelsGetMessages) + d.OnChannelsDeleteMessages(r.onChannelsDeleteMessages) + d.OnChannelsDeleteHistory(r.onChannelsDeleteHistory) + d.OnMessagesUpdatePinnedMessage(r.onMessagesUpdatePinnedMessage) + d.OnMessagesUnpinAllMessages(r.onMessagesUnpinAllMessages) + d.OnMessagesExportChatInvite(r.onMessagesExportChatInvite) + d.OnMessagesCheckChatInvite(r.onMessagesCheckChatInvite) + d.OnMessagesImportChatInvite(r.onMessagesImportChatInvite) + d.OnMessagesGetExportedChatInvites(r.onMessagesGetExportedChatInvites) + d.OnMessagesGetExportedChatInvite(r.onMessagesGetExportedChatInvite) + d.OnMessagesEditExportedChatInvite(r.onMessagesEditExportedChatInvite) + d.OnMessagesDeleteRevokedExportedChatInvites(r.onMessagesDeleteRevokedExportedChatInvites) + d.OnMessagesDeleteExportedChatInvite(r.onMessagesDeleteExportedChatInvite) + d.OnMessagesGetAdminsWithInvites(r.onMessagesGetAdminsWithInvites) + d.OnMessagesGetChatInviteImporters(r.onMessagesGetChatInviteImporters) + d.OnMessagesHideChatJoinRequest(r.onMessagesHideChatJoinRequest) + d.OnMessagesHideAllChatJoinRequests(r.onMessagesHideAllChatJoinRequests) + d.OnUpdatesGetChannelDifference(r.onUpdatesGetChannelDifference) +} + +func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreateChatRequest) (*tg.MessagesInvitedUsers, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if !validChannelTitle(req.Title) || len(req.Users) > domain.MaxChannelInviteUsers { + return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + memberIDs, err := r.userIDsFromInputUsers(ctx, userID, req.Users) + if err != nil { + return nil, err + } + memberIDs = createChatInviteMemberIDs(memberIDs, userID) + date := int(r.clock.Now().Unix()) + r.log.Debug("messages.createChat resolved users", + zap.Int("input_users", len(req.Users)), + zap.Int("member_ids", len(memberIDs)), + zap.Int64s("member_user_ids", memberIDs), + ) + if len(memberIDs) == 0 { + return nil, usersTooFewErr() + } + createRes, err := r.deps.Channels.CreateMegagroupFromCreateChat(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: req.Title, + TTLPeriod: req.TTLPeriod, + Date: date, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + r.addOnlineChannelMemberships(createRes.Channel.ID, channelMemberUserIDs(createRes.Members)...) + + responseRes := createRes + var inviteRes domain.CreateChannelResult + if len(memberIDs) > 0 { + inviteRes, err = r.deps.Channels.InviteToChannel(ctx, userID, createRes.Channel.ID, memberIDs, date) + if err != nil { + return nil, channelInviteErr(err) + } + r.addOnlineChannelMemberships(inviteRes.Channel.ID, channelMemberUserIDs(inviteRes.Members)...) + responseRes.Channel = inviteRes.Channel + responseRes.Members = mergeChannelMembers(createRes.Members, inviteRes.Members) + responseRes.Recipients = uniqueRecipientIDs(append(append([]int64{}, createRes.Recipients...), inviteRes.Recipients...)) + } + + updates := r.channelOperationUpdates(ctx, userID, responseRes) + if tdesktopCreateChatNeedsLegacyChat(ctx) { + updates = r.tdesktopCreateChatUpdates(ctx, userID, responseRes) + } + if inviteRes.Event.Pts != 0 { + inviteUpdates := r.channelOperationUpdates(ctx, userID, inviteRes) + if inviteUpdates != nil { + updates.Updates = append(updates.Updates, inviteUpdates.Updates...) + } + } + if inviteRes.Event.Pts != 0 { + r.pushChannelExplicitUpdates(ctx, userID, inviteRes.Channel.ID, memberIDs, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, inviteRes) + }) + } + return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil +} + +func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if chatID <= 0 { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + userID, view, err := r.channelChangeInfoView(ctx, &tg.InputChannel{ChannelID: chatID}) + if err != nil { + return nil, err + } + if !view.Channel.Megagroup { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + return r.channelStateUpdates(userID, view.Channel), nil +} + +func (r *Router) onMessagesGetChats(ctx context.Context, ids []int64) (tg.MessagesChatsClass, error) { + if len(ids) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + chats := make([]tg.ChatClass, 0, len(ids)) + if r.deps.Channels != nil { + for _, id := range ids { + if id <= 0 { + continue + } + view, err := r.deps.Channels.GetChannel(ctx, userID, id) + if err != nil { + if isChannelNotFound(err) { + continue + } + return nil, channelInvalidErr(err) + } + chats = append(chats, tgChannelChat(userID, view.Channel, &view.Self)) + } + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onMessagesGetFullChat(ctx context.Context, chatID int64) (*tg.MessagesChatFull, error) { + if chatID <= 0 { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + return r.onChannelsGetFullChannel(ctx, &tg.InputChannel{ChannelID: chatID}) +} + +func (r *Router) onMessagesAddChatUser(ctx context.Context, req *tg.MessagesAddChatUserRequest) (*tg.MessagesInvitedUsers, error) { + if req.ChatID <= 0 || req.UserID == nil { + return nil, peerIDInvalidErr() + } + return r.onChannelsInviteToChannel(ctx, &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + Users: []tg.InputUserClass{req.UserID}, + }) +} + +func (r *Router) onMessagesDeleteChatUser(ctx context.Context, req *tg.MessagesDeleteChatUserRequest) (tg.UpdatesClass, error) { + if req.ChatID <= 0 || req.UserID == nil { + return nil, peerIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found || target.ID == 0 { + return nil, peerIDInvalidErr() + } + if target.ID == userID { + return r.onChannelsLeaveChannel(ctx, &tg.InputChannel{ChannelID: req.ChatID}) + } + return r.onChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + Participant: &tg.InputPeerUser{UserID: target.ID, AccessHash: target.AccessHash}, + BannedRights: tg.ChatBannedRights{ + ViewMessages: true, + UntilDate: 0, + }, + }) +} + +func (r *Router) onMessagesEditChatTitle(ctx context.Context, req *tg.MessagesEditChatTitleRequest) (tg.UpdatesClass, error) { + if req.ChatID <= 0 { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + return r.onChannelsEditTitle(ctx, &tg.ChannelsEditTitleRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + Title: req.Title, + }) +} + +func (r *Router) onMessagesEditChatPhoto(ctx context.Context, req *tg.MessagesEditChatPhotoRequest) (tg.UpdatesClass, error) { + if req.ChatID <= 0 || req.Photo == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + return r.onChannelsEditPhoto(ctx, &tg.ChannelsEditPhotoRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + Photo: req.Photo, + }) +} + +func (r *Router) onMessagesEditChatAdmin(ctx context.Context, req *tg.MessagesEditChatAdminRequest) (bool, error) { + if req.ChatID <= 0 || req.UserID == nil { + return false, peerIDInvalidErr() + } + rights := tg.ChatAdminRights{} + if req.IsAdmin { + rights = legacyBasicGroupAdminRights() + } + _, err := r.onChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + UserID: req.UserID, + AdminRights: rights, + }) + if err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEditChatAboutRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + if utf8.RuneCountInString(req.About) > maxChannelAboutLength { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) + if err != nil { + return false, err + } + channel, err := r.deps.Channels.EditAbout(ctx, userID, domain.EditChannelAboutRequest{ + UserID: userID, + ChannelID: channelID, + About: req.About, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return false, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return true, nil +} + +func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req *tg.MessagesEditChatDefaultBannedRightsRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + channel, err := r.deps.Channels.EditDefaultBannedRights(ctx, userID, domain.EditChannelDefaultBannedRightsRequest{ + UserID: userID, + ChannelID: peer.ID, + BannedRights: domainChannelBannedRights(req.BannedRights), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(userID, channel) + r.pushChannelStateToMembers(ctx, userID, channel) + return updates, nil +} + +func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.MessagesEditChatCreatorRequest) (tg.UpdatesClass, error) { + if req.UserID == nil { + return nil, peerIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err != nil { + return nil, err + } + if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil { + return nil, internalErr() + } else if !found { + return nil, peerIDInvalidErr() + } + return nil, tgerr.New(400, "PASSWORD_HASH_INVALID") +} + +func (r *Router) onMessagesEditChatParticipantRank(ctx context.Context, req *tg.MessagesEditChatParticipantRankRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if len(req.Rank) > domain.MaxChannelAdminRankLength { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + participant, ok := r.domainPeerFromInputPeer(userID, req.Participant) + if !ok || participant.Type != domain.PeerTypeUser || participant.ID == 0 { + return nil, peerIDInvalidErr() + } + member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, participant.ID) + if err != nil { + return nil, channelAdminErr(err) + } + return r.onChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: channelID}, + UserID: &tg.InputUser{UserID: participant.ID}, + AdminRights: tgChatAdminRights(member.AdminRights), + Rank: req.Rank, + }) +} + +func (r *Router) onMessagesSetChatTheme(ctx context.Context, req *tg.MessagesSetChatThemeRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err == nil { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) + if err != nil { + return nil, channelInvalidErr(err) + } + return r.channelStateUpdates(userID, view.Channel), nil + } else if _, ok := channelIDFromLegacyInputPeer(userID, req.Peer); ok { + return nil, err + } + peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) + if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} + +func (r *Router) onMessagesToggleNoForwards(ctx context.Context, req *tg.MessagesToggleNoForwardsRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetNoForwards(ctx, userID, channelID, req.Enabled) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(userID, channel) + r.pushChannelStateToMembers(ctx, userID, channel) + return updates, nil +} + +func (r *Router) onMessagesSetChatAvailableReactions(ctx context.Context, req *tg.MessagesSetChatAvailableReactionsRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + policy, err := domainChannelReactionPolicy(req) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetAvailableReactions(ctx, userID, channelID, policy) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(userID, channel) + r.pushChannelStateToMembers(ctx, userID, channel) + return updates, nil +} + +func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCreateChannelRequest) (tg.UpdatesClass, error) { + if err := validateChannelsCreateChannelOptions(req); err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if !validChannelTitle(req.Title) || utf8.RuneCountInString(req.About) > maxChannelAboutLength { + return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + res, err := r.deps.Channels.CreateChannel(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: req.Title, + About: req.About, + Broadcast: req.Broadcast, + Megagroup: req.Megagroup, + Forum: req.Forum, + ForumTabs: req.Forum, + TTLPeriod: req.TTLPeriod, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + return updates, nil +} + +func validateChannelsCreateChannelOptions(req *tg.ChannelsCreateChannelRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.ForImport { + return chatInvalidErr() + } + if req.GeoPoint != nil || req.Address != "" { + return addressInvalidErr() + } + if req.TTLPeriod < 0 { + return ttlPeriodInvalidErr() + } + return nil +} + +func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChannelClass) (tg.MessagesChatsClass, error) { + if len(ids) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + chats := make([]tg.ChatClass, 0, len(ids)) + for _, input := range ids { + ref, ok := inputChannelRef(input) + if !ok || ref.ID == 0 || r.deps.Channels == nil { + continue + } + view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) + if err != nil { + if isChannelNotFound(err) { + continue + } + return nil, internalErr() + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + continue + } + chats = append(chats, tgChannelChat(userID, view.Channel, &view.Self)) + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputChannelClass) (*tg.MessagesChatFull, error) { + if r.deps.Channels == nil { + return &tg.MessagesChatFull{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + _, view, err := r.channelView(ctx, input) + if err != nil { + return nil, err + } + full := tgChannelFull(view) + userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID} + if canViewChannelJoinRequests(view.Self) { + userIDs = r.applyPendingJoinRequestsToFullChannel(ctx, full, view.Channel.ID, userIDs) + } + r.trackChannelInterest(ctx, userID, view.Channel.ID) + return &tg.MessagesChatFull{ + FullChat: full, + Chats: []tg.ChatClass{tgChannelChat(userID, view.Channel, &view.Self)}, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil +} + +func (r *Router) onChannelsGetSendAs(ctx context.Context, req *tg.ChannelsGetSendAsRequest) (*tg.ChannelsSendAsPeers, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) + if !ok || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + chats := []tg.ChatClass(nil) + peers := []tg.SendAsPeer{{Peer: &tg.PeerUser{UserID: userID}}} + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return &tg.ChannelsSendAsPeers{}, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + if ref, ok := inputPeerChannelRef(req.Peer); ok { + if ref.ID != view.Channel.ID || (ref.CheckAccessHash && !inputChannelAccessHashMatches(ref, view.Channel)) { + return nil, channelInvalidErr(domain.ErrChannelPrivate) + } + } + chats = []tg.ChatClass{tgChannelChat(userID, view.Channel, &view.Self)} + if canCurrentChannelSendAs(view) { + peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}}) + } + } + return &tg.ChannelsSendAsPeers{ + Peers: peers, + Chats: chats, + Users: r.tgUsersForIDs(ctx, userID, []int64{userID}), + }, nil +} + +func (r *Router) onChannelsCheckUsername(ctx context.Context, req *tg.ChannelsCheckUsernameRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return false, err + } + okUsername, err := r.deps.Channels.CheckUsername(ctx, userID, channelID, req.Username) + if err != nil { + return false, channelUsernameErr(err) + } + return okUsername, nil +} + +func (r *Router) onChannelsUpdateUsername(ctx context.Context, req *tg.ChannelsUpdateUsernameRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return false, err + } + channel, err := r.deps.Channels.UpdateUsername(ctx, userID, domain.UpdateChannelUsernameRequest{ + UserID: userID, + ChannelID: channelID, + Username: req.Username, + }) + if err != nil { + return false, channelUsernameErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return true, nil +} + +func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg.ChannelsGetAdminedPublicChannelsRequest) (tg.MessagesChatsClass, error) { + if r.deps.Channels == nil { + return &tg.MessagesChats{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.ByLocation { + return &tg.MessagesChats{}, nil + } + channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID) + if err != nil { + return nil, internalErr() + } + return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil +} + +func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.ChannelsExportMessageLinkRequest) (*tg.ExportedMessageLink, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, view, err := r.channelView(ctx, req.Channel) + if err != nil { + return nil, err + } + history, err := r.deps.Channels.GetMessages(ctx, userID, view.Channel.ID, []int{req.ID}) + if err != nil { + return nil, channelInvalidErr(err) + } + if len(history.Messages) != 1 || history.Messages[0].ID != req.ID { + return nil, messageIDInvalidErr() + } + link := "" + if view.Channel.Username != "" { + link = "https://t.me/" + view.Channel.Username + "/" + strconv.Itoa(req.ID) + } else { + link = "https://t.me/c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID) + } + if req.Thread { + if rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID { + link += "?thread=" + strconv.Itoa(rootID) + } + } + return &tg.ExportedMessageLink{Link: link, HTML: ""}, nil +} + +func channelMessageThreadRootID(msg domain.ChannelMessage) int { + if msg.ReplyTo == nil { + return 0 + } + if msg.ReplyTo.TopMessageID > 0 { + return msg.ReplyTo.TopMessageID + } + return msg.ReplyTo.MessageID +} + +func (r *Router) onChannelsToggleSignatures(ctx context.Context, req *tg.ChannelsToggleSignaturesRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetSignatures(ctx, userID, channelID, req.SignaturesEnabled) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(userID, channel) + r.pushChannelStateToMembers(ctx, userID, channel) + return updates, nil +} + +func (r *Router) onChannelsTogglePreHistoryHidden(ctx context.Context, req *tg.ChannelsTogglePreHistoryHiddenRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + viewerUserID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, viewerUserID, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetPreHistoryHidden(ctx, viewerUserID, channelID, req.Enabled) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(viewerUserID, channel) + r.pushChannelStateToMembers(ctx, viewerUserID, channel) + return updates, nil +} + +func (r *Router) onChannelsToggleSlowMode(ctx context.Context, req *tg.ChannelsToggleSlowModeRequest) (tg.UpdatesClass, error) { + if !domain.ValidChannelSlowModeSeconds(req.Seconds) { + return nil, secondsInvalidErr() + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + viewerUserID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, viewerUserID, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetSlowMode(ctx, viewerUserID, channelID, req.Seconds) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(viewerUserID, channel) + r.pushChannelStateToMembers(ctx, viewerUserID, channel) + return updates, nil +} + +func (r *Router) onChannelsSetStickers(ctx context.Context, req *tg.ChannelsSetStickersRequest) (bool, error) { + _, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return false, err + } + if !view.Channel.Megagroup || view.Channel.Broadcast { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + if err := validateEmptyChannelStickerSet(req.Stickerset); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsSetEmojiStickers(ctx context.Context, req *tg.ChannelsSetEmojiStickersRequest) (bool, error) { + _, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return false, err + } + if !view.Channel.Megagroup || view.Channel.Broadcast { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + if err := validateEmptyChannelStickerSet(req.Stickerset); err != nil { + return false, err + } + return true, nil +} + +func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) error { + if _, ok := stickerset.(*tg.InputStickerSetEmpty); ok { + return nil + } + return stickersetInvalidErr() +} + +func (r *Router) onChannelsReorderUsernames(ctx context.Context, req *tg.ChannelsReorderUsernamesRequest) (bool, error) { + if len(req.Order) > maxChannelUsernameOrder { + return false, limitInvalidErr() + } + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsToggleUsername(ctx context.Context, req *tg.ChannelsToggleUsernameRequest) (bool, error) { + if req.Username != "" && !validChannelManagementUsername(req.Username) { + return false, usernameInvalidErr() + } + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsDeactivateAllUsernames(ctx context.Context, input tg.InputChannelClass) (bool, error) { + if _, _, err := r.channelChangeInfoView(ctx, input); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsUpdateColor(ctx context.Context, req *tg.ChannelsUpdateColorRequest) (tg.UpdatesClass, error) { + viewerUserID, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetColor(ctx, viewerUserID, view.Channel.ID, req.ForProfile, domainPeerColorFromChannelUpdate(req)) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(viewerUserID, channel) + r.pushChannelStateToMembers(ctx, viewerUserID, channel) + return updates, nil +} + +func (r *Router) onChannelsUpdateEmojiStatus(ctx context.Context, req *tg.ChannelsUpdateEmojiStatusRequest) (tg.UpdatesClass, error) { + viewerUserID, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return nil, err + } + status, err := domainChannelEmojiStatus(req.EmojiStatus) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetEmojiStatus(ctx, viewerUserID, view.Channel.ID, status) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(viewerUserID, channel) + r.pushChannelStateToMembers(ctx, viewerUserID, channel) + return updates, nil +} + +func (r *Router) onChannelsReadMessageContents(ctx context.Context, req *tg.ChannelsReadMessageContentsRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return false, err + } + read, err := r.deps.Channels.ReadMessageContents(ctx, userID, domain.ReadChannelMessageContentsRequest{ + UserID: userID, + ChannelID: channelID, + IDs: req.ID, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return false, messageIDInvalidErr() + } + return false, channelInvalidErr(err) + } + if ids := readChannelMessageContentIDs(read.Messages); len(ids) > 0 { + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateChannelReadMessagesContents{ + ChannelID: read.Channel.ID, + Messages: ids, + }}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{tgChannelChat(userID, read.Channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) + } + if len(read.ClearedUnreadReactionMessageIDs) > 0 { + r.pushUserUpdates(ctx, userID, r.channelMessagesReactionsUpdates(ctx, userID, domain.ChannelMessageReactionsResult{ + Channel: read.Channel, + Messages: read.Messages, + }, read.ClearedUnreadReactionMessageIDs)) + } + return true, nil +} + +func readChannelMessageContentIDs(messages []domain.ChannelMessage) []int { + if len(messages) == 0 { + return nil + } + ids := make([]int, 0, len(messages)) + seen := make(map[int]struct{}, len(messages)) + for _, msg := range messages { + if msg.ID <= 0 { + continue + } + if _, ok := seen[msg.ID]; ok { + continue + } + seen[msg.ID] = struct{}{} + ids = append(ids, msg.ID) + } + sort.Ints(ids) + return ids +} + +func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) { + if len(req.ID) > maxChannelReportMessageIDs { + return false, limitInvalidErr() + } + for _, id := range req.ID { + if id <= 0 || id > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + } + if _, _, err := r.channelView(ctx, req.Channel); err != nil { + return false, err + } + if peer, ok := r.domainPeerFromInputPeer(0, req.Participant); !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return false, peerIDInvalidErr() + } + return true, nil +} + +func (r *Router) onChannelsGetLeftChannels(ctx context.Context, offset int) (tg.MessagesChatsClass, error) { + if offset < 0 || offset > domain.MaxLeftChannelsOffset { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil + } + list, err := r.deps.Channels.LeftChannels(ctx, userID, offset, domain.MaxLeftChannelsLimit) + if err != nil { + return nil, channelInvalidErr(err) + } + chats := make([]tg.ChatClass, 0, len(list.Channels)) + for _, item := range list.Channels { + chats = append(chats, tgChannelChat(userID, item.Channel, &item.Self)) + } + if len(chats) == 0 && list.Count > 0 { + return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil + } + if offset+len(chats) < list.Count { + return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onChannelsGetInactiveChannels(ctx context.Context) (*tg.MessagesInactiveChats, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return &tg.MessagesInactiveChats{Dates: []int{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil + } + list, err := r.deps.Channels.InactiveChannels(ctx, userID, domain.MaxInactiveChannelsLimit) + if err != nil { + return nil, channelInvalidErr(err) + } + dates := make([]int, 0, len(list.Channels)) + chats := make([]tg.ChatClass, 0, len(list.Channels)) + for i, channel := range list.Channels { + date := channel.Date + if i < len(list.Dialogs) && list.Dialogs[i].TopMessageDate > 0 { + date = list.Dialogs[i].TopMessageDate + } + dates = append(dates, date) + chats = append(chats, tgChannelChat(userID, channel, nil)) + } + return &tg.MessagesInactiveChats{Dates: dates, Chats: chats, Users: []tg.UserClass{}}, nil +} + +func (r *Router) onChannelsGetGroupsForDiscussion(ctx context.Context) (tg.MessagesChatsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Channels == nil { + return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil + } + channels, err := r.deps.Channels.DiscussionGroups(ctx, userID, domain.MaxDiscussionGroupsLimit) + if err != nil { + return nil, channelInvalidErr(err) + } + return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil +} + +func (r *Router) onChannelsSetDiscussionGroup(ctx context.Context, req *tg.ChannelsSetDiscussionGroupRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + if req == nil { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + broadcastID, err := r.optionalChannelIDFromInput(ctx, userID, req.Broadcast) + if err != nil { + return false, channelDiscussionErr(err) + } + groupID, err := r.optionalChannelIDFromInput(ctx, userID, req.Group) + if err != nil { + return false, channelDiscussionErr(err) + } + res, err := r.deps.Channels.SetDiscussionGroup(ctx, userID, broadcastID, groupID) + if err != nil { + return false, channelDiscussionErr(err) + } + for _, channel := range res.Channels { + r.pushChannelStateToMembers(ctx, userID, channel) + } + return true, nil +} + +func (r *Router) onChannelsEditLocation(ctx context.Context, req *tg.ChannelsEditLocationRequest) (bool, error) { + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsConvertToGigagroup(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { + userID, view, err := r.channelChangeInfoView(ctx, input) + if err != nil { + return nil, err + } + return r.channelStateUpdates(userID, view.Channel), nil +} + +func (r *Router) onChannelsDeleteParticipantHistory(ctx context.Context, req *tg.ChannelsDeleteParticipantHistoryRequest) (*tg.MessagesAffectedHistory, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + peer, ok := r.domainPeerFromInputPeer(0, req.Participant) + if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.DeleteParticipantHistory(ctx, userID, domain.DeleteChannelParticipantHistoryRequest{ + UserID: userID, + ChannelID: channelID, + ParticipantUserID: peer.ID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelDeleteErr(err) + } + if res.Event.Pts != 0 { + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) + }) + } + return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil +} + +func (r *Router) onChannelsToggleJoinToSend(ctx context.Context, req *tg.ChannelsToggleJoinToSendRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetJoinToSend(ctx, userID, channelID, req.Enabled) + if err != nil { + return nil, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return r.channelStateUpdates(userID, channel), nil +} + +func (r *Router) onChannelsToggleJoinRequest(ctx context.Context, req *tg.ChannelsToggleJoinRequestRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetJoinRequest(ctx, userID, channelID, req.Enabled) + if err != nil { + return nil, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return r.channelStateUpdates(userID, channel), nil +} + +func (r *Router) onChannelsToggleForum(ctx context.Context, req *tg.ChannelsToggleForumRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetForum(ctx, userID, channelID, req.Enabled, req.Tabs) + if err != nil { + return nil, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return r.channelStateUpdates(userID, channel), nil +} + +func (r *Router) onChannelsToggleAntiSpam(ctx context.Context, req *tg.ChannelsToggleAntiSpamRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetAntiSpam(ctx, userID, channelID, req.Enabled) + if err != nil { + return nil, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return r.channelStateUpdates(userID, channel), nil +} + +func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req *tg.ChannelsReportAntiSpamFalsePositiveRequest) (bool, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsToggleParticipantsHidden(ctx context.Context, req *tg.ChannelsToggleParticipantsHiddenRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetParticipantsHidden(ctx, userID, channelID, req.Enabled) + if err != nil { + return nil, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return r.channelStateUpdates(userID, channel), nil +} + +func (r *Router) onChannelsToggleViewForumAsMessages(ctx context.Context, req *tg.ChannelsToggleViewForumAsMessagesRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + changed, err := r.deps.Channels.SetViewForumAsMessages(ctx, userID, channelID, req.Enabled) + if err != nil { + return nil, channelInvalidErr(err) + } + if !changed { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventChannelViewForum, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + Bool: req.Enabled, + PtsCount: 1, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordChannelViewForumAsMessages(ctx, authKeyID, userID, channelID, req.Enabled, sessionID) + if err != nil { + return nil, internalErr() + } + } + out := tgUpdateForOutboxEvent(event) + if out == nil { + out = tgEmptyUpdates(event.Date) + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out) + return out, nil +} + +func (r *Router) onChannelsGetChannelRecommendations(ctx context.Context, req *tg.ChannelsGetChannelRecommendationsRequest) (tg.MessagesChatsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Channels == nil { + return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil + } + sourceChannelID := int64(0) + if req != nil { + if input, ok := req.GetChannel(); ok { + source, err := r.publicRecommendationSourceChannel(ctx, userID, input) + if err != nil { + return nil, err + } + sourceChannelID = source + } + } + res, err := r.deps.Channels.ChannelRecommendations(ctx, userID, domain.ChannelRecommendationsRequest{ + UserID: userID, + SourceChannelID: sourceChannelID, + Limit: domain.DefaultChannelRecommendationsLimit, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + chats := tgChannels(userID, res.Channels) + if res.Count > len(chats) { + return &tg.MessagesChatsSlice{Count: res.Count, Chats: chats}, nil + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) publicRecommendationSourceChannel(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { + ref, ok := inputChannelRef(input) + if !ok { + return 0, channelInvalidErr(domain.ErrChannelInvalid) + } + channel, err := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) + if err != nil { + return 0, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, channel) { + return 0, channelInvalidErr(domain.ErrChannelPrivate) + } + if channel.Deleted || !channel.Broadcast || channel.Megagroup || channel.Username == "" { + return 0, channelInvalidErr(domain.ErrChannelInvalid) + } + return channel.ID, nil +} + +func (r *Router) onChannelsSetBoostsToUnblockRestrictions(ctx context.Context, req *tg.ChannelsSetBoostsToUnblockRestrictionsRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if req.Boosts < 0 || req.Boosts > maxChannelBoostsToUnblockRestrictions { + return nil, limitInvalidErr() + } + return r.channelStateCompatUpdate(ctx, req.Channel) +} + +func (r *Router) onChannelsRestrictSponsoredMessages(ctx context.Context, req *tg.ChannelsRestrictSponsoredMessagesRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetRestrictedSponsored(ctx, userID, view.Channel.ID, req.Restricted) + if err != nil { + return nil, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return r.channelStateUpdates(userID, channel), nil +} + +func (r *Router) onChannelsSearchPosts(ctx context.Context, req *tg.ChannelsSearchPostsRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateChannelSearchPostsRequest(req); err != nil { + return nil, err + } + offsetChannelID, err := r.searchPostsOffsetChannelID(ctx, userID, req.OffsetPeer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil + } + hashtag, query := channelSearchPostsTerms(req) + history, err := r.deps.Channels.SearchPosts(ctx, userID, domain.ChannelSearchPostsRequest{ + Hashtag: hashtag, + Query: query, + OffsetRate: req.OffsetRate, + OffsetChannelID: offsetChannelID, + OffsetID: req.OffsetID, + Limit: req.Limit, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + return tgChannelSearchPostsMessages(userID, history), nil +} + +func validateChannelSearchPostsRequest(req *tg.ChannelsSearchPostsRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.Limit < 0 || req.Limit > maxChannelSearchPostsLimit { + return limitInvalidErr() + } + if req.OffsetRate < 0 { + return limitInvalidErr() + } + if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID { + return messageIDInvalidErr() + } + if req.AllowPaidStars < 0 { + return limitInvalidErr() + } + hashtag, hasHashtag, query, hasQuery := channelSearchPostsTermsWithFlags(req) + if hasHashtag == hasQuery { + return searchQueryEmptyErr() + } + if hasHashtag { + if strings.TrimSpace(hashtag) == "" { + return searchQueryEmptyErr() + } + if strings.Contains(hashtag, "#") || utf8.RuneCountInString(hashtag) > maxChannelSearchPostsQuery { + return limitInvalidErr() + } + } + if hasQuery { + if strings.TrimSpace(query) == "" { + return searchQueryEmptyErr() + } + if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery { + return limitInvalidErr() + } + } + return nil +} + +func channelSearchPostsTerms(req *tg.ChannelsSearchPostsRequest) (hashtag, query string) { + hashtag, _, query, _ = channelSearchPostsTermsWithFlags(req) + return strings.TrimSpace(hashtag), strings.TrimSpace(query) +} + +func channelSearchPostsTermsWithFlags(req *tg.ChannelsSearchPostsRequest) (hashtag string, hasHashtag bool, query string, hasQuery bool) { + hashtag, hasHashtag = req.GetHashtag() + if !hasHashtag && req.Hashtag != "" { + hashtag, hasHashtag = req.Hashtag, true + } + query, hasQuery = req.GetQuery() + if !hasQuery && req.Query != "" { + query, hasQuery = req.Query, true + } + return hashtag, hasHashtag, query, hasQuery +} + +func (r *Router) validateSearchPostsOffsetPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) error { + _, err := r.searchPostsOffsetChannelID(ctx, userID, peer) + return err +} + +func (r *Router) searchPostsOffsetChannelID(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { + if peer == nil { + return 0, nil + } + if _, ok := peer.(*tg.InputPeerEmpty); ok { + return 0, nil + } + out, ok := r.domainPeerFromInputPeer(userID, peer) + if !ok || out.ID == 0 { + return 0, peerIDInvalidErr() + } + if out.Type != domain.PeerTypeChannel { + return 0, peerIDInvalidErr() + } + ref, ok := inputPeerChannelRef(peer) + if !ok || !ref.CheckAccessHash || r.deps.Channels == nil { + return out.ID, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, out.ID) + if err == nil { + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, channelInvalidErr(domain.ErrChannelPrivate) + } + return out.ID, nil + } + if !errors.Is(err, domain.ErrChannelPrivate) { + return 0, channelInvalidErr(err) + } + channel, joinErr := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) + if joinErr != nil || channel.Username == "" || !inputChannelAccessHashMatches(ref, channel) { + return 0, channelInvalidErr(err) + } + return out.ID, nil +} + +func tgChannelSearchPostsMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { + messages := make([]tg.MessageClass, 0, len(history.Messages)) + for _, msg := range history.Messages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + chats := tgChannels(viewerUserID, history.Channels) + users := tgUsers(history.Users) + if history.Count > len(messages) { + out := &tg.MessagesMessagesSlice{ + Count: history.Count, + Messages: messages, + Topics: []tg.ForumTopicClass{}, + Chats: chats, + Users: users, + } + if len(history.Messages) > 0 { + out.SetNextRate(history.Messages[len(history.Messages)-1].Date) + } + out.SetSearchFlood(tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}) + return out + } + return &tg.MessagesMessages{Messages: messages, Topics: []tg.ForumTopicClass{}, Chats: chats, Users: users} +} + +func (r *Router) onChannelsUpdatePaidMessagesPrice(ctx context.Context, req *tg.ChannelsUpdatePaidMessagesPriceRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return nil, err + } + if err := validateChannelPaidMessagesPriceRequest(req, view.Channel); err != nil { + return nil, err + } + stars := req.SendPaidMessagesStars + if stars < 0 { + stars = 0 + } + channel, err := r.deps.Channels.SetPaidMessagesPrice(ctx, userID, view.Channel.ID, stars, req.BroadcastMessagesAllowed) + if err != nil { + return nil, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return r.channelStateUpdates(userID, channel), nil +} + +func validateChannelPaidMessagesPriceRequest(req *tg.ChannelsUpdatePaidMessagesPriceRequest, channel domain.Channel) error { + stars := req.SendPaidMessagesStars + if stars == -1 && channel.Broadcast && !req.BroadcastMessagesAllowed { + return nil + } + if stars < 0 || stars > maxChannelPaidMessageStars { + return starsAmountInvalidErr() + } + return nil +} + +func (r *Router) onChannelsToggleAutotranslation(ctx context.Context, req *tg.ChannelsToggleAutotranslationRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetAutotranslation(ctx, userID, view.Channel.ID, req.Enabled) + if err != nil { + return nil, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return r.channelStateUpdates(userID, channel), nil +} + +func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.ChannelsGetMessageAuthorRequest) (tg.UserClass, error) { + userID, view, err := r.channelView(ctx, req.Channel) + if err != nil { + return nil, err + } + author, err := r.deps.Channels.GetMessageAuthor(ctx, userID, domain.GetChannelMessageAuthorRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + ID: req.ID, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, channelInvalidErr(err) + } + users := r.tgUsersForIDs(ctx, userID, []int64{author.SenderUserID}) + if len(users) == 0 { + return nil, peerIDInvalidErr() + } + return users[0], nil +} + +func (r *Router) onChannelsCheckSearchPostsFlood(ctx context.Context, req *tg.ChannelsCheckSearchPostsFloodRequest) (*tg.SearchPostsFlood, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if err := validateChannelCheckSearchPostsFloodRequest(req); err != nil { + return nil, err + } + return &tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}, nil +} + +func validateChannelCheckSearchPostsFloodRequest(req *tg.ChannelsCheckSearchPostsFloodRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + query, hasQuery := req.GetQuery() + if !hasQuery && req.Query != "" { + query, hasQuery = req.Query, true + } + if !hasQuery || strings.TrimSpace(query) == "" { + return searchQueryEmptyErr() + } + if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery { + return limitInvalidErr() + } + return nil +} + +func (r *Router) onChannelsSetMainProfileTab(ctx context.Context, req *tg.ChannelsSetMainProfileTabRequest) (bool, error) { + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (tg.ChannelsChannelParticipantsClass, error) { + if r.deps.Channels == nil { + return &tg.ChannelsChannelParticipants{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + filter := domainChannelParticipantsFilter(req.Filter) + if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength { + return nil, limitInvalidErr() + } + list, err := r.deps.Channels.GetParticipants(ctx, userID, channelID, filter, req.Offset, req.Limit) + if err != nil { + return nil, channelInvalidErr(err) + } + if req.Hash != 0 && list.Hash == req.Hash { + return &tg.ChannelsChannelParticipantsNotModified{}, nil + } + participants := make([]tg.ChannelParticipantClass, 0, len(list.Participants)) + userIDs := make([]int64, 0, len(list.Participants)) + for _, member := range list.Participants { + participants = append(participants, tgChannelParticipant(userID, member)) + userIDs = append(userIDs, member.UserID) + } + users := r.tgUsers(list.Users) + if len(users) == 0 { + users = r.tgUsersForIDs(ctx, userID, userIDs) + } + r.log.Debug("channels.getParticipants result", + zap.Int64("channel_id", channelID), + zap.String("filter", string(filter.Kind)), + zap.Int("count", list.Count), + zap.Int("participants", len(participants)), + zap.Int("users", len(users)), + ) + return &tg.ChannelsChannelParticipants{ + Count: list.Count, + Participants: participants, + Chats: []tg.ChatClass{}, + Users: users, + }, nil +} + +func (r *Router) onChannelsGetParticipant(ctx context.Context, req *tg.ChannelsGetParticipantRequest) (*tg.ChannelsChannelParticipant, error) { + if r.deps.Channels == nil { + return &tg.ChannelsChannelParticipant{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + peer, ok := r.domainPeerFromInputPeer(userID, req.Participant) + if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + return &tg.ChannelsChannelParticipant{ + Participant: tgChannelParticipant(userID, member), + Users: r.tgUsersForIDs(ctx, userID, []int64{member.UserID}), + }, nil +} + +func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.ChannelsInviteToChannelRequest) (*tg.MessagesInvitedUsers, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if len(req.Users) == 0 || len(req.Users) > domain.MaxChannelInviteUsers { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + userIDs, err := r.userIDsFromInputUsers(ctx, userID, req.Users) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, int(r.clock.Now().Unix())) + if err != nil { + return nil, channelInviteErr(err) + } + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil +} + +func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ref, ok := inputChannelRef(input) + if !ok { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + if ref.CheckAccessHash { + channel, err := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, channel) { + return nil, channelInvalidErr(domain.ErrChannelPrivate) + } + } + res, err := r.deps.Channels.JoinChannel(ctx, userID, ref.ID, int(r.clock.Now().Unix())) + if err != nil { + if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 { + r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) + } + return nil, channelInviteErr(err) + } + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + return updates, nil +} + +func (r *Router) onChannelsLeaveChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, input) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.LeaveChannel(ctx, userID, channelID, int(r.clock.Now().Unix())) + if err != nil { + return nil, channelInvalidErr(err) + } + r.removeOnlineChannelMemberships(res.Channel.ID, userID) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + return updates, nil +} + +func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTitleRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if !validChannelTitle(req.Title) { + return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.EditTitle(ctx, userID, domain.EditChannelTitleRequest{ + UserID: userID, + ChannelID: channelID, + Title: req.Title, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelTitleUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelTitleUpdates(ctx, viewerUserID, res) + }) + return updates, nil +} + +func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found || target.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.EditAdmin(ctx, userID, domain.EditChannelAdminRequest{ + UserID: userID, + ChannelID: channelID, + MemberID: target.ID, + AdminRights: domainChannelAdminRights(req.AdminRights), + Rank: req.Rank, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + if res.Participant.Status == domain.ChannelMemberActive { + r.addOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID) + } else { + r.removeOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID) + } + updates := r.channelParticipantUpdates(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Event, res.Date) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelParticipantUpdates(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Event, res.Date) + }) + return updates, nil +} + +func (r *Router) onChannelsEditBanned(ctx context.Context, req *tg.ChannelsEditBannedRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + participant, ok := r.domainPeerFromInputPeer(userID, req.Participant) + if !ok || participant.Type != domain.PeerTypeUser || participant.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.EditBanned(ctx, userID, domain.EditChannelBannedRequest{ + UserID: userID, + ChannelID: channelID, + Participant: participant, + BannedRights: domainChannelBannedRights(req.BannedRights), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelParticipantUpdates(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Event, res.Date) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelParticipantUpdates(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Event, res.Date) + }) + return updates, nil +} + +func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPhotoRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if req.Photo == nil { + return nil, photoInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetPhoto(ctx, userID, channelID, photo) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(userID, channel) + r.pushChannelStateToMembers(ctx, userID, channel) + return updates, nil +} + +func (r *Router) onChannelsDeleteChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, input) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.DeleteChannel(ctx, userID, domain.DeleteChannelRequest{ + UserID: userID, + ChannelID: channelID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelStateUpdates(userID, res.Channel) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelStateUpdates(viewerUserID, res.Channel) + }) + r.removeOnlineChannelMembershipsForOnlineMembers(res.Channel.ID) + return updates, nil +} + +func (r *Router) onChannelsGetAdminLog(ctx context.Context, req *tg.ChannelsGetAdminLogRequest) (*tg.ChannelsAdminLogResults, error) { + if r.deps.Channels == nil { + return &tg.ChannelsAdminLogResults{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + adminIDs := []int64(nil) + if admins, ok := req.GetAdmins(); ok && len(admins) > 0 { + if len(admins) > domain.MaxChannelAdminLogAdmins { + return nil, limitInvalidErr() + } + adminIDs, err = r.userIDsFromInputUsers(ctx, userID, admins) + if err != nil { + return nil, err + } + } + res, err := r.deps.Channels.ListAdminLog(ctx, userID, domain.ChannelAdminLogRequest{ + UserID: userID, + ChannelID: channelID, + Query: req.Q, + AdminUserIDs: adminIDs, + MaxID: req.MaxID, + MinID: req.MinID, + Limit: req.Limit, + Filter: domainChannelAdminLogFilter(req), + }) + if err != nil { + return nil, channelAdminErr(err) + } + return &tg.ChannelsAdminLogResults{ + Events: tgChannelAdminLogEvents(userID, res.Events), + Chats: []tg.ChatClass{tgChannelChat(userID, res.Channel, nil)}, + Users: r.channelAdminLogUsers(ctx, userID, res.Events), + }, nil +} + +func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsReadHistoryRequest) (bool, error) { + if r.deps.Channels == nil { + return true, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return false, err + } + read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ + UserID: userID, + ChannelID: channelID, + MaxID: req.MaxID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return false, channelInvalidErr(err) + } + if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil { + return false, err + } + r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) + return true, nil +} + +func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetMessagesRequest) (tg.MessagesMessagesClass, error) { + if len(req.ID) > domain.MaxGetMessageIDs { + return nil, limitInvalidErr() + } + if r.deps.Channels == nil || len(req.ID) == 0 { + return &tg.MessagesMessages{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + ids := make([]int, 0, len(req.ID)) + for _, input := range req.ID { + id, ok := inputMessageBoxID(input) + if !ok || id <= 0 || id > domain.MaxMessageBoxID { + continue + } + ids = append(ids, id) + } + if len(ids) == 0 { + return &tg.MessagesMessages{}, nil + } + history, err := r.deps.Channels.GetMessages(ctx, userID, channelID, ids) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + byID := make(map[int]domain.ChannelMessage, len(history.Messages)) + for _, msg := range history.Messages { + byID[msg.ID] = msg + } + messages := make([]tg.MessageClass, 0, len(ids)) + for _, id := range ids { + if msg, ok := byID[id]; ok { + messages = append(messages, tgChannelMessage(userID, msg)) + } else { + messages = append(messages, &tg.MessageEmpty{ID: id}) + } + } + return &tg.MessagesMessages{ + Messages: messages, + Chats: tgChannels(userID, []domain.Channel{history.Channel}), + Users: r.tgUsers(history.Users), + }, nil +} + +func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) { + if len(req.ID) == 0 { + return &tg.MessagesAffectedMessages{PtsCount: 0}, nil + } + if len(req.ID) > domain.MaxDeleteMessageIDs { + return nil, limitInvalidErr() + } + for _, id := range req.ID { + if id <= 0 || id > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.DeleteMessages(ctx, userID, domain.DeleteChannelMessagesRequest{ + UserID: userID, + ChannelID: channelID, + IDs: append([]int(nil), req.ID...), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelDeleteErr(err) + } + if res.Event.Pts != 0 { + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) + }) + return &tg.MessagesAffectedMessages{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount}, nil + } + return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil +} + +func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ + UserID: userID, + ChannelID: channelID, + MaxID: req.MaxID, + ForEveryone: req.GetForEveryone(), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelDeleteErr(err) + } + if res.Event.Pts == 0 { + event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID) + updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID) + r.pushUserUpdates(ctx, userID, updates) + return updates, nil + } + updates := r.channelDeleteMessagesUpdates(userID, res.Channel, res.Event) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) + }) + return updates, nil +} + +func (r *Router) onMessagesUpdatePinnedMessage(ctx context.Context, req *tg.MessagesUpdatePinnedMessageRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Channels.UpdatePinnedMessage(ctx, userID, domain.UpdateChannelPinnedMessageRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.ID, + Pinned: !req.Unpin, + Silent: req.Silent, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + updates := r.channelPinnedUpdates(userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelPinnedUpdates(viewerUserID, res) + }) + return updates, nil +} + +func (r *Router) onMessagesUnpinAllMessages(ctx context.Context, req *tg.MessagesUnpinAllMessagesRequest) (*tg.MessagesAffectedHistory, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID <= 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + if savedPeer, ok := req.GetSavedPeerID(); ok && savedPeer != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeer); err != nil { + return nil, err + } + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return &tg.MessagesAffectedHistory{Offset: 0}, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return nil, channelAdminErr(err) + } + if topMsgID, ok := req.GetTopMsgID(); ok && topMsgID > 0 { + return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil + } + if _, ok := req.GetSavedPeerID(); ok { + return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil + } + if view.Channel.PinnedMessageID == 0 { + return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil + } + res, err := r.deps.Channels.UpdatePinnedMessage(ctx, userID, domain.UpdateChannelPinnedMessageRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: view.Channel.PinnedMessageID, + Pinned: false, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + if errors.Is(err, domain.ErrChannelNotModified) { + return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil + } + return nil, channelAdminErr(err) + } + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelPinnedUpdates(viewerUserID, res) + }) + return &tg.MessagesAffectedHistory{ + Pts: res.Event.Pts, + PtsCount: res.Event.PtsCount, + Offset: 0, + }, nil +} + +func (r *Router) onMessagesExportChatInvite(ctx context.Context, req *tg.MessagesExportChatInviteRequest) (tg.ExportedChatInviteClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + if req.UsageLimit < 0 || req.ExpireDate < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { + return nil, limitInvalidErr() + } + res, err := r.deps.Channels.ExportInvite(ctx, userID, domain.ExportChannelInviteRequest{ + UserID: userID, + ChannelID: peer.ID, + Title: req.Title, + RequestNeeded: req.RequestNeeded, + ExpireDate: req.ExpireDate, + UsageLimit: req.UsageLimit, + LegacyRevokePermanent: req.LegacyRevokePermanent, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInviteErr(err) + } + return tgExportedChannelInvite(res.Invite), nil +} + +func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg.ChatInviteClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + res, err := r.deps.Channels.CheckInvite(ctx, userID, hash, int(r.clock.Now().Unix())) + if err != nil { + return nil, channelInviteErr(err) + } + if res.Already { + return &tg.ChatInviteAlready{Chat: tgChannelChat(userID, res.Channel, &res.Self)}, nil + } + return &tg.ChatInvite{ + Channel: true, + Broadcast: res.Channel.Broadcast, + Megagroup: res.Channel.Megagroup, + Public: res.Channel.Username != "", + RequestNeeded: res.Invite.RequestNeeded, + Title: res.Channel.Title, + About: res.Channel.About, + Photo: &tg.PhotoEmpty{}, + ParticipantsCount: res.Channel.ParticipantsCount, + }, nil +} + +func (r *Router) onMessagesImportChatInvite(ctx context.Context, hash string) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + res, err := r.deps.Channels.ImportInvite(ctx, userID, domain.ImportChannelInviteRequest{ + UserID: userID, + Hash: hash, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 { + r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) + } + return nil, channelInviteErr(err) + } + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + return updates, nil +} + +func (r *Router) onMessagesGetExportedChatInvites(ctx context.Context, req *tg.MessagesGetExportedChatInvitesRequest) (*tg.MessagesExportedChatInvites, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(req.OffsetLink) > maxChatInviteLinkLength { + return nil, limitInvalidErr() + } + adminID := userID + if !inputUserIsEmpty(req.AdminID) { + admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID}) + if err != nil { + return nil, err + } + if len(admins) > 0 { + adminID = admins[0] + } + } + offsetHash := "" + if req.OffsetLink != "" { + offsetHash, err = channelInviteHashFromLink(req.OffsetLink) + if err != nil { + return nil, err + } + } + list, err := r.deps.Channels.ListExportedInvites(ctx, userID, domain.ChannelInviteListRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + AdminUserID: adminID, + Revoked: req.Revoked, + OffsetDate: req.OffsetDate, + OffsetHash: offsetHash, + Limit: req.Limit, + }) + if err != nil { + return nil, channelInviteErr(err) + } + userIDs := []int64{adminID} + invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites)) + for _, invite := range list.Invites { + invites = append(invites, tgExportedChannelInvite(invite)) + userIDs = append(userIDs, invite.AdminUserID) + } + return &tg.MessagesExportedChatInvites{ + Count: list.Count, + Invites: invites, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil +} + +func (r *Router) onMessagesGetExportedChatInvite(ctx context.Context, req *tg.MessagesGetExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + hash, err := channelInviteHashFromLink(req.Link) + if err != nil { + return nil, err + } + invite, err := r.deps.Channels.GetExportedInvite(ctx, userID, domain.GetChannelInviteRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + }) + if err != nil { + return nil, channelInviteErr(err) + } + return &tg.MessagesExportedChatInvite{ + Invite: tgExportedChannelInvite(invite), + Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}), + }, nil +} + +func (r *Router) onMessagesEditExportedChatInvite(ctx context.Context, req *tg.MessagesEditExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + hash, err := channelInviteHashFromLink(req.Link) + if err != nil { + return nil, err + } + if req.ExpireDate < 0 || req.UsageLimit < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { + return nil, limitInvalidErr() + } + expireDate, hasExpireDate := req.GetExpireDate() + usageLimit, hasUsageLimit := req.GetUsageLimit() + requestNeeded, hasRequestNeeded := req.GetRequestNeeded() + title, hasTitle := req.GetTitle() + edited, err := r.deps.Channels.EditExportedInvite(ctx, userID, domain.EditChannelInviteRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + Revoked: req.Revoked, + HasExpireDate: hasExpireDate, + ExpireDate: expireDate, + HasUsageLimit: hasUsageLimit, + UsageLimit: usageLimit, + HasRequestNeeded: hasRequestNeeded, + RequestNeeded: requestNeeded, + HasTitle: hasTitle, + Title: title, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInviteErr(err) + } + users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID}) + if edited.NewInvite != nil { + return &tg.MessagesExportedChatInviteReplaced{ + Invite: tgExportedChannelInvite(edited.Invite), + NewInvite: tgExportedChannelInvite(*edited.NewInvite), + Users: users, + }, nil + } + return &tg.MessagesExportedChatInvite{Invite: tgExportedChannelInvite(edited.Invite), Users: users}, nil +} + +func (r *Router) onMessagesDeleteRevokedExportedChatInvites(ctx context.Context, req *tg.MessagesDeleteRevokedExportedChatInvitesRequest) (bool, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return false, err + } + adminID := userID + if !inputUserIsEmpty(req.AdminID) { + admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID}) + if err != nil { + return false, err + } + if len(admins) > 0 { + adminID = admins[0] + } + } + if err := r.deps.Channels.DeleteRevokedExportedInvites(ctx, userID, domain.DeleteRevokedChannelInvitesRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + AdminUserID: adminID, + Limit: domain.MaxChannelHideJoinRequests, + }); err != nil { + return false, channelInviteErr(err) + } + return true, nil +} + +func (r *Router) onMessagesDeleteExportedChatInvite(ctx context.Context, req *tg.MessagesDeleteExportedChatInviteRequest) (bool, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return false, err + } + hash, err := channelInviteHashFromLink(req.Link) + if err != nil { + return false, err + } + if err := r.deps.Channels.DeleteExportedInvite(ctx, userID, domain.DeleteChannelInviteRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + }); err != nil { + return false, channelInviteErr(err) + } + return true, nil +} + +func (r *Router) onMessagesGetAdminsWithInvites(ctx context.Context, peer tg.InputPeerClass) (*tg.MessagesChatAdminsWithInvites, error) { + userID, view, err := r.inviteManagementChannelView(ctx, peer) + if err != nil { + return nil, err + } + counts, err := r.deps.Channels.ListAdminsWithInvites(ctx, userID, view.Channel.ID) + if err != nil { + return nil, channelInviteErr(err) + } + admins := make([]tg.ChatAdminWithInvites, 0, len(counts)) + userIDs := make([]int64, 0, len(counts)) + for _, count := range counts { + admins = append(admins, tg.ChatAdminWithInvites{ + AdminID: count.AdminUserID, + InvitesCount: count.InvitesCount, + RevokedInvitesCount: count.RevokedInvitesCount, + }) + userIDs = append(userIDs, count.AdminUserID) + } + return &tg.MessagesChatAdminsWithInvites{ + Admins: admins, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil +} + +func (r *Router) onMessagesGetChatInviteImporters(ctx context.Context, req *tg.MessagesGetChatInviteImportersRequest) (*tg.MessagesChatInviteImporters, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + link, hasLink := req.GetLink() + query, hasQuery := req.GetQ() + if hasLink && hasQuery && strings.TrimSpace(link) != "" && strings.TrimSpace(query) != "" { + return nil, tgerr400("SEARCH_WITH_LINK_NOT_SUPPORTED") + } + if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(link) > maxChatInviteLinkLength || len(query) > maxChatInviteSearchLength { + return nil, limitInvalidErr() + } + hash := "" + if strings.TrimSpace(link) != "" { + hash, err = channelInviteHashFromLink(link) + if err != nil { + return nil, err + } + } + offsetUserID := int64(0) + if !inputUserIsEmpty(req.OffsetUser) { + ids, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.OffsetUser}) + if err != nil { + return nil, err + } + if len(ids) > 0 { + offsetUserID = ids[0] + } + } + list, err := r.deps.Channels.ListInviteImporters(ctx, userID, domain.ChannelInviteImportersRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + Requested: req.Requested, + Query: query, + OffsetDate: req.OffsetDate, + OffsetUserID: offsetUserID, + Limit: req.Limit, + }) + if err != nil { + return nil, channelInviteErr(err) + } + importers := make([]tg.ChatInviteImporter, 0, len(list.Importers)) + userIDs := make([]int64, 0, len(list.Importers)) + for _, importer := range list.Importers { + tgImporter := tg.ChatInviteImporter{ + UserID: importer.UserID, + Date: importer.Date, + } + if importer.Requested { + tgImporter.SetRequested(true) + } + if importer.ApprovedBy != 0 { + tgImporter.SetApprovedBy(importer.ApprovedBy) + userIDs = append(userIDs, importer.ApprovedBy) + } + importers = append(importers, tgImporter) + userIDs = append(userIDs, importer.UserID) + } + return &tg.MessagesChatInviteImporters{ + Count: list.Count, + Importers: importers, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil +} + +func (r *Router) onMessagesHideChatJoinRequest(ctx context.Context, req *tg.MessagesHideChatJoinRequestRequest) (tg.UpdatesClass, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + targets, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.UserID}) + if err != nil { + return nil, err + } + if len(targets) == 0 { + return nil, userIDInvalidErr() + } + res, err := r.deps.Channels.HideChatJoinRequest(ctx, userID, domain.HideChannelJoinRequestRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + TargetUserID: targets[0], + Approved: req.Approved, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInviteErr(err) + } + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) + return updates, nil +} + +func (r *Router) onMessagesHideAllChatJoinRequests(ctx context.Context, req *tg.MessagesHideAllChatJoinRequestsRequest) (tg.UpdatesClass, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + link, hasLink := req.GetLink() + if hasLink && len(link) > maxChatInviteLinkLength { + return nil, limitInvalidErr() + } + hash := "" + if hasLink && strings.TrimSpace(link) != "" { + hash, err = channelInviteHashFromLink(link) + if err != nil { + return nil, err + } + } + res, err := r.deps.Channels.HideAllChatJoinRequests(ctx, userID, domain.HideChannelJoinRequestsRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + Approved: req.Approved, + Limit: domain.MaxChannelHideJoinRequests, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInviteErr(err) + } + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) + return updates, nil +} + +func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.UpdatesGetChannelDifferenceRequest) (tg.UpdatesChannelDifferenceClass, error) { + if r.deps.Channels == nil { + return &tg.UpdatesChannelDifferenceEmpty{Final: true, Pts: req.Pts, Timeout: 30}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + r.trackChannelInterest(ctx, userID, channelID) + diff, err := r.deps.Channels.GetDifference(ctx, userID, domain.ChannelDifferenceRequest{ + UserID: userID, + ChannelID: channelID, + Pts: req.Pts, + Limit: req.Limit, + Force: req.Force, + }) + if err != nil { + if errors.Is(err, domain.ErrPersistentTimestamp) { + return nil, persistentTimestampInvalidErr() + } + return nil, channelInvalidErr(err) + } + diff = r.enrichChannelDifference(ctx, userID, diff) + return tgChannelDifference(userID, diff), nil +} + +func (r *Router) channelOperationUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) *tg.Updates { + users := make([]int64, 0, len(res.Members)+1) + users = append(users, res.Channel.CreatorUserID) + for _, member := range res.Members { + users = append(users, member.UserID) + } + updates := make([]tg.UpdateClass, 0, 1) + if res.Event.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, users), + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, channelMemberForUser(res.Members, viewerUserID))}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) tdesktopCreateChatUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) *tg.Updates { + updates := r.channelOperationUpdates(ctx, viewerUserID, res) + if updates == nil { + return updates + } + self := channelMemberForUser(res.Members, viewerUserID) + legacy := tgMigratedLegacyChat(viewerUserID, res.Channel, self) + if legacy == nil { + return updates + } + chats := make([]tg.ChatClass, 0, len(updates.Chats)+1) + chats = append(chats, legacy) + chats = append(chats, updates.Chats...) + updates.Chats = chats + return updates +} + +func tdesktopCreateChatNeedsLegacyChat(ctx context.Context) bool { + ci, ok := ClientInfoFrom(ctx) + if !ok { + _, hasSession := SessionIDFrom(ctx) + return hasSession + } + device := strings.ToLower(ci.DeviceModel) + return ci.LangPack == "tdesktop" || strings.Contains(device, "desktop") +} + +func channelMemberForUser(members []domain.ChannelMember, userID int64) *domain.ChannelMember { + for i := range members { + if members[i].UserID == userID { + return &members[i] + } + } + return nil +} + +func createChatInviteMemberIDs(ids []int64, selfUserID int64) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 || id == selfUserID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func mergeChannelMembers(a, b []domain.ChannelMember) []domain.ChannelMember { + if len(a) == 0 { + return append([]domain.ChannelMember(nil), b...) + } + if len(b) == 0 { + return append([]domain.ChannelMember(nil), a...) + } + out := make([]domain.ChannelMember, 0, len(a)+len(b)) + seen := make(map[int64]struct{}, len(a)+len(b)) + appendOne := func(member domain.ChannelMember) { + if member.UserID == 0 { + return + } + if _, ok := seen[member.UserID]; ok { + return + } + seen[member.UserID] = struct{}{} + out = append(out, member) + } + for _, member := range a { + appendOne(member) + } + for _, member := range b { + appendOne(member) + } + return out +} + +func (r *Router) channelSelfForViewer(ctx context.Context, userID, channelID int64, members []domain.ChannelMember) *domain.ChannelMember { + if self := channelMemberForUser(members, userID); self != nil { + return self + } + if r.deps.Channels == nil || userID == 0 || channelID == 0 { + return nil + } + self, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, userID) + if err != nil { + return nil + } + return &self +} + +func canViewChannelJoinRequests(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) +} + +func (r *Router) applyPendingJoinRequestsToFullChannel(ctx context.Context, full *tg.ChannelFull, channelID int64, userIDs []int64) []int64 { + if r.deps.Channels == nil || full == nil || channelID == 0 { + return userIDs + } + pending, err := r.deps.Channels.PendingJoinRequests(ctx, channelID, domain.MaxChannelPendingJoinRecentRequesters) + if err != nil || pending.Count <= 0 { + return userIDs + } + full.SetRequestsPending(pending.Count) + full.SetRecentRequesters(pending.RecentRequesters) + return append(userIDs, pending.RecentRequesters...) +} + +func (r *Router) pendingJoinRequestsUpdates(ctx context.Context, viewerUserID int64, channel domain.Channel) *tg.Updates { + if r.deps.Channels == nil || channel.ID == 0 { + return nil + } + pending, err := r.deps.Channels.PendingJoinRequests(ctx, channel.ID, domain.MaxChannelPendingJoinRecentRequesters) + if err != nil { + return nil + } + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePendingJoinRequests{ + Peer: &tg.PeerChannel{ChannelID: channel.ID}, + RequestsPending: pending.Count, + RecentRequesters: pending.RecentRequesters, + }}, + Users: r.tgUsersForIDs(ctx, viewerUserID, pending.RecentRequesters), + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) appendPendingJoinRequestsUpdate(ctx context.Context, viewerUserID int64, updates *tg.Updates, channel domain.Channel) { + if updates == nil { + return + } + pending := r.pendingJoinRequestsUpdates(ctx, viewerUserID, channel) + if pending == nil { + return + } + updates.Updates = append(updates.Updates, pending.Updates...) + updates.Users = append(updates.Users, pending.Users...) +} + +func (r *Router) pushPendingJoinRequestsToAdmins(ctx context.Context, channel domain.Channel) { + if r.deps.Channels == nil || r.deps.Sessions == nil || channel.ID == 0 { + return + } + adminIDs, err := r.deps.Channels.InviteAdminMemberIDs(ctx, channel.ID, domain.MaxChannelRealtimeFanout) + if err != nil || len(adminIDs) == 0 { + adminIDs = []int64{channel.CreatorUserID} + } + seen := make(map[int64]struct{}, len(adminIDs)) + for _, adminID := range adminIDs { + if adminID == 0 { + continue + } + if _, ok := seen[adminID]; ok { + continue + } + seen[adminID] = struct{}{} + updates := r.pendingJoinRequestsUpdates(ctx, adminID, channel) + if updates == nil { + continue + } + r.pushUserUpdates(ctx, adminID, updates) + } +} + +func (r *Router) channelTitleUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelTitleResult) *tg.Updates { + updates := []tg.UpdateClass{&tg.UpdateChannel{ChannelID: res.Channel.ID}} + if res.Event.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, []int64{res.Message.SenderUserID}), + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelParticipantUpdates(ctx context.Context, viewerUserID, actorUserID int64, channel domain.Channel, previous, participant domain.ChannelMember, event domain.ChannelUpdateEvent, date int) *tg.Updates { + update := &tg.UpdateChannelParticipant{ + ChannelID: channel.ID, + Date: date, + ActorID: actorUserID, + UserID: participant.UserID, + } + if update.ActorID == 0 { + update.ActorID = viewerUserID + } + if previous.UserID != 0 { + update.SetPrevParticipant(tgChannelParticipantForUpdate(viewerUserID, previous)) + } + if participant.UserID != 0 { + update.SetNewParticipant(tgChannelParticipantForUpdate(viewerUserID, participant)) + } + updates := []tg.UpdateClass{update, &tg.UpdateChannel{ChannelID: channel.ID}} + if event.Pts > 0 { + tooLong := &tg.UpdateChannelTooLong{ChannelID: channel.ID} + tooLong.SetPts(event.Pts) + updates = append(updates, tooLong) + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, []int64{participant.UserID, participant.InviterUserID, previous.UserID, previous.InviterUserID, update.ActorID}), + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelStateUpdates(viewerUserID int64, channel domain.Channel) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateChannel{ChannelID: channel.ID}}, + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelPinnedUpdates(viewerUserID int64, res domain.UpdateChannelPinnedMessageResult) *tg.Updates { + updates := []tg.UpdateClass(nil) + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID}) + return &tg.Updates{ + Updates: updates, + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelMessageUpdates(ctx context.Context, viewerUserID int64, res domain.SendChannelMessageResult, randomID int64) *tg.Updates { + randomIDs := []int64(nil) + includeMessageIDs := randomID != 0 + if includeMessageIDs { + randomIDs = []int64{randomID} + } + return r.channelMessagesUpdates(ctx, viewerUserID, []domain.SendChannelMessageResult{res}, randomIDs, includeMessageIDs, nil) +} + +func (r *Router) pushChannelDiscussionUpdate(ctx context.Context, originUserID int64, discussion *domain.SendChannelDiscussionResult) { + if discussion == nil || discussion.Channel.ID == 0 || discussion.Event.Pts == 0 { + return + } + res := domain.SendChannelMessageResult{ + Channel: discussion.Channel, + Message: discussion.Message, + Event: discussion.Event, + Recipients: discussion.Recipients, + } + r.pushChannelUpdates(ctx, originUserID, discussion.Channel.ID, discussion.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelMessageUpdates(ctx, viewerUserID, res, 0) + }) +} + +func (r *Router) channelMessagesUpdates(ctx context.Context, viewerUserID int64, results []domain.SendChannelMessageResult, randomIDs []int64, includeMessageIDs bool, extraUserIDs []int64) *tg.Updates { + updates := make([]tg.UpdateClass, 0, len(results)*2) + userIDs := make([]int64, 0, len(results)+len(extraUserIDs)) + userIDs = append(userIDs, extraUserIDs...) + extraChannelIDs := make([]int64, 0, len(results)) + var channel domain.Channel + date := 0 + for i, res := range results { + if res.Channel.ID != 0 { + channel = res.Channel + } + if includeMessageIDs && res.Message.ID != 0 && i < len(randomIDs) && randomIDs[i] != 0 { + updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: randomIDs[i]}) + } + if res.Event.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + } + if res.Message.SenderUserID != 0 { + userIDs = append(userIDs, res.Message.SenderUserID) + } + if res.Message.SendAs != nil { + switch res.Message.SendAs.Type { + case domain.PeerTypeUser: + userIDs = append(userIDs, res.Message.SendAs.ID) + case domain.PeerTypeChannel: + extraChannelIDs = append(extraChannelIDs, res.Message.SendAs.ID) + } + } + if res.Message.Forward != nil && res.Message.Forward.From.Type == domain.PeerTypeChannel { + extraChannelIDs = append(extraChannelIDs, res.Message.Forward.From.ID) + } + if res.Message.ReplyTo != nil && res.Message.ReplyTo.Peer.Type == domain.PeerTypeChannel { + extraChannelIDs = append(extraChannelIDs, res.Message.ReplyTo.Peer.ID) + } + if date == 0 { + date = res.Event.Date + } + if date == 0 { + date = res.Message.Date + } + } + chats := []tg.ChatClass(nil) + if channel.ID != 0 { + chats = []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)} + } + chats = append(chats, r.tgChannelsForIDs(ctx, viewerUserID, extraChannelIDs, channel.ID)...) + if date == 0 { + date = int(r.clock.Now().Unix()) + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), + Chats: chats, + Date: date, + Seq: 0, + } +} + +func (r *Router) tgChannelsForIDs(ctx context.Context, viewerUserID int64, ids []int64, skipIDs ...int64) []tg.ChatClass { + if r.deps.Channels == nil || len(ids) == 0 { + return nil + } + skip := make(map[int64]struct{}, len(skipIDs)) + for _, id := range skipIDs { + if id != 0 { + skip[id] = struct{}{} + } + } + seen := make(map[int64]struct{}, len(ids)) + chats := make([]tg.ChatClass, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := skip[id]; ok { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + view, err := r.deps.Channels.GetChannel(ctx, viewerUserID, id) + if err != nil || view.Channel.ID == 0 { + continue + } + chats = append(chats, tgChannelChat(viewerUserID, view.Channel, &view.Self)) + } + return chats +} + +func (r *Router) channelEditMessageUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult) *tg.Updates { + updates := make([]tg.UpdateClass, 0, 1) + if res.Event.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, []int64{res.Message.SenderUserID}), + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelDeleteMessagesUpdates(viewerUserID int64, channel domain.Channel, event domain.ChannelUpdateEvent) *tg.Updates { + updates := make([]tg.UpdateClass, 0, 1) + if update := tgChannelUpdate(viewerUserID, event); update != nil { + updates = append(updates, update) + } + return &tg.Updates{ + Updates: updates, + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelAvailableMessagesUpdates(viewerUserID int64, channel domain.Channel, availableMinID int) *tg.Updates { + updates := make([]tg.UpdateClass, 0, 1) + if channel.ID != 0 && availableMinID > 0 { + updates = append(updates, &tg.UpdateChannelAvailableMessages{ + ChannelID: channel.ID, + AvailableMinID: availableMinID, + }) + } + return &tg.Updates{ + Updates: updates, + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +type channelUpdatesBuilder func(viewerUserID int64) *tg.Updates + +type channelFanoutScope int + +const ( + channelFanoutMembers channelFanoutScope = iota + channelFanoutViewers + channelFanoutExplicit +) + +func (r *Router) pushChannelReadOutboxUpdates(ctx context.Context, channelID int64, updates []domain.ChannelReadOutboxUpdate) { + if r.deps.Sessions == nil || channelID == 0 || len(updates) == 0 { + return + } + seen := make(map[int64]int, len(updates)) + for _, update := range updates { + if update.UserID == 0 || update.MaxID <= 0 { + continue + } + if seen[update.UserID] < update.MaxID { + seen[update.UserID] = update.MaxID + } + } + date := int(r.clock.Now().Unix()) + for userID, maxID := range seen { + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateReadChannelOutbox{ChannelID: channelID, MaxID: maxID}}, + Date: date, + Seq: 0, + }) + } +} + +func (r *Router) recordChannelAvailableMessages(ctx context.Context, userID, channelID int64, availableMinID int) domain.UpdateEvent { + event := domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventChannelAvailable, + Date: int(r.clock.Now().Unix()), + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + MaxID: availableMinID, + PtsCount: 1, + } + if r.deps.Updates == nil || userID == 0 || channelID == 0 || availableMinID <= 0 { + return event + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + recorded, _, err := r.deps.Updates.RecordChannelAvailableMessages(ctx, authKeyID, userID, channelID, availableMinID, sessionID) + if err != nil { + return event + } + return recorded +} + +func (r *Router) recordChannelReadInbox(ctx context.Context, userID int64, read domain.ReadChannelHistoryResult) (domain.UpdateEvent, error) { + if !read.Changed || read.ChannelID == 0 { + return domain.UpdateEvent{}, nil + } + date := int(r.clock.Now().Unix()) + event := domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventReadHistoryInbox, + Date: date, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: read.ChannelID}, + MaxID: read.MaxID, + StillUnreadCount: read.StillUnreadCount, + Pts: read.Pts, + PtsCount: 1, + } + recordedEvent := event + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + recorded, _, err := r.deps.Updates.RecordReadHistory(ctx, authKeyID, userID, domain.ReadHistoryResult{ + OwnerUserID: userID, + Peer: event.Peer, + MaxID: read.MaxID, + StillUnreadCount: read.StillUnreadCount, + Changed: read.Changed, + }, sessionID) + if err != nil { + return domain.UpdateEvent{}, internalErr() + } + recordedEvent = recorded + } + r.pushReadHistoryEvent(ctx, userID, event) + return recordedEvent, nil +} + +func (r *Router) pushChannelUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { + r.pushChannelUpdatesWithScope(ctx, channelFanoutMembers, originUserID, channelID, recipients, build) +} + +func (r *Router) pushChannelViewerUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { + r.pushChannelUpdatesWithScope(ctx, channelFanoutViewers, originUserID, channelID, recipients, build) +} + +func (r *Router) pushChannelExplicitUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { + r.pushChannelUpdatesWithScope(ctx, channelFanoutExplicit, originUserID, channelID, recipients, build) +} + +func (r *Router) pushChannelUpdatesWithScope(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { + if r.deps.Sessions == nil || build == nil { + return + } + recipients = r.channelFanoutRecipients(ctx, scope, channelID, recipients) + seen := make(map[int64]struct{}, len(recipients)) + pushed := false + for _, userID := range recipients { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + updates := build(userID) + if updates == nil { + continue + } + r.pushUserUpdates(ctx, userID, updates) + pushed = true + } + if !pushed && originUserID != 0 { + updates := build(originUserID) + if updates == nil { + return + } + r.pushUserUpdates(ctx, originUserID, updates) + } +} + +func (r *Router) channelFanoutRecipients(ctx context.Context, scope channelFanoutScope, channelID int64, explicit []int64) []int64 { + if channelID == 0 || r.deps.Channels == nil || r.deps.Sessions == nil { + return uniqueRecipientIDs(explicit) + } + if scope == channelFanoutExplicit { + return uniqueRecipientIDs(explicit) + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return uniqueRecipientIDs(explicit) + } + var online []int64 + switch scope { + case channelFanoutMembers: + online = provider.OnlineChannelMemberUserIDs(channelID, 0) + case channelFanoutViewers: + online = provider.OnlineChannelUserIDs(channelID, 0) + } + if len(online) == 0 { + return uniqueRecipientIDs(explicit) + } + active, err := r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, online) + if err != nil { + return uniqueRecipientIDs(explicit) + } + if len(active) == 0 && len(explicit) == 0 { + return nil + } + out := uniqueRecipientIDs(active) + seen := make(map[int64]struct{}, len(out)+len(explicit)) + for _, userID := range active { + if userID == 0 { + continue + } + seen[userID] = struct{}{} + } + // Keep operation-specific recipients as a fallback: leave/kick/delete flows + // may need to notify a user who is no longer an active member after commit. + for _, userID := range explicit { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + out = append(out, userID) + } + return out +} + +func uniqueRecipientIDs(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, userID := range ids { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + out = append(out, userID) + } + return out +} + +func (r *Router) pushChannelStateToMembers(ctx context.Context, originUserID int64, channel domain.Channel) { + if r.deps.Channels == nil || channel.ID == 0 { + return + } + r.pushChannelUpdates(ctx, originUserID, channel.ID, []int64{originUserID}, func(viewerUserID int64) *tg.Updates { + return r.channelStateUpdates(viewerUserID, channel) + }) +} + +func (r *Router) tgUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []tg.UserClass { + if r.deps.Users == nil || len(ids) == 0 { + return nil + } + unique := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + users, err := r.deps.Users.ByIDs(ctx, currentUserID, unique) + if err != nil { + // 批量解析失败(通常是 DB 故障)不静默丢弃:批量语义下无部分结果,记日志便于排查。 + // update 仍以空 users 列表推送,客户端用本地缓存或后续 getUser 补齐,不致命。 + // 不降级逐个查询以免 DB 抖动时把一次失败放大成 N 次查询。 + r.log.Warn("batch resolve users for channel update failed", + zap.Int("count", len(unique)), zap.Error(err)) + return nil + } + byID := make(map[int64]domain.User, len(users)) + for _, u := range users { + if u.ID != 0 { + byID[u.ID] = u + } + } + out := make([]tg.UserClass, 0, len(byID)) + for _, id := range unique { + u, ok := byID[id] + if !ok { + continue + } + if id == currentUserID { + out = append(out, r.tgSelfUser(u)) + continue + } + out = append(out, r.tgUser(u)) + } + return out +} + +func tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass { + out := &tg.ChatInviteExported{ + Revoked: invite.Revoked, + Permanent: invite.Permanent, + RequestNeeded: invite.RequestNeeded, + Link: "https://t.me/+" + invite.Hash, + AdminID: invite.AdminUserID, + Date: invite.Date, + } + if invite.Title != "" { + out.SetTitle(invite.Title) + } + if invite.ExpireDate > 0 { + out.SetExpireDate(invite.ExpireDate) + } + if invite.UsageLimit > 0 { + out.SetUsageLimit(invite.UsageLimit) + } + if invite.UsageCount > 0 { + out.SetUsage(invite.UsageCount) + } + if invite.RequestedCount > 0 { + out.SetRequested(invite.RequestedCount) + } + return out +} + +func chatInviteExportedStub(adminUserID int64, link, title string, revoked, requestNeeded bool, expireDate, usageLimit, usageCount, date int) tg.ExportedChatInviteClass { + out := &tg.ChatInviteExported{ + Revoked: revoked, + RequestNeeded: requestNeeded, + Link: strings.TrimSpace(link), + AdminID: adminUserID, + Date: date, + } + if title != "" { + out.SetTitle(title) + } + if expireDate > 0 { + out.SetExpireDate(expireDate) + } + if usageLimit > 0 { + out.SetUsageLimit(usageLimit) + } + if usageCount > 0 { + out.SetUsage(usageCount) + } + return out +} + +func (r *Router) inviteManagementChannelView(ctx context.Context, peer tg.InputPeerClass) (int64, domain.ChannelView, error) { + ref, ok := inviteManagementChannelRef(peer) + if !ok { + return 0, domain.ChannelView{}, peerIDInvalidErr() + } + input := &tg.InputChannel{ChannelID: ref.ID} + if ref.CheckAccessHash { + input.AccessHash = ref.AccessHash + } + userID, view, err := r.channelView(ctx, input) + if err != nil { + return 0, domain.ChannelView{}, err + } + if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin { + return 0, domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED") + } + return userID, view, nil +} + +func inviteManagementChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) { + switch p := peer.(type) { + case *tg.InputPeerChannel: + return channelInputRef{ + ID: p.ChannelID, + AccessHash: p.AccessHash, + CheckAccessHash: p.AccessHash != 0, + }, p.ChannelID > 0 + case *tg.InputPeerChannelFromMessage: + return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0 + case *tg.InputPeerChat: + return channelInputRef{ID: p.ChatID}, p.ChatID > 0 + default: + return channelInputRef{}, false + } +} + +func validateChatInviteLink(link string) error { + link = strings.TrimSpace(link) + if link == "" { + return tgerr400("INVITE_HASH_EMPTY") + } + if len(link) > maxChatInviteLinkLength { + return limitInvalidErr() + } + return nil +} + +func channelInviteHashFromLink(link string) (string, error) { + if err := validateChatInviteLink(link); err != nil { + return "", err + } + link = strings.TrimSpace(link) + link = strings.TrimPrefix(link, "tg://join?invite=") + if strings.Contains(link, "://") { + if idx := strings.LastIndex(link, "/+"); idx >= 0 { + link = link[idx+2:] + } else if idx := strings.LastIndex(link, "/joinchat/"); idx >= 0 { + link = link[idx+10:] + } else if idx := strings.LastIndex(link, "/"); idx >= 0 { + link = link[idx+1:] + } + } + link = strings.TrimPrefix(link, "+") + link = strings.TrimSpace(link) + if link == "" { + return "", tgerr400("INVITE_HASH_EMPTY") + } + if len(link) > maxChatInviteLinkLength { + return "", limitInvalidErr() + } + return link, nil +} + +func inputUserIsEmpty(input tg.InputUserClass) bool { + switch input.(type) { + case nil, *tg.InputUserEmpty: + return true + default: + return false + } +} + +func (r *Router) userIDsFromInputUsers(ctx context.Context, currentUserID int64, inputs []tg.InputUserClass) ([]int64, error) { + out := make([]int64, 0, len(inputs)) + seen := make(map[int64]struct{}, len(inputs)) + for _, input := range inputs { + u, found, err := r.userFromInput(ctx, currentUserID, input) + if err != nil { + return nil, internalErr() + } + if !found || u.ID == 0 { + return nil, peerIDInvalidErr() + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + out = append(out, u.ID) + } + return out, nil +} + +func domainChannelAdminLogFilter(req *tg.ChannelsGetAdminLogRequest) domain.ChannelAdminLogFilter { + filter, ok := req.GetEventsFilter() + if !ok { + return domain.ChannelAdminLogFilter{} + } + return domain.ChannelAdminLogFilter{ + Join: filter.GetJoin(), + Leave: filter.GetLeave(), + Invite: filter.GetInvite(), + Ban: filter.GetBan(), + Unban: filter.GetUnban(), + Kick: filter.GetKick(), + Unkick: filter.GetUnkick(), + Promote: filter.GetPromote(), + Demote: filter.GetDemote(), + Info: filter.GetInfo(), + Settings: filter.GetSettings(), + Pinned: filter.GetPinned(), + Edit: filter.GetEdit(), + Delete: filter.GetDelete(), + Send: filter.GetSend(), + Invites: filter.GetInvites(), + Forums: filter.GetForums(), + SubExtend: filter.GetSubExtend(), + EditRank: filter.GetEditRank(), + } +} + +func (r *Router) channelAdminLogUsers(ctx context.Context, currentUserID int64, events []domain.ChannelAdminLogEvent) []tg.UserClass { + if r.deps.Users == nil || len(events) == 0 { + return nil + } + ids := make(map[int64]struct{}, len(events)) + add := func(id int64) { + if id != 0 { + ids[id] = struct{}{} + } + } + addMember := func(member *domain.ChannelMember) { + if member != nil { + add(member.UserID) + add(member.InviterUserID) + } + } + addMessage := func(msg *domain.ChannelMessage) { + if msg != nil { + add(msg.SenderUserID) + if msg.From.Type == domain.PeerTypeUser { + add(msg.From.ID) + } + } + } + for _, event := range events { + add(event.UserID) + addMember(event.PrevParticipant) + addMember(event.NewParticipant) + addMember(event.Participant) + addMessage(event.Message) + addMessage(event.PrevMessage) + addMessage(event.NewMessage) + } + out := make([]tg.UserClass, 0, len(ids)) + userIDs := make([]int64, 0, len(ids)) + for id := range ids { + userIDs = append(userIDs, id) + } + sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) + for _, id := range userIDs { + u, found, err := r.deps.Users.ByID(ctx, currentUserID, id) + if err == nil && found { + out = append(out, r.tgUser(u)) + } + } + return out +} + +type channelInputRef struct { + ID int64 + AccessHash int64 + CheckAccessHash bool +} + +func inputChannelRef(input tg.InputChannelClass) (channelInputRef, bool) { + switch channel := input.(type) { + case *tg.InputChannel: + return channelInputRef{ + ID: channel.ChannelID, + AccessHash: channel.AccessHash, + CheckAccessHash: channel.AccessHash != 0, + }, channel.ChannelID > 0 + case *tg.InputChannelFromMessage: + return channelInputRef{ID: channel.ChannelID}, channel.ChannelID > 0 + default: + return channelInputRef{}, false + } +} + +func inputChannelID(input tg.InputChannelClass) (int64, bool) { + ref, ok := inputChannelRef(input) + return ref.ID, ok +} + +func inputChannelAccessHashMatches(ref channelInputRef, channel domain.Channel) bool { + return !ref.CheckAccessHash || ref.AccessHash == channel.AccessHash +} + +func (r *Router) optionalChannelIDFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { + switch input.(type) { + case nil, *tg.InputChannelEmpty: + return 0, nil + default: + return r.channelIDFromInput(ctx, userID, input) + } +} + +func (r *Router) channelIDFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { + ref, ok := inputChannelRef(input) + if !ok { + return 0, channelInvalidErr(domain.ErrChannelInvalid) + } + if !ref.CheckAccessHash || r.deps.Channels == nil { + return ref.ID, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) + if err != nil { + return 0, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, channelInvalidErr(domain.ErrChannelPrivate) + } + return ref.ID, nil +} + +func validChannelTitle(title string) bool { + n := utf8.RuneCountInString(title) + return n > 0 && n <= maxChannelTitleLength +} + +func (r *Router) channelView(ctx context.Context, input tg.InputChannelClass) (int64, domain.ChannelView, error) { + if r.deps.Channels == nil { + return 0, domain.ChannelView{}, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.ChannelView{}, internalErr() + } + ref, ok := inputChannelRef(input) + if !ok { + return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid) + } + view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) + if err != nil { + return 0, domain.ChannelView{}, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate) + } + return userID, view, nil +} + +func (r *Router) channelChangeInfoView(ctx context.Context, input tg.InputChannelClass) (int64, domain.ChannelView, error) { + if r.deps.Channels == nil { + return 0, domain.ChannelView{}, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.ChannelView{}, internalErr() + } + ref, ok := inputChannelRef(input) + if !ok { + return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid) + } + view, err := r.deps.Channels.GetChannelForChangeInfo(ctx, userID, ref.ID) + if err != nil { + return 0, domain.ChannelView{}, channelAdminErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate) + } + return userID, view, nil +} + +func (r *Router) channelStateCompatUpdate(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { + userID, view, err := r.channelChangeInfoView(ctx, input) + if err != nil { + return nil, err + } + updates := r.channelStateUpdates(userID, view.Channel) + r.pushChannelStateToMembers(ctx, userID, view.Channel) + return updates, nil +} + +func tgEmptyUpdates(date int) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: date, + Seq: 0, + } +} + +func validChannelSlowModeSeconds(seconds int) bool { + return domain.ValidChannelSlowModeSeconds(seconds) +} + +func validChannelManagementUsername(username string) bool { + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + if len(username) < 5 || len(username) > 32 { + return false + } + for i := 0; i < len(username); i++ { + c := username[i] + switch { + case i == 0 && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')): + case i > 0 && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'): + default: + return false + } + } + return true +} + +func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsRequest) (domain.ChannelReactionPolicy, error) { + if req == nil || req.AvailableReactions == nil { + return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") + } + if req.ReactionsLimit < 0 || req.ReactionsLimit > domain.MaxChannelReactionItems { + return domain.ChannelReactionPolicy{}, limitInvalidErr() + } + policy := domain.ChannelReactionPolicy{ + Limit: req.ReactionsLimit, + PaidEnabled: req.PaidEnabled, + } + switch reactions := req.AvailableReactions.(type) { + case *tg.ChatReactionsNone: + policy.Type = domain.ChannelReactionPolicyNone + case *tg.ChatReactionsAll: + policy.Type = domain.ChannelReactionPolicyAll + policy.AllowCustom = reactions.AllowCustom + case *tg.ChatReactionsSome: + if len(reactions.Reactions) > domain.MaxChannelReactionItems { + return domain.ChannelReactionPolicy{}, limitInvalidErr() + } + policy.Type = domain.ChannelReactionPolicySome + for _, reaction := range reactions.Reactions { + switch value := reaction.(type) { + case *tg.ReactionEmoji: + if strings.TrimSpace(value.Emoticon) == "" || utf8.RuneCountInString(value.Emoticon) > domain.MaxChannelReactionEmoticonLength { + return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") + } + policy.Emoticons = append(policy.Emoticons, value.Emoticon) + case *tg.ReactionCustomEmoji: + if value.DocumentID <= 0 { + return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") + } + policy.CustomEmojiIDs = append(policy.CustomEmojiIDs, value.DocumentID) + default: + return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") + } + } + default: + return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") + } + return policy, nil +} + +func domainPeerColorFromChannelUpdate(req *tg.ChannelsUpdateColorRequest) domain.ChannelPeerColor { + if req == nil { + return domain.ChannelPeerColor{} + } + color, hasColor := req.GetColor() + backgroundEmojiID, hasBackground := req.GetBackgroundEmojiID() + out := domain.ChannelPeerColor{HasColor: hasColor, Color: color} + if hasBackground { + out.BackgroundEmojiID = backgroundEmojiID + } + return out +} + +func domainChannelEmojiStatus(status tg.EmojiStatusClass) (domain.ChannelEmojiStatus, error) { + switch s := status.(type) { + case *tg.EmojiStatusEmpty: + return domain.ChannelEmojiStatus{}, nil + case *tg.EmojiStatus: + if s.DocumentID <= 0 { + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + until, _ := s.GetUntil() + if until < 0 { + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + return domain.ChannelEmojiStatus{DocumentID: s.DocumentID, Until: until}, nil + case *tg.EmojiStatusCollectible: + if s.DocumentID <= 0 { + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + until, _ := s.GetUntil() + if until < 0 { + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + return domain.ChannelEmojiStatus{DocumentID: s.DocumentID, Until: until}, nil + case *tg.InputEmojiStatusCollectible: + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + default: + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } +} + +func domainChannelParticipantsFilter(filter tg.ChannelParticipantsFilterClass) domain.ChannelParticipantsFilter { + switch f := filter.(type) { + case *tg.ChannelParticipantsAdmins: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins} + case *tg.ChannelParticipantsKicked: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsKicked, Query: f.Q} + case *tg.ChannelParticipantsBanned: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBanned, Query: f.Q} + case *tg.ChannelParticipantsSearch: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsSearch, Query: f.Q} + case *tg.ChannelParticipantsBots: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBots} + case *tg.ChannelParticipantsContacts: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsContacts, Query: f.Q} + case *tg.ChannelParticipantsMentions: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsMentions, Query: f.Q} + default: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent} + } +} + +func legacyBasicGroupAdminRights() tg.ChatAdminRights { + return tg.ChatAdminRights{ + ChangeInfo: true, + DeleteMessages: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + Other: true, + } +} + +func channelIDFromLegacyInputPeer(userID int64, peer tg.InputPeerClass) (int64, bool) { + switch p := peer.(type) { + case *tg.InputPeerChannel: + return p.ChannelID, p.ChannelID > 0 + case *tg.InputPeerChat: + return p.ChatID, p.ChatID > 0 + case *tg.InputPeerChannelFromMessage: + return p.ChannelID, p.ChannelID > 0 + default: + return 0, false + } +} + +func (r *Router) channelIDFromLegacyInputPeerChecked(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { + channelID, ok := channelIDFromLegacyInputPeer(userID, peer) + if !ok { + return 0, peerIDInvalidErr() + } + if err := r.validateInputPeerChannelAccess(ctx, userID, peer, channelID); err != nil { + return 0, err + } + return channelID, nil +} + +func isChannelNotFound(err error) bool { + return errors.Is(err, domain.ErrChannelInvalid) || + errors.Is(err, domain.ErrChannelPrivate) || + errors.Is(err, domain.ErrChannelUserBanned) +} + +func channelInvalidErr(err error) error { + switch { + case errors.Is(err, domain.ErrChannelTitleInvalid): + return tgerr400("CHAT_TITLE_EMPTY") + case errors.Is(err, domain.ErrChannelInvalid): + return tgerr400("CHANNEL_INVALID") + case errors.Is(err, domain.ErrChannelPrivate): + return tgerr400("CHANNEL_PRIVATE") + case errors.Is(err, domain.ErrChannelUserBanned): + return tgerr400("USER_BANNED_IN_CHANNEL") + case errors.Is(err, domain.ErrChannelWriteForbidden): + return tgerr400("CHAT_WRITE_FORBIDDEN") + case errors.Is(err, domain.ErrChannelAdminRequired): + return tgerr400("CHAT_ADMIN_REQUIRED") + case errors.Is(err, domain.ErrUserAlreadyParticipant): + return tgerr400("USER_ALREADY_PARTICIPANT") + case errors.Is(err, domain.ErrReplyMessageIDInvalid): + return replyMessageIDInvalidErr() + default: + if seconds, ok := domain.SlowModeWaitSeconds(err); ok { + return tgerr.New(420, fmt.Sprintf("SLOWMODE_WAIT_%d", seconds)) + } + return internalErr() + } +} + +func channelDeleteErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrMessageAuthorRequired): + return messageAuthorRequiredErr() + case errors.Is(err, domain.ErrChannelAdminRequired): + return tgerr400("CHAT_ADMIN_REQUIRED") + default: + return channelInvalidErr(err) + } +} + +func channelAdminErr(err error) error { + switch { + case errors.Is(err, domain.ErrChannelNotModified): + return tgerr400("CHAT_NOT_MODIFIED") + case errors.Is(err, domain.ErrChatPublicRequired): + return tgerr400("CHAT_PUBLIC_REQUIRED") + case errors.Is(err, domain.ErrChatDiscussionUnallowed): + return tgerr400("CHAT_DISCUSSION_UNALLOWED") + case errors.Is(err, domain.ErrChannelRightForbidden): + return tgerr.New(403, "RIGHT_FORBIDDEN") + case errors.Is(err, domain.ErrChannelUserCreator): + return tgerr400("USER_CREATOR") + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + default: + return channelInvalidErr(err) + } +} + +func channelDiscussionErr(err error) error { + switch { + case errors.Is(err, domain.ErrLinkNotModified): + return tgerr400("LINK_NOT_MODIFIED") + case errors.Is(err, domain.ErrBroadcastIDInvalid): + return tgerr400("BROADCAST_ID_INVALID") + case errors.Is(err, domain.ErrMegagroupIDInvalid): + return tgerr400("MEGAGROUP_ID_INVALID") + case errors.Is(err, domain.ErrMegagroupPrehistoryHidden): + return tgerr400("MEGAGROUP_PREHISTORY_HIDDEN") + default: + return channelAdminErr(err) + } +} + +func channelInviteErr(err error) error { + switch { + case errors.Is(err, domain.ErrInviteHashEmpty): + return tgerr400("INVITE_HASH_EMPTY") + case errors.Is(err, domain.ErrInviteHashInvalid): + return tgerr400("INVITE_HASH_INVALID") + case errors.Is(err, domain.ErrInviteHashExpired): + return tgerr.New(406, "INVITE_HASH_EXPIRED") + case errors.Is(err, domain.ErrInvitePermanent): + return tgerr400("CHAT_INVITE_PERMANENT") + case errors.Is(err, domain.ErrInviteRevokedMissing): + return tgerr400("INVITE_REVOKED_MISSING") + case errors.Is(err, domain.ErrInviteRequestSent): + return tgerr400("INVITE_REQUEST_SENT") + case errors.Is(err, domain.ErrHideRequesterMissing): + return tgerr400("HIDE_REQUESTER_MISSING") + case errors.Is(err, domain.ErrUsersTooMuch): + return tgerr400("USERS_TOO_MUCH") + case errors.Is(err, domain.ErrUserAlreadyParticipant): + return tgerr400("USER_ALREADY_PARTICIPANT") + case errors.Is(err, domain.ErrUserKicked): + return tgerr400("USER_KICKED") + default: + return channelInvalidErr(err) + } +} + +func channelUsernameErr(err error) error { + switch { + case errors.Is(err, domain.ErrUsernameInvalid): + return usernameInvalidErr() + case errors.Is(err, domain.ErrUsernameOccupied): + return usernameOccupiedErr() + case errors.Is(err, domain.ErrChannelNotModified): + return usernameNotModifiedErr() + default: + return channelAdminErr(err) + } +} + +func tgerr400(message string) error { + return tgerr.New(400, message) +} diff --git a/internal/rpc/contacts.go b/internal/rpc/contacts.go new file mode 100644 index 00000000..7e3c7a3c --- /dev/null +++ b/internal/rpc/contacts.go @@ -0,0 +1,562 @@ +package rpc + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/app/contacts" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +const ( + maxContactImportBatch = 500 + maxContactDeleteBatch = 500 + maxContactNameLength = 128 + maxContactPhoneLength = 64 + maxContactNoteLength = 4096 + maxContactSearchQLen = 256 + maxContactSearchLimit = 50 +) + +// registerContacts 注册 contacts.* RPC handler。 +func (r *Router) registerContacts(d *tg.ServerDispatcher) { + d.OnContactsGetContacts(r.onContactsGetContacts) + d.OnContactsGetContactIDs(r.onContactsGetContactIDs) + d.OnContactsGetStatuses(r.onContactsGetStatuses) + d.OnContactsImportContacts(r.onContactsImportContacts) + d.OnContactsAddContact(r.onContactsAddContact) + d.OnContactsDeleteContacts(r.onContactsDeleteContacts) + d.OnContactsUpdateContactNote(r.onContactsUpdateContactNote) + d.OnContactsSearch(r.onContactsSearch) + d.OnContactsResolveUsername(r.onContactsResolveUsername) + d.OnContactsResolvePhone(r.onContactsResolvePhone) + d.OnContactsGetTopPeers(func(ctx context.Context, req *tg.ContactsGetTopPeersRequest) (tg.ContactsTopPeersClass, error) { + return tdesktop.TopPeers(), nil + }) + d.OnContactsGetBlocked(func(ctx context.Context, req *tg.ContactsGetBlockedRequest) (tg.ContactsBlockedClass, error) { + if req.Limit > 50 { + return nil, limitInvalidErr() + } + return tdesktop.BlockedContacts(), nil + }) + d.OnContactsGetSponsoredPeers(func(ctx context.Context, q string) (tg.ContactsSponsoredPeersClass, error) { + if utf8.RuneCountInString(q) > maxContactSearchQLen { + return nil, limitInvalidErr() + } + return &tg.ContactsSponsoredPeersEmpty{}, nil + }) +} + +func (r *Router) onContactsGetContacts(ctx context.Context, hash int64) (tg.ContactsContactsClass, error) { + if r.deps.Contacts == nil { + return &tg.ContactsContacts{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + list, notModified, err := r.deps.Contacts.GetContacts(ctx, userID, hash) + if err != nil { + return nil, internalErr() + } + if notModified { + return &tg.ContactsContactsNotModified{}, nil + } + return tgContacts(r.withContactListPresence(list)), nil +} + +func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus, error) { + if r.deps.Contacts == nil { + return []tg.ContactStatus{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + list, _, err := r.deps.Contacts.GetContacts(ctx, userID, 0) + if err != nil { + return nil, internalErr() + } + contactUserIDs := make([]int64, 0, len(list.Contacts)) + out := make([]tg.ContactStatus, 0, len(list.Contacts)) + seen := make(map[int64]struct{}, len(list.Contacts)) + for _, contact := range list.Contacts { + id := contact.User.ID + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + contactUserIDs = append(contactUserIDs, id) + } + usersByID := make(map[int64]domain.User, len(contactUserIDs)) + if len(contactUserIDs) > 0 && r.deps.Users != nil { + users, err := r.deps.Users.ByIDs(ctx, userID, contactUserIDs) + if err != nil { + return nil, internalErr() + } + for _, u := range users { + if u.ID != 0 { + usersByID[u.ID] = u + } + } + } + seen = make(map[int64]struct{}, len(list.Contacts)) + for _, contact := range list.Contacts { + id := contact.User.ID + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + u := contact.User + if current, ok := usersByID[id]; ok { + u.LastSeenAt = current.LastSeenAt + u.Status = current.Status + } + out = append(out, tg.ContactStatus{ + UserID: id, + Status: tgUserStatus(r.userPresenceStatusForUser(u)), + }) + } + return out, nil +} + +func (r *Router) onContactsGetContactIDs(ctx context.Context, hash int64) ([]int, error) { + if r.deps.Contacts == nil { + return nil, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, hash) + if err != nil { + return nil, internalErr() + } + if notModified { + return nil, nil + } + return ids, nil +} + +func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputPhoneContact) (*tg.ContactsImportedContacts, error) { + if r.deps.Contacts == nil { + return &tg.ContactsImportedContacts{}, nil + } + if len(input) > maxContactImportBatch { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + items := make([]domain.ContactInput, 0, len(input)) + for _, item := range input { + note, entities := contactNote(item.GetNote()) + if !validContactInput(item.Phone, item.FirstName, item.LastName, note, len(entities)) { + return nil, limitInvalidErr() + } + items = append(items, domain.ContactInput{ + ClientID: item.ClientID, + Phone: item.Phone, + FirstName: item.FirstName, + LastName: item.LastName, + Note: note, + NoteEntities: entities, + }) + } + res, err := r.deps.Contacts.ImportContacts(ctx, userID, items) + if err != nil { + return nil, internalErr() + } + out := &tg.ContactsImportedContacts{ + Imported: make([]tg.ImportedContact, 0, len(res.Imported)), + Users: make([]tg.UserClass, 0, len(res.Contacts)), + } + for _, imported := range res.Imported { + out.Imported = append(out.Imported, tg.ImportedContact{UserID: imported.UserID, ClientID: imported.ClientID}) + } + for _, contact := range res.Contacts { + out.Users = append(out.Users, r.tgUser(contact.User)) + } + out.RetryContacts = append(out.RetryContacts, res.RetryContacts...) + for _, contact := range res.Contacts { + if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}, domain.PeerSettings{ShareContact: true}); err != nil { + return nil, internalErr() + } + } + if err := r.recordContactsReset(ctx, userID); err != nil { + return nil, internalErr() + } + r.pushContactsReset(ctx, userID) + return out, nil +} + +func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddContactRequest) (tg.UpdatesClass, error) { + if r.deps.Contacts == nil { + return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + target, found, err := r.userFromInput(ctx, userID, req.ID) + if err != nil { + return nil, contactErr(err) + } + if !found { + return nil, contactIDInvalidErr() + } + note, entities := contactNote(req.GetNote()) + if !validContactInput(req.Phone, req.FirstName, req.LastName, note, len(entities)) { + return nil, limitInvalidErr() + } + contact, err := r.deps.Contacts.AddContact(ctx, userID, domain.ContactInput{ + ContactUserID: target.ID, + Phone: req.Phone, + FirstName: req.FirstName, + LastName: req.LastName, + Note: note, + NoteEntities: entities, + AddPhonePrivacyException: req.AddPhonePrivacyException, + }) + if err != nil { + return nil, contactErr(err) + } + updates := r.contactPeerSettingsUpdates(ctx, userID, contact.User, domain.PeerSettings{ShareContact: true}, true) + updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{}) + if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}, domain.PeerSettings{ShareContact: true}); err != nil { + return nil, internalErr() + } + if err := r.recordContactsReset(ctx, userID); err != nil { + return nil, internalErr() + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates) + return updates, nil +} + +func (r *Router) onContactsDeleteContacts(ctx context.Context, ids []tg.InputUserClass) (tg.UpdatesClass, error) { + if r.deps.Contacts == nil { + return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil + } + if len(ids) > maxContactDeleteBatch { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + contactIDs := make([]int64, 0, len(ids)) + users := make([]tg.UserClass, 0, len(ids)+1) + if r.deps.Users != nil { + if u, err := r.deps.Users.Self(ctx, userID); err == nil { + users = append(users, r.tgSelfUser(u)) + } + } + seen := map[int64]struct{}{userID: {}} + for _, id := range ids { + u, found, err := r.userFromInput(ctx, userID, id) + if err != nil { + return nil, contactErr(err) + } + if !found || u.ID == userID { + continue + } + contactIDs = append(contactIDs, u.ID) + u.Contact = false + u.Mutual = false + if _, ok := seen[u.ID]; !ok { + users = append(users, r.tgUser(u)) + seen[u.ID] = struct{}{} + } + } + if _, err := r.deps.Contacts.DeleteContacts(ctx, userID, contactIDs); err != nil { + return nil, internalErr() + } + updates := make([]tg.UpdateClass, 0, len(contactIDs)) + for _, id := range contactIDs { + updates = append(updates, &tg.UpdatePeerSettings{ + Peer: &tg.PeerUser{UserID: id}, + Settings: tgPeerSettings(domain.PeerSettings{AddContact: true, BlockContact: true}), + }) + } + if len(contactIDs) > 0 { + for _, id := range contactIDs { + if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: id}, domain.PeerSettings{AddContact: true, BlockContact: true}); err != nil { + return nil, internalErr() + } + } + updates = append(updates, &tg.UpdateContactsReset{}) + if err := r.recordContactsReset(ctx, userID); err != nil { + return nil, internalErr() + } + } + out := &tg.Updates{Updates: updates, Users: users, Date: int(r.clock.Now().Unix())} + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out) + return out, nil +} + +func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.ContactsUpdateContactNoteRequest) (bool, error) { + if r.deps.Contacts == nil { + return true, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + target, found, err := r.userFromInput(ctx, userID, req.ID) + if err != nil { + return false, contactErr(err) + } + if !found { + return false, contactIDInvalidErr() + } + if utf8.RuneCountInString(req.Note.Text) > maxContactNoteLength || len(req.Note.Entities) > maxMessageEntityCount { + return false, limitInvalidErr() + } + if _, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, req.Note.Text, domainMessageEntities(req.Note.Entities)); err != nil { + return false, contactErr(err) + } + if err := r.recordContactsReset(ctx, userID); err != nil { + return false, internalErr() + } + r.pushContactsReset(ctx, userID) + return true, nil +} + +func (r *Router) onContactsSearch(ctx context.Context, req *tg.ContactsSearchRequest) (*tg.ContactsFound, error) { + if r.deps.Contacts == nil && r.deps.Channels == nil { + return &tg.ContactsFound{}, nil + } + query := normalizeSearchQuery(req.Q) + if query == "" { + return nil, searchQueryEmptyErr() + } + if utf8.RuneCountInString(query) < 3 { + return nil, queryTooShortErr() + } + if utf8.RuneCountInString(query) > maxContactSearchQLen { + return nil, limitInvalidErr() + } + limit := req.Limit + if limit <= 0 || limit > maxContactSearchLimit { + limit = maxContactSearchLimit + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + res := domain.UserSearchResult{} + if r.deps.Contacts != nil { + userRes, err := r.deps.Contacts.Search(ctx, userID, query, limit) + if err != nil { + return nil, internalErr() + } + res = userRes + } + if r.deps.Channels != nil { + channelRes, err := r.deps.Channels.SearchPublicChannels(ctx, userID, query, limit) + if err != nil { + return nil, channelInvalidErr(err) + } + res.MyChannelResults = channelRes.MyResults + res.ChannelResults = channelRes.Results + } + return tgContactsFound(userID, r.withUserSearchPresence(res)), nil +} + +func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.ContactsResolveUsernameRequest) (*tg.ContactsResolvedPeer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if svc, ok := r.deps.Users.(UserIdentityService); ok { + u, found, err := svc.ResolveUsername(ctx, userID, req.Username) + if err != nil { + return nil, usernameErr(err) + } + if found { + return r.tgResolvedUserPeer(userID, u), nil + } + } + if r.deps.Channels != nil { + ch, found, err := r.deps.Channels.ResolvePublicUsername(ctx, userID, req.Username) + if err != nil { + return nil, usernameErr(err) + } + if found { + return tgResolvedChannelPeer(userID, ch), nil + } + } + return nil, usernameNotOccupiedErr() +} + +func (r *Router) onContactsResolvePhone(ctx context.Context, phone string) (*tg.ContactsResolvedPeer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return nil, phoneNotOccupiedErr() + } + u, found, err := svc.ResolvePhone(ctx, userID, phone) + if err != nil { + if errors.Is(err, domain.ErrPhoneNotOccupied) { + return nil, phoneNotOccupiedErr() + } + return nil, internalErr() + } + if !found { + return nil, phoneNotOccupiedErr() + } + return r.tgResolvedUserPeer(userID, u), nil +} + +func (r *Router) tgResolvedUserPeer(currentUserID int64, u domain.User) *tg.ContactsResolvedPeer { + var user tg.UserClass + if u.ID == currentUserID { + user = r.tgSelfUser(u) + } else { + user = r.tgUser(u) + } + return &tg.ContactsResolvedPeer{ + Peer: &tg.PeerUser{UserID: u.ID}, + Users: []tg.UserClass{user}, + } +} + +func tgResolvedChannelPeer(currentUserID int64, ch domain.Channel) *tg.ContactsResolvedPeer { + return &tg.ContactsResolvedPeer{ + Peer: &tg.PeerChannel{ChannelID: ch.ID}, + Chats: []tg.ChatClass{tgChannelChat(currentUserID, ch, nil)}, + } +} + +func normalizeSearchQuery(query string) string { + query = strings.TrimSpace(query) + query = strings.TrimPrefix(query, "@") + return strings.TrimSpace(query) +} + +func validContactInput(phone, firstName, lastName, note string, entities int) bool { + if utf8.RuneCountInString(phone) > maxContactPhoneLength { + return false + } + if utf8.RuneCountInString(firstName) > maxContactNameLength || utf8.RuneCountInString(lastName) > maxContactNameLength { + return false + } + if utf8.RuneCountInString(note) > maxContactNoteLength || entities > maxMessageEntityCount { + return false + } + return true +} + +func contactNote(note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity) { + if !ok { + return "", nil + } + return note.Text, domainMessageEntities(note.Entities) +} + +func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, peerUser domain.User, settings domain.PeerSettings, includeSelf bool) *tg.Updates { + users := make([]tg.UserClass, 0, 2) + if includeSelf && r.deps.Users != nil { + if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { + users = append(users, r.tgSelfUser(self)) + } + } + users = append(users, r.tgUser(peerUser)) + return &tg.Updates{ + Updates: []tg.UpdateClass{ + &tg.UpdatePeerSettings{ + Peer: &tg.PeerUser{UserID: peerUser.ID}, + Settings: tgPeerSettings(settings), + }, + }, + Users: users, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) pushContactsReset(ctx context.Context, userID int64) { + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateContactsReset{}}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) +} + +func (r *Router) recordContactsReset(ctx context.Context, userID int64) error { + if r.deps.Updates == nil || userID == 0 { + return nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + _, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, sessionID) + return err +} + +func (r *Router) recordPeerSettings(ctx context.Context, userID int64, peer domain.Peer, settings domain.PeerSettings) error { + if r.deps.Updates == nil || userID == 0 { + return nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + _, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, sessionID) + return err +} + +type reliableUpdateDispatchReporter interface { + UsesReliableDispatch() bool +} + +func (r *Router) hasReliableUpdateDispatch() bool { + reporter, ok := r.deps.Updates.(reliableUpdateDispatchReporter) + return ok && reporter.UsesReliableDispatch() +} + +func (r *Router) pushUserUpdatesIfNoReliableDispatch(ctx context.Context, userID int64, updates *tg.Updates) { + if r.hasReliableUpdateDispatch() { + return + } + r.pushUserUpdates(ctx, userID, updates) +} + +func (r *Router) pushUserUpdates(ctx context.Context, userID int64, updates *tg.Updates) int { + return r.pushUserMessage(ctx, userID, "push user updates", updates) +} + +func tgPeerSettings(settings domain.PeerSettings) tg.PeerSettings { + if settings.HiddenPeerSettingsBar { + return tg.PeerSettings{} + } + return tg.PeerSettings{ + AddContact: settings.AddContact, + BlockContact: settings.BlockContact, + ShareContact: settings.ShareContact, + NeedContactsException: settings.NeedContactsException, + } +} + +func contactErr(err error) error { + switch { + case errors.Is(err, contacts.ErrContactNameEmpty): + return contactNameEmptyErr() + case errors.Is(err, contacts.ErrContactIDInvalid): + return contactIDInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/context.go b/internal/rpc/context.go new file mode 100644 index 00000000..e140eb33 --- /dev/null +++ b/internal/rpc/context.go @@ -0,0 +1,96 @@ +package rpc + +import "context" + +type ctxKey int + +const ( + layerKey ctxKey = iota + clientInfoKey + rawAuthKeyIDKey + authKeyIDKey + sessionIDKey + userIDKey +) + +// ClientInfo 是 initConnection 携带的客户端信息。 +type ClientInfo struct { + APIID int + DeviceModel string + SystemVersion string + AppVersion string + SystemLangCode string + LangPack string + LangCode string +} + +// WithLayer 在 ctx 注入客户端 layer(来自 invokeWithLayer)。 +func WithLayer(ctx context.Context, layer int) context.Context { + return context.WithValue(ctx, layerKey, layer) +} + +// LayerFrom 返回 ctx 中的客户端 layer,未设置时为 0。 +func LayerFrom(ctx context.Context) int { + if v, ok := ctx.Value(layerKey).(int); ok { + return v + } + return 0 +} + +// WithClientInfo 在 ctx 注入客户端信息(来自 initConnection)。 +func WithClientInfo(ctx context.Context, info ClientInfo) context.Context { + return context.WithValue(ctx, clientInfoKey, info) +} + +// ClientInfoFrom 返回 ctx 中的客户端信息。 +func ClientInfoFrom(ctx context.Context) (ClientInfo, bool) { + v, ok := ctx.Value(clientInfoKey).(ClientInfo) + return v, ok +} + +// WithRawAuthKeyID 在 ctx 注入连接实际使用的 auth_key_id。 +func WithRawAuthKeyID(ctx context.Context, id [8]byte) context.Context { + return context.WithValue(ctx, rawAuthKeyIDKey, id) +} + +// RawAuthKeyIDFrom 返回连接实际使用的 auth_key_id。 +func RawAuthKeyIDFrom(ctx context.Context) ([8]byte, bool) { + v, ok := ctx.Value(rawAuthKeyIDKey).([8]byte) + return v, ok +} + +// WithAuthKeyID 在 ctx 注入业务视角的 auth_key_id;temp auth_key 绑定后会解析为 perm auth_key。 +func WithAuthKeyID(ctx context.Context, id [8]byte) context.Context { + return context.WithValue(ctx, authKeyIDKey, id) +} + +// AuthKeyIDFrom 返回 ctx 中业务视角的 auth_key_id。已握手连接均有(即便尚未登录)。 +func AuthKeyIDFrom(ctx context.Context) ([8]byte, bool) { + v, ok := ctx.Value(authKeyIDKey).([8]byte) + return v, ok +} + +// WithSessionID 在 ctx 注入调用方的 MTProto session_id。 +func WithSessionID(ctx context.Context, id int64) context.Context { + return context.WithValue(ctx, sessionIDKey, id) +} + +// SessionIDFrom 返回 ctx 中调用方的 MTProto session_id。 +func SessionIDFrom(ctx context.Context) (int64, bool) { + v, ok := ctx.Value(sessionIDKey).(int64) + return v, ok +} + +// WithUserID 在 ctx 注入当前已登录用户 id。 +func WithUserID(ctx context.Context, id int64) context.Context { + return context.WithValue(ctx, userIDKey, id) +} + +// UserIDFrom 返回 ctx 中当前已登录用户 id。 +func UserIDFrom(ctx context.Context) (int64, bool) { + v, ok := ctx.Value(userIDKey).(int64) + if !ok || v == 0 { + return 0, false + } + return v, true +} diff --git a/internal/rpc/convert.go b/internal/rpc/convert.go new file mode 100644 index 00000000..01bc6211 --- /dev/null +++ b/internal/rpc/convert.go @@ -0,0 +1,1919 @@ +package rpc + +import ( + "context" + "encoding/json" + "sort" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// authzFromCtx 从连接上下文组装一条待绑定的设备授权(UserID 由业务层填充)。 +func (r *Router) authzFromCtx(ctx context.Context) domain.Authorization { + id, _ := AuthKeyIDFrom(ctx) + a := domain.Authorization{AuthKeyID: id, Layer: LayerFrom(ctx)} + if ci, ok := ClientInfoFrom(ctx); ok { + a.DeviceModel = ci.DeviceModel + a.SystemVersion = ci.SystemVersion + a.AppVersion = ci.AppVersion + a.APIID = ci.APIID + } + return a +} + +// currentUserID 返回当前连接已登录的 user_id。 +// +// 优先使用 active session 缓存;若新连接尚未绑定但 auth_key 已授权,则只在这里 +// 查询一次授权表并回填 session,避免各业务 service 每个 RPC 重复 authKey→userID。 +func (r *Router) currentUserID(ctx context.Context) (int64, bool, error) { + if userID, ok := UserIDFrom(ctx); ok { + return userID, true, nil + } + if r.deps.Sessions != nil { + if sessionID, ok := SessionIDFrom(ctx); ok { + if scoped, ok := r.scopedSessions(); ok { + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + if userID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved { + return userID, userID != 0, nil + } + } + } else if userID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved { + return userID, userID != 0, nil + } + } + } + if r.deps.Auth == nil { + return 0, false, nil + } + authKeyID, ok := AuthKeyIDFrom(ctx) + if !ok { + return 0, false, nil + } + userID, found, err := r.deps.Auth.UserID(ctx, authKeyID) + if err != nil || !found { + if err == nil && !found { + r.bindSessionUser(ctx, 0) + } + return 0, found, err + } + r.bindSessionUser(ctx, userID) + return userID, true, nil +} + +// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。 +func tgSelfUser(u domain.User) *tg.User { + out := &tg.User{ + ID: u.ID, + AccessHash: u.AccessHash, + FirstName: u.FirstName, + LastName: u.LastName, + Username: u.Username, + Phone: u.Phone, + Self: true, + Verified: u.Verified, + Support: u.Support, + Contact: u.Contact, + MutualContact: u.Mutual, + Usernames: tgUsernames(u.Username), + Status: tgUserStatus(u.Status), + } + if photo := tgUserProfilePhoto(u); photo != nil { + out.Photo = photo + } + return out +} + +func tgUser(u domain.User) *tg.User { + out := &tg.User{ + ID: u.ID, + AccessHash: u.AccessHash, + FirstName: u.FirstName, + LastName: u.LastName, + Username: u.Username, + Phone: u.Phone, + Verified: u.Verified, + Support: u.Support, + Contact: u.Contact, + MutualContact: u.Mutual, + Usernames: tgUsernames(u.Username), + Status: tgUserStatus(u.Status), + } + if photo := tgUserProfilePhoto(u); photo != nil { + out.Photo = photo + } + return out +} + +// tgUserProfilePhoto 由 domain.User 反范式头像字段构造 UserProfilePhoto;无头像返回 nil(Encode 时为 empty)。 +func tgUserProfilePhoto(u domain.User) tg.UserProfilePhotoClass { + if u.PhotoID == 0 { + return nil + } + photo := &tg.UserProfilePhoto{PhotoID: u.PhotoID, DCID: u.PhotoDCID} + if len(u.PhotoStripped) > 0 { + photo.SetStrippedThumb(u.PhotoStripped) + } + return photo +} + +func tgUserStatus(status domain.UserStatus) tg.UserStatusClass { + switch status.Kind { + case domain.UserStatusOnline: + if status.Expires > 0 { + return &tg.UserStatusOnline{Expires: status.Expires} + } + case domain.UserStatusOffline: + if status.WasOnline > 0 { + return &tg.UserStatusOffline{WasOnline: status.WasOnline} + } + case domain.UserStatusLastWeek: + return &tg.UserStatusLastWeek{} + case domain.UserStatusLastMonth: + return &tg.UserStatusLastMonth{} + case domain.UserStatusEmpty: + return &tg.UserStatusEmpty{} + case domain.UserStatusRecently, domain.UserStatusUnknown: + return &tg.UserStatusRecently{} + } + return &tg.UserStatusRecently{} +} + +func tgUsernames(username string) []tg.Username { + if username == "" { + return nil + } + return []tg.Username{{Editable: true, Active: true, Username: username}} +} + +func tgUpdateState(st domain.UpdateState) tg.UpdatesState { + return tg.UpdatesState{Pts: st.Pts, Qts: st.Qts, Date: st.Date, Seq: st.Seq} +} + +func tgContacts(list domain.ContactList) tg.ContactsContactsClass { + out := &tg.ContactsContacts{ + Contacts: make([]tg.Contact, 0, len(list.Contacts)), + Users: make([]tg.UserClass, 0, len(list.Contacts)), + SavedCount: len(list.Contacts), + } + for _, c := range list.Contacts { + out.Contacts = append(out.Contacts, tg.Contact{UserID: c.User.ID, Mutual: c.Mutual}) + out.Users = append(out.Users, tgUser(c.User)) + } + return out +} + +func tgContactsFound(viewerUserID int64, res domain.UserSearchResult) *tg.ContactsFound { + out := &tg.ContactsFound{ + MyResults: make([]tg.PeerClass, 0, len(res.MyResults)+len(res.MyChannelResults)), + Results: make([]tg.PeerClass, 0, len(res.Results)+len(res.ChannelResults)), + Chats: make([]tg.ChatClass, 0, len(res.MyChannelResults)+len(res.ChannelResults)), + Users: make([]tg.UserClass, 0, len(res.MyResults)+len(res.Results)), + } + seen := make(map[int64]struct{}, len(res.MyResults)+len(res.Results)) + appendUser := func(u domain.User) { + if u.ID == 0 { + return + } + if _, ok := seen[u.ID]; ok { + return + } + seen[u.ID] = struct{}{} + out.Users = append(out.Users, tgUser(u)) + } + seenChannels := make(map[int64]struct{}, len(res.MyChannelResults)+len(res.ChannelResults)) + appendChannel := func(ch domain.Channel, self *domain.ChannelMember) { + if ch.ID == 0 { + return + } + if _, ok := seenChannels[ch.ID]; ok { + return + } + seenChannels[ch.ID] = struct{}{} + out.Chats = append(out.Chats, tgChannelChat(viewerUserID, ch, self)) + } + for _, u := range res.MyResults { + out.MyResults = append(out.MyResults, &tg.PeerUser{UserID: u.ID}) + appendUser(u) + } + for _, ch := range res.MyChannelResults { + out.MyResults = append(out.MyResults, &tg.PeerChannel{ChannelID: ch.ID}) + appendChannel(ch, nil) + } + for _, u := range res.Results { + out.Results = append(out.Results, &tg.PeerUser{UserID: u.ID}) + appendUser(u) + } + for _, ch := range res.ChannelResults { + out.Results = append(out.Results, &tg.PeerChannel{ChannelID: ch.ID}) + appendChannel(ch, &domain.ChannelMember{ChannelID: ch.ID, UserID: viewerUserID, Status: domain.ChannelMemberLeft}) + } + return out +} + +func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDialogsClass { + dialogs := make([]tg.DialogClass, 0, len(list.Dialogs)) + for _, d := range list.Dialogs { + if dialog := tgDialog(d); dialog != nil { + dialogs = append(dialogs, dialog) + } + } + messages := make([]tg.MessageClass, 0, len(list.Messages)) + for _, msg := range list.Messages { + if item := tgMessage(msg); item != nil { + messages = append(messages, item) + } + } + for _, msg := range list.ChannelMessages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + users := tgUsers(list.Users) + chats := tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs) + if list.Count > len(dialogs) { + return &tg.MessagesDialogsSlice{ + Count: list.Count, + Dialogs: dialogs, + Messages: messages, + Chats: chats, + Users: users, + } + } + return &tg.MessagesDialogs{ + Dialogs: dialogs, + Messages: messages, + Chats: chats, + Users: users, + } +} + +func tgMessagesMessages(list domain.MessageList) tg.MessagesMessagesClass { + messages := make([]tg.MessageClass, 0, len(list.Messages)) + for _, msg := range list.Messages { + if item := tgMessage(msg); item != nil { + messages = append(messages, item) + } + } + users := tgUsers(list.Users) + if list.Count > len(messages) { + return &tg.MessagesMessagesSlice{ + Count: list.Count, + Messages: messages, + Users: users, + } + } + return &tg.MessagesMessages{ + Messages: messages, + Users: users, + } +} + +func tgUsers(users []domain.User) []tg.UserClass { + out := make([]tg.UserClass, 0, len(users)) + for _, u := range users { + out = append(out, tgUser(u)) + } + return out +} + +func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateState) *tg.MessagesPeerDialogs { + out := &tg.MessagesPeerDialogs{ + Dialogs: make([]tg.DialogClass, 0, len(list.Dialogs)), + Messages: make([]tg.MessageClass, 0, len(list.Messages)+len(list.ChannelMessages)), + Users: make([]tg.UserClass, 0, len(list.Users)), + Chats: make([]tg.ChatClass, 0, len(list.Channels)), + State: tgUpdateState(st), + } + for _, d := range list.Dialogs { + if dialog := tgDialog(d); dialog != nil { + out.Dialogs = append(out.Dialogs, dialog) + } + } + for _, msg := range list.Messages { + if item := tgMessage(msg); item != nil { + out.Messages = append(out.Messages, item) + } + } + for _, msg := range list.ChannelMessages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + out.Messages = append(out.Messages, item) + } + } + for _, u := range list.Users { + out.Users = append(out.Users, tgUser(u)) + } + out.Chats = append(out.Chats, tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)...) + return out +} + +func tgUpdatesDifference(diff domain.UpdateDifference) tg.UpdatesDifferenceClass { + out := &tg.UpdatesDifference{ + NewMessages: make([]tg.MessageClass, 0, len(diff.Events)), + OtherUpdates: make([]tg.UpdateClass, 0, len(diff.Events)), + Users: make([]tg.UserClass, 0, 1), + Chats: make([]tg.ChatClass, 0, 1), + } + seenUsers := make(map[int64]struct{}) + seenChats := make(map[int64]struct{}) + for _, event := range diff.Events { + addUsers(out, seenUsers, event.Users) + addChannels(out, seenChats, event.UserID, event.Channels) + switch event.Type { + case domain.UpdateEventNewMessage: + if msg := tgMessage(event.Message); msg != nil { + out.NewMessages = append(out.NewMessages, msg) + addMessageUsers(out, seenUsers, event.Message) + } + case domain.UpdateEventReadHistoryInbox: + if update := tgReadHistoryInboxUpdate(event); update != nil { + out.OtherUpdates = append(out.OtherUpdates, update) + } + case domain.UpdateEventReadHistoryOutbox: + if update := tgReadHistoryOutboxUpdate(event); update != nil { + out.OtherUpdates = append(out.OtherUpdates, update) + } + case domain.UpdateEventMessageReactions: + if msg := tgMessage(event.Message); msg != nil { + out.NewMessages = append(out.NewMessages, msg) + addMessageUsers(out, seenUsers, event.Message) + } + if update := tgOtherUpdateFromEvent(event); update != nil { + out.OtherUpdates = append(out.OtherUpdates, update) + } + default: + if update := tgOtherUpdateFromEvent(event); update != nil { + out.OtherUpdates = append(out.OtherUpdates, update) + } + } + } + // Partial:连续事件被 limit 截断、后面还有 → updates.differenceSlice,客户端据 IntermediateState 续拉。 + if diff.Partial { + return &tg.UpdatesDifferenceSlice{ + NewMessages: out.NewMessages, + OtherUpdates: out.OtherUpdates, + Chats: out.Chats, + Users: out.Users, + IntermediateState: tgUpdateState(diff.State), + } + } + out.State = tgUpdateState(diff.State) + return out +} + +func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.UpdatesChannelDifferenceClass { + if diff.TooLong { + messages := make([]tg.MessageClass, 0, len(diff.NewMessages)) + for _, msg := range diff.NewMessages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + dialog := tgDialog(domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: diff.Channel.ID}, + TopMessage: diff.Dialog.TopMessageID, + TopMessageDate: diff.Dialog.TopMessageDate, + ReadInboxMaxID: diff.Dialog.ReadInboxMaxID, + ReadOutboxMaxID: diff.Dialog.ReadOutboxMaxID, + UnreadCount: diff.Dialog.UnreadCount, + UnreadMark: diff.Dialog.UnreadMark, + FolderID: diff.Dialog.FolderID, + }) + if dialog != nil { + dialog.SetPts(diff.Pts) + } + return &tg.UpdatesChannelDifferenceTooLong{ + Final: diff.Final, + Timeout: diff.Timeout, + Dialog: dialog, + Messages: messages, + Chats: tgChannelDifferenceChats(viewerUserID, diff), + Users: tgUsers(diff.Users), + } + } + if len(diff.Events) == 0 && len(diff.NewMessages) == 0 && len(diff.OtherUpdates) == 0 { + return &tg.UpdatesChannelDifferenceEmpty{ + Final: diff.Final, + Pts: diff.Pts, + Timeout: diff.Timeout, + } + } + messages := make([]tg.MessageClass, 0, len(diff.NewMessages)) + for _, msg := range diff.NewMessages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + updates := make([]tg.UpdateClass, 0, len(diff.OtherUpdates)) + for _, event := range diff.OtherUpdates { + if update := tgChannelUpdate(viewerUserID, event); update != nil { + updates = append(updates, update) + } + } + chats := tgChannelDifferenceChats(viewerUserID, diff) + users := tgUsers(diff.Users) + return &tg.UpdatesChannelDifference{ + Final: diff.Final, + Pts: diff.Pts, + Timeout: diff.Timeout, + NewMessages: messages, + OtherUpdates: updates, + Chats: chats, + Users: users, + } +} + +func tgChannelDifferenceChats(viewerUserID int64, diff domain.ChannelDifference) []tg.ChatClass { + return tgChannelChatsWithPrimarySelf(viewerUserID, diff.Channel, diff.Channels, diff.Self) +} + +func tgChannelChatsWithPrimarySelf(viewerUserID int64, primary domain.Channel, extras []domain.Channel, primarySelf domain.ChannelMember) []tg.ChatClass { + channels := make([]domain.Channel, 0, len(extras)+1) + channels = append(channels, primary) + channels = append(channels, extras...) + out := make([]tg.ChatClass, 0, len(channels)) + seen := make(map[int64]struct{}, len(channels)) + for _, ch := range channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + var self *domain.ChannelMember + if ch.ID == primary.ID && primarySelf.ChannelID == ch.ID && primarySelf.UserID == viewerUserID { + self = &primarySelf + } + out = append(out, tgChannelChat(viewerUserID, ch, self)) + } + return out +} + +func tgChannelUpdate(viewerUserID int64, event domain.ChannelUpdateEvent) tg.UpdateClass { + switch event.Type { + case domain.ChannelUpdateNewMessage: + msg := tgChannelMessage(viewerUserID, event.Message) + if msg == nil { + return nil + } + return &tg.UpdateNewChannelMessage{Message: msg, Pts: event.Pts, PtsCount: event.PtsCount} + case domain.ChannelUpdateEditMessage: + msg := tgChannelMessage(viewerUserID, event.Message) + if msg == nil { + return nil + } + return &tg.UpdateEditChannelMessage{Message: msg, Pts: event.Pts, PtsCount: event.PtsCount} + case domain.ChannelUpdateDeleteMessages: + if len(event.MessageIDs) == 0 { + return nil + } + return &tg.UpdateDeleteChannelMessages{ + ChannelID: event.ChannelID, + Messages: append([]int(nil), event.MessageIDs...), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.ChannelUpdatePinnedMessages: + if len(event.MessageIDs) == 0 { + return nil + } + return &tg.UpdatePinnedChannelMessages{ + Pinned: event.Pinned, + ChannelID: event.ChannelID, + Messages: append([]int(nil), event.MessageIDs...), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.ChannelUpdateParticipant: + userID := event.Participant.UserID + if userID == 0 { + userID = event.Previous.UserID + } + if userID == 0 { + return nil + } + actorID := event.SenderUserID + if actorID == 0 { + actorID = userID + } + update := &tg.UpdateChannelParticipant{ + ChannelID: event.ChannelID, + Date: event.Date, + ActorID: actorID, + UserID: userID, + } + if event.Previous.UserID != 0 { + update.SetPrevParticipant(tgChannelParticipantForUpdate(viewerUserID, event.Previous)) + } + if event.Participant.UserID != 0 { + update.SetNewParticipant(tgChannelParticipantForUpdate(viewerUserID, event.Participant)) + } + return update + default: + return nil + } +} + +func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass { + switch event.Type { + case domain.UpdateEventContactsReset: + return &tg.UpdateContactsReset{} + case domain.UpdateEventDialogPinned: + peer := tgDialogPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdateDialogPinned{Pinned: event.Bool, Peer: peer} + case domain.UpdateEventPinnedDialogs: + update := &tg.UpdatePinnedDialogs{} + if len(event.Peers) > 0 { + update.Order = tgDialogPeers(event.Peers) + } + return update + case domain.UpdateEventDialogUnreadMark: + peer := tgDialogPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdateDialogUnreadMark{Unread: event.Bool, Peer: peer} + case domain.UpdateEventChannelViewForum: + if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { + return nil + } + return &tg.UpdateChannelViewForumAsMessages{ChannelID: event.Peer.ID, Enabled: event.Bool} + case domain.UpdateEventPeerSettings: + peer := tgPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdatePeerSettings{Peer: peer, Settings: tgPeerSettings(event.Settings)} + case domain.UpdateEventDeleteMessages: + if len(event.MessageIDs) == 0 { + return nil + } + return &tg.UpdateDeleteMessages{ + Messages: append([]int(nil), event.MessageIDs...), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.UpdateEventReadHistoryOutbox: + return tgReadHistoryOutbox(event) + case domain.UpdateEventEditMessage: + msg := tgMessage(event.Message) + if msg == nil { + return nil + } + return &tg.UpdateEditMessage{ + Message: msg, + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.UpdateEventMessageReactions: + if event.Message.ID <= 0 || event.Message.ID > domain.MaxMessageBoxID { + return nil + } + peer := event.Message.Peer + if peer.Type == "" || peer.ID == 0 { + peer = event.Peer + } + outPeer := tgPeer(peer) + if outPeer == nil { + return nil + } + reactions := event.Message.Reactions + if reactions == nil { + empty := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} + reactions = &empty + } + converted := tgMessageReactions(event.UserID, reactions) + if converted == nil { + converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} + } + return &tg.UpdateMessageReactions{ + Peer: outPeer, + MsgID: event.Message.ID, + Reactions: *converted, + } + case domain.UpdateEventDialogFilter: + update := &tg.UpdateDialogFilter{ID: event.FilterID} + if event.DialogFilter != nil { + update.SetFilter(tgDialogFilter(*event.DialogFilter)) + } + return update + case domain.UpdateEventDialogFilterOrder: + return &tg.UpdateDialogFilterOrder{Order: append([]int(nil), event.FilterOrder...)} + case domain.UpdateEventDialogFilters: + return &tg.UpdateDialogFilters{} + case domain.UpdateEventFolderPeers: + if len(event.FolderPeers) == 0 { + return nil + } + return &tg.UpdateFolderPeers{ + FolderPeers: tgFolderPeers(event.FolderPeers), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.UpdateEventChannelAvailable: + if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 || event.MaxID <= 0 { + return nil + } + return &tg.UpdateChannelAvailableMessages{ + ChannelID: event.Peer.ID, + AvailableMinID: event.MaxID, + } + default: + return nil + } +} + +func addUsers(out *tg.UpdatesDifference, seen map[int64]struct{}, users []domain.User) { + for _, u := range users { + if u.ID == 0 { + continue + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + out.Users = append(out.Users, tgUser(u)) + } +} + +func addChannels(out *tg.UpdatesDifference, seen map[int64]struct{}, viewerUserID int64, channels []domain.Channel) { + for _, ch := range channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + out.Chats = append(out.Chats, tgChannelChat(viewerUserID, ch, nil)) + } +} + +func addMessageUsers(out *tg.UpdatesDifference, seen map[int64]struct{}, msg domain.Message) { + for _, peer := range []domain.Peer{msg.From, msg.Peer} { + if peer.Type != domain.PeerTypeUser { + continue + } + if _, ok := seen[peer.ID]; ok { + continue + } + seen[peer.ID] = struct{}{} + switch peer.ID { + case domain.OfficialSystemUserID: + out.Users = append(out.Users, tgUser(domain.OfficialSystemUser())) + } + } +} + +func tgDialog(d domain.Dialog) *tg.Dialog { + peer := tgPeer(d.Peer) + if peer == nil { + return nil + } + out := &tg.Dialog{ + Pinned: d.Pinned, + UnreadMark: d.UnreadMark, + ViewForumAsMessages: d.ViewForumAsMessages, + Peer: peer, + TopMessage: d.TopMessage, + ReadInboxMaxID: d.ReadInboxMaxID, + ReadOutboxMaxID: d.ReadOutboxMaxID, + UnreadCount: d.UnreadCount, + UnreadMentionsCount: d.UnreadMentions, + UnreadReactionsCount: d.UnreadReactions, + NotifySettings: *tdesktop.NotifySettings(), + } + if d.FolderID != domain.DialogMainFolderID { + out.SetFolderID(d.FolderID) + } + if d.Draft != nil { + out.SetDraft(tgDialogDraft(*d.Draft)) + } + return out +} + +func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass { + if d.Empty() { + out := &tg.DraftMessageEmpty{} + out.SetDate(d.Date) + return out + } + out := &tg.DraftMessage{ + NoWebpage: d.NoWebpage, + InvertMedia: d.InvertMedia, + ReplyTo: tgDraftReplyTo(d), + Message: d.Message, + Entities: tgMessageEntities(d.Entities), + Media: tgDraftWebPage(d.WebPage), + Date: d.Date, + Effect: d.Effect, + } + return out +} + +func tgDraftReplyTo(d domain.DialogDraft) tg.InputReplyToClass { + if d.ReplyTo == nil { + return nil + } + reply := &tg.InputReplyToMessage{ReplyToMsgID: d.ReplyTo.MessageID} + if d.ReplyTo.TopMessageID > 0 { + reply.SetTopMsgID(d.ReplyTo.TopMessageID) + } + if d.ReplyTo.QuoteText != "" { + reply.SetQuoteText(d.ReplyTo.QuoteText) + } + if len(d.ReplyTo.QuoteEntities) > 0 { + reply.SetQuoteEntities(tgMessageEntities(d.ReplyTo.QuoteEntities)) + } + if d.ReplyTo.QuoteOffset > 0 { + reply.SetQuoteOffset(d.ReplyTo.QuoteOffset) + } + return reply +} + +func tgDraftWebPage(webpage *domain.DialogDraftWebPage) tg.InputMediaClass { + if webpage == nil || webpage.URL == "" { + return nil + } + return &tg.InputMediaWebPage{ + ForceLargeMedia: webpage.ForceLargeMedia, + ForceSmallMedia: webpage.ForceSmallMedia, + Optional: webpage.Optional, + URL: webpage.URL, + } +} + +func tgDialogPeer(p domain.Peer) tg.DialogPeerClass { + peer := tgPeer(p) + if peer == nil { + return nil + } + return &tg.DialogPeer{Peer: peer} +} + +func tgDialogPeers(peers []domain.Peer) []tg.DialogPeerClass { + out := make([]tg.DialogPeerClass, 0, len(peers)) + for _, peer := range peers { + item := tgDialogPeer(peer) + if item != nil { + out = append(out, item) + } + } + return out +} + +func tgDialogFilters(list domain.DialogFolderList) *tg.MessagesDialogFilters { + filters := make([]tg.DialogFilterClass, 0, len(list.Folders)+1) + filters = append(filters, &tg.DialogFilterDefault{}) + for _, folder := range list.Folders { + if item := tgDialogFilter(folder); item != nil { + filters = append(filters, item) + } + } + return &tg.MessagesDialogFilters{TagsEnabled: list.TagsEnabled, Filters: filters} +} + +func tgDialogFilter(folder domain.DialogFolder) tg.DialogFilterClass { + title := tg.TextWithEntities{Text: folder.Title, Entities: tgMessageEntities(folder.TitleEntities)} + if folder.IsChatlist { + out := &tg.DialogFilterChatlist{ + TitleNoanimate: folder.TitleNoanimate, + ID: folder.ID, + Title: title, + PinnedPeers: tgDialogFolderInputPeers(folder.PinnedPeers), + IncludePeers: tgDialogFolderInputPeers(folder.IncludePeers), + } + if folder.HasEmoticon { + out.SetEmoticon(folder.Emoticon) + } + if folder.HasColor { + out.SetColor(folder.Color) + } + return out + } + out := &tg.DialogFilter{ + Contacts: folder.Contacts, + NonContacts: folder.NonContacts, + Groups: folder.Groups, + Broadcasts: folder.Broadcasts, + Bots: folder.Bots, + ExcludeMuted: folder.ExcludeMuted, + ExcludeRead: folder.ExcludeRead, + ExcludeArchived: folder.ExcludeArchived, + TitleNoanimate: folder.TitleNoanimate, + ID: folder.ID, + Title: title, + PinnedPeers: tgDialogFolderInputPeers(folder.PinnedPeers), + IncludePeers: tgDialogFolderInputPeers(folder.IncludePeers), + ExcludePeers: tgDialogFolderInputPeers(folder.ExcludePeers), + } + if folder.HasEmoticon { + out.SetEmoticon(folder.Emoticon) + } + if folder.HasColor { + out.SetColor(folder.Color) + } + return out +} + +func tgDialogFolderInputPeers(peers []domain.DialogFolderPeer) []tg.InputPeerClass { + if len(peers) == 0 { + return nil + } + out := make([]tg.InputPeerClass, 0, len(peers)) + for _, item := range peers { + switch item.Peer.Type { + case domain.PeerTypeUser: + out = append(out, &tg.InputPeerUser{UserID: item.Peer.ID, AccessHash: item.AccessHash}) + case domain.PeerTypeChannel: + out = append(out, &tg.InputPeerChannel{ChannelID: item.Peer.ID, AccessHash: item.AccessHash}) + } + } + return out +} + +func tgFolderPeers(peers []domain.FolderPeerUpdate) []tg.FolderPeer { + out := make([]tg.FolderPeer, 0, len(peers)) + for _, item := range peers { + peer := tgPeer(item.Peer) + if peer == nil { + continue + } + out = append(out, tg.FolderPeer{Peer: peer, FolderID: item.FolderID}) + } + return out +} + +func tgMessage(m domain.Message) tg.MessageClass { + peer := tgPeer(m.Peer) + if peer == nil || m.ID == 0 { + return nil + } + msg := &tg.Message{ + Out: m.Out, + ID: m.ID, + PeerID: peer, + Date: m.Date, + Message: m.Body, + Entities: tgMessageEntities(m.Entities), + } + if m.EditDate != 0 { + msg.SetEditDate(m.EditDate) + } + if m.Silent { + msg.SetSilent(true) + } + if m.NoForwards { + msg.SetNoforwards(true) + } + if reply := tgMessageReplyHeader(m); reply != nil { + msg.SetReplyTo(reply) + } + if fwd := tgMessageFwdHeader(m.Forward); fwd != nil { + msg.SetFwdFrom(*fwd) + } + if from := tgPeer(m.From); from != nil { + msg.FromID = from + } + if !m.Media.IsZero() { + msg.SetMedia(tgMessageMedia(m.Media)) + } + if reactions := tgMessageReactions(m.OwnerUserID, m.Reactions); reactions != nil { + msg.SetReactions(*reactions) + } + return msg +} + +func tgChannelHistoryMessages(viewerUserID int64, list domain.ChannelHistory) tg.MessagesMessagesClass { + messages := make([]tg.MessageClass, 0, len(list.Messages)) + for _, msg := range list.Messages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + topics := make([]tg.ForumTopicClass, 0, len(list.Topics)) + for _, topic := range list.Topics { + item := tgForumTopicFromDomain(viewerUserID, topic) + item.SetShort(true) + topics = append(topics, item) + } + users := tgUsers(list.Users) + chats := tgChannelChatsWithPrimarySelf(viewerUserID, list.Channel, list.Channels, list.Self) + if list.Channel.ID != 0 { + return &tg.MessagesChannelMessages{ + Pts: list.Channel.Pts, + Count: list.Count, + Messages: messages, + Topics: topics, + Chats: chats, + Users: users, + } + } + if list.Count > len(messages) { + return &tg.MessagesMessagesSlice{ + Count: list.Count, + Messages: messages, + Topics: topics, + Chats: chats, + Users: users, + } + } + return &tg.MessagesMessages{Messages: messages, Topics: topics, Chats: chats, Users: users} +} + +func tgMessagesDiscussionMessage(viewerUserID int64, in domain.ChannelDiscussionMessage) *tg.MessagesDiscussionMessage { + messages := make([]tg.MessageClass, 0, len(in.Messages)) + for _, msg := range in.Messages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + channels := make([]domain.Channel, 0, 2+len(in.Channels)) + if in.PostChannel.ID != 0 { + channels = append(channels, in.PostChannel) + } + if in.DiscussionChannel.ID != 0 && in.DiscussionChannel.ID != in.PostChannel.ID { + channels = append(channels, in.DiscussionChannel) + } + channels = append(channels, in.Channels...) + out := &tg.MessagesDiscussionMessage{ + Messages: messages, + Chats: tgChannels(viewerUserID, channels), + Users: tgUsers(in.Users), + UnreadCount: in.UnreadCount, + } + if in.MaxID > 0 { + out.SetMaxID(in.MaxID) + } + if in.ReadInboxMaxID > 0 { + out.SetReadInboxMaxID(in.ReadInboxMaxID) + } + if in.ReadOutboxMaxID > 0 { + out.SetReadOutboxMaxID(in.ReadOutboxMaxID) + } + return out +} + +func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageClass { + if m.ID == 0 || m.ChannelID == 0 { + return nil + } + peer := &tg.PeerChannel{ChannelID: m.ChannelID} + outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && m.From.Type != domain.PeerTypeChannel + from := tg.PeerClass(nil) + if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 { + from = tgPeer(*m.SendAs) + } + if !m.Post && from == nil && m.From.ID != 0 { + from = tgPeer(m.From) + } + if from == nil && m.SenderUserID != 0 && !m.Post { + from = &tg.PeerUser{UserID: m.SenderUserID} + } + if m.Action != nil { + msg := &tg.MessageService{ + Out: outgoing, + Silent: m.Silent, + Post: m.Post, + ID: m.ID, + FromID: from, + PeerID: peer, + Date: m.Date, + Action: tgChannelMessageAction(*m.Action), + } + if msg.Action == nil { + msg.Action = &tg.MessageActionEmpty{} + } + return msg + } + msg := &tg.Message{ + Out: outgoing, + Silent: m.Silent, + Post: m.Post, + Noforwards: m.NoForwards, + ID: m.ID, + FromID: from, + PeerID: peer, + Date: m.Date, + Message: m.Body, + Entities: tgMessageEntities(m.Entities), + } + if m.EditDate != 0 { + msg.SetEditDate(m.EditDate) + } + if reply := tgMessageReplyHeader(domain.Message{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID}, + ReplyTo: m.ReplyTo, + }); reply != nil { + msg.SetReplyTo(reply) + } + if fwd := tgMessageFwdHeader(m.Forward); fwd != nil { + msg.SetFwdFrom(*fwd) + } + if replies := tgChannelMessageReplies(m.Replies); replies != nil { + msg.SetReplies(*replies) + } + if reactions := tgMessageReactions(viewerUserID, m.Reactions); reactions != nil { + msg.SetReactions(*reactions) + } + if !m.Media.IsZero() { + msg.SetMedia(tgMessageMedia(m.Media)) + } + return msg +} + +func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageActionClass { + switch action.Type { + case domain.ChannelActionCreate: + return &tg.MessageActionChannelCreate{Title: action.Title} + case domain.ChannelActionChatAddUser, domain.ChannelActionChatJoined: + return &tg.MessageActionChatAddUser{Users: append([]int64(nil), action.UserIDs...)} + case domain.ChannelActionChatDelete: + var userID int64 + if len(action.UserIDs) > 0 { + userID = action.UserIDs[0] + } + return &tg.MessageActionChatDeleteUser{UserID: userID} + case domain.ChannelActionEditTitle: + return &tg.MessageActionChatEditTitle{Title: action.Title} + case domain.ChannelActionTopicCreate: + return &tg.MessageActionTopicCreate{ + TitleMissing: action.TitleMissing, + Title: action.Title, + IconColor: action.IconColor, + IconEmojiID: action.IconEmojiID, + } + case domain.ChannelActionTopicEdit: + out := &tg.MessageActionTopicEdit{} + if action.Title != "" { + out.SetTitle(action.Title) + } + if action.IconEmojiIDSet { + out.SetIconEmojiID(action.IconEmojiID) + } + if action.Closed != nil { + out.SetClosed(*action.Closed) + } + if action.Hidden != nil { + out.SetHidden(*action.Hidden) + } + return out + default: + return nil + } +} + +func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass { + if m.ReplyTo == nil || (m.ReplyTo.MessageID <= 0 && m.ReplyTo.TopMessageID <= 0) { + return nil + } + header := &tg.MessageReplyHeader{} + if m.ReplyTo.ForumTopic { + header.SetForumTopic(true) + } + if m.ReplyTo.MessageID > 0 { + header.SetReplyToMsgID(m.ReplyTo.MessageID) + } + if m.ReplyTo.TopMessageID > 0 { + header.SetReplyToTopID(m.ReplyTo.TopMessageID) + } + if m.ReplyTo.Peer.ID != 0 && m.ReplyTo.Peer != m.Peer { + if peer := tgPeer(m.ReplyTo.Peer); peer != nil { + header.SetReplyToPeerID(peer) + } + } + if m.ReplyTo.QuoteText != "" { + header.SetQuote(true) + header.SetQuoteText(m.ReplyTo.QuoteText) + header.SetQuoteEntities(tgMessageEntities(m.ReplyTo.QuoteEntities)) + header.SetQuoteOffset(m.ReplyTo.QuoteOffset) + } + return header +} + +func tgMessageFwdHeader(fwd *domain.MessageForward) *tg.MessageFwdHeader { + if fwd == nil || (fwd.Date == 0 && fwd.From.ID == 0 && fwd.FromName == "" && fwd.ChannelPost == 0 && fwd.SavedFrom.ID == 0 && fwd.SavedFromMsgID == 0) { + return nil + } + header := &tg.MessageFwdHeader{Date: fwd.Date} + if peer := tgPeer(fwd.From); peer != nil { + header.SetFromID(peer) + } + if fwd.FromName != "" { + header.SetFromName(fwd.FromName) + } + if fwd.ChannelPost > 0 { + header.SetChannelPost(fwd.ChannelPost) + } + if peer := tgPeer(fwd.SavedFrom); peer != nil { + header.SetSavedFromPeer(peer) + } + if fwd.SavedFromMsgID > 0 { + header.SetSavedFromMsgID(fwd.SavedFromMsgID) + } + return header +} + +func tgChannelMessageReplies(in *domain.ChannelMessageReplies) *tg.MessageReplies { + if in == nil { + return nil + } + out := &tg.MessageReplies{ + Comments: in.Comments, + Replies: in.Replies, + RepliesPts: in.RepliesPts, + } + if len(in.RecentRepliers) > 0 { + repliers := make([]tg.PeerClass, 0, len(in.RecentRepliers)) + for _, peer := range in.RecentRepliers { + if converted := tgPeer(peer); converted != nil { + repliers = append(repliers, converted) + } + } + if len(repliers) > 0 { + out.SetRecentRepliers(repliers) + } + } + if in.ChannelID != 0 { + out.SetChannelID(in.ChannelID) + } + if in.MaxID > 0 { + out.SetMaxID(in.MaxID) + } + if in.ReadMaxID > 0 { + out.SetReadMaxID(in.ReadMaxID) + } + return out +} + +func tgMessageReactions(viewerUserID int64, in *domain.ChannelMessageReactions) *tg.MessageReactions { + if in == nil { + return nil + } + out := &tg.MessageReactions{ + Results: make([]tg.ReactionCount, 0, len(in.Results)), + } + if in.CanSeeList { + out.SetCanSeeList(true) + } + for _, item := range in.Results { + reaction := tgMessageReaction(item.Reaction) + if reaction == nil || item.Count <= 0 { + continue + } + count := tg.ReactionCount{ + Reaction: reaction, + Count: item.Count, + } + if item.ChosenOrder > 0 { + count.SetChosenOrder(item.ChosenOrder) + } + out.Results = append(out.Results, count) + } + if len(in.Recent) > 0 { + recent := make([]tg.MessagePeerReaction, 0, len(in.Recent)) + for _, item := range in.Recent { + if converted := tgMessagePeerReaction(viewerUserID, item); converted != nil { + recent = append(recent, *converted) + } + } + if len(recent) > 0 { + out.SetRecentReactions(recent) + } + } + if out.Results == nil { + out.Results = []tg.ReactionCount{} + } + return out +} + +func tgMessagePeerReaction(viewerUserID int64, in domain.ChannelMessagePeerReaction) *tg.MessagePeerReaction { + reaction := tgMessageReaction(in.Reaction) + if reaction == nil || in.UserID == 0 { + return nil + } + out := &tg.MessagePeerReaction{ + PeerID: &tg.PeerUser{UserID: in.UserID}, + Date: in.Date, + Reaction: reaction, + } + if in.Big { + out.SetBig(true) + } + if in.Unread && in.SenderUserID == viewerUserID { + out.SetUnread(true) + } + if in.My || in.UserID == viewerUserID { + out.SetMy(true) + } + return out +} + +func tgMessageReaction(in domain.MessageReaction) tg.ReactionClass { + switch in.Type { + case domain.MessageReactionEmoji: + if in.Emoticon == "" { + return nil + } + return &tg.ReactionEmoji{Emoticon: in.Emoticon} + default: + return nil + } +} + +func tgMessageEntities(entities []domain.MessageEntity) []tg.MessageEntityClass { + if len(entities) == 0 { + return nil + } + out := make([]tg.MessageEntityClass, 0, len(entities)) + for _, entity := range entities { + switch entity.Type { + case domain.MessageEntityBold: + out = append(out, &tg.MessageEntityBold{Offset: entity.Offset, Length: entity.Length}) + } + } + return out +} + +func domainMessageEntities(entities []tg.MessageEntityClass) []domain.MessageEntity { + if len(entities) == 0 { + return nil + } + out := make([]domain.MessageEntity, 0, len(entities)) + for _, entity := range entities { + switch e := entity.(type) { + case *tg.MessageEntityBold: + out = append(out, domain.MessageEntity{ + Type: domain.MessageEntityBold, + Offset: e.Offset, + Length: e.Length, + }) + } + } + return out +} + +func tgPeer(p domain.Peer) tg.PeerClass { + switch p.Type { + case domain.PeerTypeUser: + return &tg.PeerUser{UserID: p.ID} + case domain.PeerTypeChannel: + return &tg.PeerChannel{ChannelID: p.ID} + default: + return nil + } +} + +func tgChannels(viewerUserID int64, channels []domain.Channel) []tg.ChatClass { + out := make([]tg.ChatClass, 0, len(channels)) + seen := make(map[int64]struct{}, len(channels)) + for _, ch := range channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + out = append(out, tgChannelChat(viewerUserID, ch, nil)) + } + return out +} + +func tgChannelsForDialogs(viewerUserID int64, channels []domain.Channel, dialogs []domain.Dialog) []tg.ChatClass { + left := make(map[int64]struct{}) + for _, dialog := range dialogs { + if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 && dialog.ChannelLeft { + left[dialog.Peer.ID] = struct{}{} + } + } + out := make([]tg.ChatClass, 0, len(channels)) + seen := make(map[int64]struct{}, len(channels)) + for _, ch := range channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + var self *domain.ChannelMember + if _, ok := left[ch.ID]; ok { + self = &domain.ChannelMember{ + ChannelID: ch.ID, + UserID: viewerUserID, + Status: domain.ChannelMemberLeft, + } + } + out = append(out, tgChannelChat(viewerUserID, ch, self)) + } + return out +} + +func tgChannelChat(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) tg.ChatClass { + if ch.Deleted { + return tgChannelForbidden(ch) + } + return tgChannel(viewerUserID, ch, self) +} + +func tgMigratedLegacyChat(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) *tg.Chat { + if ch.ID == 0 || ch.Deleted { + return nil + } + out := &tg.Chat{ + Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0, + Deactivated: true, + Noforwards: ch.NoForwards, + ID: ch.ID, + Title: ch.Title, + Photo: tgChatPhoto(ch), + ParticipantsCount: ch.ParticipantsCount, + Date: ch.Date, + Version: ch.Pts, + } + out.SetMigratedTo(&tg.InputChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash}) + if self != nil { + if self.Status == domain.ChannelMemberLeft { + out.Left = true + } + switch self.Role { + case domain.ChannelRoleCreator: + out.Creator = true + out.SetAdminRights(tgChatAdminRights(self.AdminRights)) + case domain.ChannelRoleAdmin: + out.SetAdminRights(tgChatAdminRights(self.AdminRights)) + } + } + if !zeroChannelBannedRights(ch.DefaultBannedRights) { + out.SetDefaultBannedRights(tgChatBannedRights(ch.DefaultBannedRights)) + } + return out +} + +func tgChannelForbidden(ch domain.Channel) *tg.ChannelForbidden { + return &tg.ChannelForbidden{ + Broadcast: ch.Broadcast, + Megagroup: ch.Megagroup, + ID: ch.ID, + AccessHash: ch.AccessHash, + Title: ch.Title, + } +} + +func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) *tg.Channel { + out := &tg.Channel{ + Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0, + Broadcast: ch.Broadcast, + Megagroup: ch.Megagroup, + Forum: ch.Forum, + ForumTabs: ch.ForumTabs, + Noforwards: ch.NoForwards, + Signatures: ch.Signatures, + ID: ch.ID, + Title: ch.Title, + Photo: tgChatPhoto(ch), + Date: ch.Date, + } + out.SetAccessHash(ch.AccessHash) + out.SetJoinToSend(ch.JoinToSend) + out.SetJoinRequest(ch.JoinRequest) + out.SetAutotranslation(ch.Autotranslation) + out.SetBroadcastMessagesAllowed(ch.BroadcastMessagesAllowed) + if ch.SendPaidMessagesStars > 0 || ch.BroadcastMessagesAllowed { + out.SetSendPaidMessagesStars(ch.SendPaidMessagesStars) + } + if ch.LinkedChatID != 0 { + out.SetHasLink(true) + } + if ch.Username != "" { + out.SetUsername(ch.Username) + out.SetUsernames(tgUsernames(ch.Username)) + } + if color := tgPeerColor(ch.Color); color != nil { + out.SetColor(color) + } + if profileColor := tgPeerColor(ch.ProfileColor); profileColor != nil { + out.SetProfileColor(profileColor) + } + if status := tgChannelEmojiStatus(ch.EmojiStatus); status != nil { + out.SetEmojiStatus(status) + } + if ch.SlowmodeSeconds > 0 { + out.SetSlowmodeEnabled(true) + } + if ch.ParticipantsCount > 0 { + out.SetParticipantsCount(ch.ParticipantsCount) + } + if !zeroChannelBannedRights(ch.DefaultBannedRights) { + out.SetDefaultBannedRights(tgChatBannedRights(ch.DefaultBannedRights)) + } + if self != nil { + if self.Status == domain.ChannelMemberLeft { + out.Left = true + } + switch self.Role { + case domain.ChannelRoleCreator: + out.Creator = true + out.SetAdminRights(tgChatAdminRights(self.AdminRights)) + case domain.ChannelRoleAdmin: + out.SetAdminRights(tgChatAdminRights(self.AdminRights)) + } + if !zeroChannelBannedRights(self.BannedRights) { + out.SetBannedRights(tgChatBannedRights(self.BannedRights)) + } + } + return out +} + +func tgChannelFull(view domain.ChannelView) *tg.ChannelFull { + ch := view.Channel + full := &tg.ChannelFull{ + CanViewParticipants: !ch.ParticipantsHidden || channelMemberIsAdmin(view.Self), + CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator, + ID: ch.ID, + About: ch.About, + ReadInboxMaxID: view.Dialog.ReadInboxMaxID, + ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID, + UnreadCount: view.Dialog.UnreadCount, + ChatPhoto: tgChannelChatPhotoFull(ch), + NotifySettings: *tdesktop.NotifySettings(), + Pts: ch.Pts, + } + if ch.ParticipantsCount > 0 { + full.SetParticipantsCount(ch.ParticipantsCount) + } + if ch.AdminsCount > 0 { + full.SetAdminsCount(ch.AdminsCount) + } + if ch.KickedCount > 0 { + full.SetKickedCount(ch.KickedCount) + } + if ch.BannedCount > 0 { + full.SetBannedCount(ch.BannedCount) + } + if view.Dialog.FolderID != domain.DialogMainFolderID { + full.SetFolderID(view.Dialog.FolderID) + } + if ch.TTLPeriod > 0 { + full.SetTTLPeriod(ch.TTLPeriod) + } + if ch.PreHistoryHidden { + full.SetHiddenPrehistory(true) + } + if ch.ParticipantsHidden { + full.SetParticipantsHidden(true) + } + if ch.AntiSpam { + full.SetAntispam(true) + } + if ch.RestrictedSponsored { + full.SetRestrictedSponsored(true) + } + if ch.SendPaidMessagesStars > 0 || ch.BroadcastMessagesAllowed { + full.SetPaidMessagesAvailable(true) + full.SetSendPaidMessagesStars(ch.SendPaidMessagesStars) + } + if view.Dialog.ViewForumAsMessages { + full.SetViewForumAsMessages(true) + } + if ch.LinkedChatID != 0 { + full.SetLinkedChatID(ch.LinkedChatID) + } + if defaultSendAs := validDefaultSendAsPeer(view); defaultSendAs != nil { + full.SetDefaultSendAs(tgPeer(*defaultSendAs)) + } + if ch.SlowmodeSeconds > 0 { + full.SetSlowmodeSeconds(ch.SlowmodeSeconds) + if view.Self.SlowmodeLastSendDate > 0 { + full.SetSlowmodeNextSendDate(view.Self.SlowmodeLastSendDate + ch.SlowmodeSeconds) + } + } + if ch.PinnedMessageID > 0 { + full.SetPinnedMsgID(ch.PinnedMessageID) + } + if reactions := tgChannelReactionPolicy(ch.ReactionPolicy); reactions != nil { + full.SetAvailableReactions(reactions) + } + if ch.ReactionPolicy.Limit > 0 { + full.SetReactionsLimit(ch.ReactionPolicy.Limit) + } + if ch.ReactionPolicy.PaidEnabled { + full.SetPaidReactionsAvailable(true) + } + return full +} + +func channelMemberIsAdmin(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin +} + +func tgChannelReactionPolicy(policy domain.ChannelReactionPolicy) tg.ChatReactionsClass { + switch policy.Type { + case domain.ChannelReactionPolicyNone: + return &tg.ChatReactionsNone{} + case domain.ChannelReactionPolicyAll: + out := &tg.ChatReactionsAll{} + if policy.AllowCustom { + out.SetAllowCustom(true) + } + return out + case domain.ChannelReactionPolicySome: + reactions := make([]tg.ReactionClass, 0, len(policy.Emoticons)+len(policy.CustomEmojiIDs)) + for _, emoticon := range policy.Emoticons { + if emoticon == "" { + continue + } + reactions = append(reactions, &tg.ReactionEmoji{Emoticon: emoticon}) + } + for _, id := range policy.CustomEmojiIDs { + if id <= 0 { + continue + } + reactions = append(reactions, &tg.ReactionCustomEmoji{DocumentID: id}) + } + return &tg.ChatReactionsSome{Reactions: reactions} + default: + return nil + } +} + +func tgPeerColor(color domain.ChannelPeerColor) tg.PeerColorClass { + if color.Empty() { + return nil + } + out := &tg.PeerColor{} + if color.HasColor { + out.SetColor(color.Color) + } + if color.BackgroundEmojiID != 0 { + out.SetBackgroundEmojiID(color.BackgroundEmojiID) + } + return out +} + +func tgChannelEmojiStatus(status domain.ChannelEmojiStatus) tg.EmojiStatusClass { + if status.Empty() { + return nil + } + out := &tg.EmojiStatus{DocumentID: status.DocumentID} + if status.Until > 0 { + out.SetUntil(status.Until) + } + return out +} + +func tgChannelParticipant(selfUserID int64, member domain.ChannelMember) tg.ChannelParticipantClass { + switch member.Role { + case domain.ChannelRoleCreator: + out := &tg.ChannelParticipantCreator{ + UserID: member.UserID, + AdminRights: tgChatAdminRights(member.AdminRights), + } + if member.Rank != "" { + out.SetRank(member.Rank) + } + return out + case domain.ChannelRoleAdmin: + out := &tg.ChannelParticipantAdmin{ + Self: member.UserID == selfUserID, + UserID: member.UserID, + PromotedBy: member.InviterUserID, + Date: member.JoinedAt, + AdminRights: tgChatAdminRights(member.AdminRights), + } + if member.InviterUserID != 0 { + out.SetInviterID(member.InviterUserID) + } + if member.Rank != "" { + out.SetRank(member.Rank) + } + return out + default: + if member.Status != domain.ChannelMemberActive { + return &tg.ChannelParticipantLeft{Peer: &tg.PeerUser{UserID: member.UserID}} + } + if member.UserID == selfUserID { + out := &tg.ChannelParticipantSelf{ + UserID: member.UserID, + InviterID: member.InviterUserID, + Date: member.JoinedAt, + } + if member.Rank != "" { + out.SetRank(member.Rank) + } + return out + } + out := &tg.ChannelParticipant{UserID: member.UserID, Date: member.JoinedAt} + if member.Rank != "" { + out.SetRank(member.Rank) + } + return out + } +} + +func tgChannelParticipantForUpdate(selfUserID int64, member domain.ChannelMember) tg.ChannelParticipantClass { + if member.UserID == 0 { + return nil + } + if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { + return &tg.ChannelParticipantBanned{ + Left: member.Status != domain.ChannelMemberActive, + Peer: &tg.PeerUser{UserID: member.UserID}, + KickedBy: member.InviterUserID, + Date: member.LeftAt, + BannedRights: tgChatBannedRights(member.BannedRights), + } + } + if member.Status == domain.ChannelMemberLeft { + return &tg.ChannelParticipantLeft{Peer: &tg.PeerUser{UserID: member.UserID}} + } + return tgChannelParticipant(selfUserID, member) +} + +func tgChannelAdminLogEvents(viewerUserID int64, events []domain.ChannelAdminLogEvent) []tg.ChannelAdminLogEvent { + out := make([]tg.ChannelAdminLogEvent, 0, len(events)) + for _, event := range events { + if item, ok := tgChannelAdminLogEvent(viewerUserID, event); ok { + out = append(out, item) + } + } + return out +} + +func tgChannelAdminLogEvent(viewerUserID int64, event domain.ChannelAdminLogEvent) (tg.ChannelAdminLogEvent, bool) { + action := tg.ChannelAdminLogEventActionClass(nil) + switch event.Type { + case domain.ChannelAdminLogChangeTitle: + action = &tg.ChannelAdminLogEventActionChangeTitle{PrevValue: event.PrevString, NewValue: event.NewString} + case domain.ChannelAdminLogChangeUsername: + action = &tg.ChannelAdminLogEventActionChangeUsername{PrevValue: event.PrevString, NewValue: event.NewString} + case domain.ChannelAdminLogChangeLinkedChat: + action = &tg.ChannelAdminLogEventActionChangeLinkedChat{PrevValue: int64(event.PrevInt), NewValue: int64(event.NewInt)} + case domain.ChannelAdminLogToggleSignatures: + action = &tg.ChannelAdminLogEventActionToggleSignatures{NewValue: event.NewBool} + case domain.ChannelAdminLogTogglePreHistoryHidden: + action = &tg.ChannelAdminLogEventActionTogglePreHistoryHidden{NewValue: event.NewBool} + case domain.ChannelAdminLogToggleForum: + action = &tg.ChannelAdminLogEventActionToggleForum{NewValue: event.NewBool} + case domain.ChannelAdminLogToggleAutotranslation: + action = &tg.ChannelAdminLogEventActionToggleAutotranslation{NewValue: event.NewBool} + case domain.ChannelAdminLogToggleAntiSpam: + action = &tg.ChannelAdminLogEventActionToggleAntiSpam{NewValue: event.NewBool} + case domain.ChannelAdminLogToggleSlowMode: + action = &tg.ChannelAdminLogEventActionToggleSlowMode{PrevValue: event.PrevInt, NewValue: event.NewInt} + case domain.ChannelAdminLogParticipantJoin: + action = &tg.ChannelAdminLogEventActionParticipantJoin{} + case domain.ChannelAdminLogParticipantLeave: + action = &tg.ChannelAdminLogEventActionParticipantLeave{} + case domain.ChannelAdminLogParticipantInvite: + if event.Participant == nil { + return tg.ChannelAdminLogEvent{}, false + } + action = &tg.ChannelAdminLogEventActionParticipantInvite{ + Participant: tgChannelParticipantForUpdate(viewerUserID, *event.Participant), + } + case domain.ChannelAdminLogParticipantPromote, domain.ChannelAdminLogParticipantDemote: + if event.PrevParticipant == nil || event.NewParticipant == nil { + return tg.ChannelAdminLogEvent{}, false + } + action = &tg.ChannelAdminLogEventActionParticipantToggleAdmin{ + PrevParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.PrevParticipant), + NewParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.NewParticipant), + } + case domain.ChannelAdminLogParticipantBan, domain.ChannelAdminLogParticipantUnban, domain.ChannelAdminLogParticipantKick, domain.ChannelAdminLogParticipantUnkick: + if event.PrevParticipant == nil || event.NewParticipant == nil { + return tg.ChannelAdminLogEvent{}, false + } + action = &tg.ChannelAdminLogEventActionParticipantToggleBan{ + PrevParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.PrevParticipant), + NewParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.NewParticipant), + } + case domain.ChannelAdminLogUpdatePinned: + action = &tg.ChannelAdminLogEventActionUpdatePinned{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} + case domain.ChannelAdminLogSendMessage: + action = &tg.ChannelAdminLogEventActionSendMessage{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} + case domain.ChannelAdminLogEditMessage: + action = &tg.ChannelAdminLogEventActionEditMessage{ + PrevMessage: tgAdminLogMessage(viewerUserID, event.ChannelID, event.PrevMessage), + NewMessage: tgAdminLogMessage(viewerUserID, event.ChannelID, event.NewMessage), + } + case domain.ChannelAdminLogDeleteMessage: + action = &tg.ChannelAdminLogEventActionDeleteMessage{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} + default: + return tg.ChannelAdminLogEvent{}, false + } + if action == nil { + return tg.ChannelAdminLogEvent{}, false + } + return tg.ChannelAdminLogEvent{ + ID: event.ID, + Date: event.Date, + UserID: event.UserID, + Action: action, + }, true +} + +func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage) tg.MessageClass { + if msg == nil || msg.ID == 0 { + out := &tg.MessageEmpty{ID: 0} + out.SetPeerID(&tg.PeerChannel{ChannelID: channelID}) + return out + } + return tgChannelMessage(viewerUserID, *msg) +} + +func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights { + return tg.ChatAdminRights{ + ChangeInfo: rights.ChangeInfo, + PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, + DeleteMessages: rights.DeleteMessages, + BanUsers: rights.BanUsers, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + AddAdmins: rights.AddAdmins, + Anonymous: rights.Anonymous, + ManageCall: rights.ManageCall, + Other: true, + } +} + +func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights { + return domain.ChannelAdminRights{ + ChangeInfo: rights.ChangeInfo, + PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, + DeleteMessages: rights.DeleteMessages, + BanUsers: rights.BanUsers, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + AddAdmins: rights.AddAdmins, + Anonymous: rights.Anonymous, + ManageCall: rights.ManageCall, + } +} + +func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights { + return tg.ChatBannedRights{ + ViewMessages: rights.ViewMessages, + SendMessages: rights.SendMessages, + SendMedia: rights.SendMedia, + SendStickers: rights.SendStickers, + SendGifs: rights.SendGifs, + SendGames: rights.SendGames, + SendInline: rights.SendInline, + EmbedLinks: rights.EmbedLinks, + SendPolls: rights.SendPolls, + ChangeInfo: rights.ChangeInfo, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + UntilDate: rights.UntilDate, + } +} + +func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights { + return domain.ChannelBannedRights{ + ViewMessages: rights.ViewMessages, + SendMessages: rights.SendMessages, + SendMedia: rights.SendMedia, + SendStickers: rights.SendStickers, + SendGifs: rights.SendGifs, + SendGames: rights.SendGames, + SendInline: rights.SendInline, + EmbedLinks: rights.EmbedLinks, + SendPolls: rights.SendPolls, + ChangeInfo: rights.ChangeInfo, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + UntilDate: rights.UntilDate, + } +} + +func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { + return rights == domain.ChannelBannedRights{} +} + +func tgLangPackDifference(pack domain.LangPack) *tg.LangPackDifference { + return &tg.LangPackDifference{ + LangCode: pack.LangCode, + FromVersion: pack.FromVersion, + Version: pack.Version, + Strings: tgLangPackStrings(pack.Strings), + } +} + +func tgLangPackStrings(items []domain.LangPackString) []tg.LangPackStringClass { + out := make([]tg.LangPackStringClass, 0, len(items)) + for _, item := range items { + if item.Deleted { + out = append(out, &tg.LangPackStringDeleted{Key: item.Key}) + continue + } + if item.Pluralized { + out = append(out, &tg.LangPackStringPluralized{ + Key: item.Key, + ZeroValue: item.ZeroValue, + OneValue: item.OneValue, + TwoValue: item.TwoValue, + FewValue: item.FewValue, + ManyValue: item.ManyValue, + OtherValue: item.OtherValue, + }) + continue + } + out = append(out, &tg.LangPackString{Key: item.Key, Value: item.Value}) + } + return out +} + +func tgPassword(settings domain.PasswordSettings) *tg.AccountPassword { + if len(settings.SecureRandom) == 0 { + settings.SecureRandom = []byte("telesrv-tdesktop-dev-secure-rand") + } + return &tg.AccountPassword{ + HasRecovery: settings.HasRecovery, + HasSecureValues: settings.HasSecureValues, + HasPassword: settings.HasPassword, + Hint: settings.Hint, + EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern, + NewAlgo: &tg.PasswordKdfAlgoUnknown{}, + NewSecureAlgo: &tg.SecurePasswordKdfAlgoUnknown{}, + SecureRandom: settings.SecureRandom, + LoginEmailPattern: settings.LoginEmailPattern, + } +} + +func tgCountriesList(list domain.CountriesList) tg.HelpCountriesListClass { + out := &tg.HelpCountriesList{ + Hash: list.Hash, + Countries: make([]tg.HelpCountry, 0, len(list.Countries)), + } + for _, country := range list.Countries { + item := tg.HelpCountry{ + Hidden: country.Hidden, + ISO2: country.ISO2, + DefaultName: country.DefaultName, + Name: country.Name, + CountryCodes: make([]tg.HelpCountryCode, 0, len(country.CountryCodes)), + } + for _, code := range country.CountryCodes { + item.CountryCodes = append(item.CountryCodes, tg.HelpCountryCode{ + CountryCode: code.CountryCode, + Prefixes: code.Prefixes, + Patterns: code.Patterns, + }) + } + out.Countries = append(out.Countries, item) + } + return out +} + +func tgJSONValue(data []byte) tg.JSONValueClass { + if len(data) == 0 { + return &tg.JSONObject{} + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + return &tg.JSONObject{} + } + return tgJSON(v) +} + +func tgJSON(v any) tg.JSONValueClass { + switch x := v.(type) { + case nil: + return &tg.JSONNull{} + case bool: + return &tg.JSONBool{Value: x} + case float64: + return &tg.JSONNumber{Value: x} + case string: + return &tg.JSONString{Value: x} + case []any: + arr := &tg.JSONArray{Value: make([]tg.JSONValueClass, 0, len(x))} + for _, item := range x { + arr.Value = append(arr.Value, tgJSON(item)) + } + return arr + case map[string]any: + obj := &tg.JSONObject{Value: make([]tg.JSONObjectValue, 0, len(x))} + keys := make([]string, 0, len(x)) + for key := range x { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + obj.Value = append(obj.Value, tg.JSONObjectValue{Key: key, Value: tgJSON(x[key])}) + } + return obj + default: + return &tg.JSONString{Value: ""} + } +} diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go new file mode 100644 index 00000000..c4c71d08 --- /dev/null +++ b/internal/rpc/convert_media.go @@ -0,0 +1,385 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 本文件集中 domain media 值对象 → tg.* 的转换;tg.* 只在 rpc 层出现。 +// 供 reaction / sticker 资源 RPC 与消息 media 共用。 + +// tgMessageMedia 把消息 media 快照转成 tg.MessageMediaClass;空载荷回退 MessageMediaEmpty。 +func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass { + if m.IsZero() { + return &tg.MessageMediaEmpty{} + } + switch m.Kind { + case domain.MessageMediaKindPhoto: + out := &tg.MessageMediaPhoto{Spoiler: m.Spoiler} + if m.Photo != nil { + out.Photo = tgPhoto(*m.Photo) + } + if m.TTLSeconds > 0 { + out.TTLSeconds = m.TTLSeconds + } + return out + case domain.MessageMediaKindDocument: + out := &tg.MessageMediaDocument{ + Spoiler: m.Spoiler, + Nopremium: m.Nopremium, + Voice: m.Voice, + Round: m.Round, + Video: m.Video, + } + if m.Document != nil { + out.Document = tgDocument(*m.Document) + } + if m.TTLSeconds > 0 { + out.TTLSeconds = m.TTLSeconds + } + return out + default: + return &tg.MessageMediaEmpty{} + } +} + +// tgChatPhoto 由 domain.Channel 反范式头像字段构造 ChatPhoto(频道/群头像缩略)。 +func tgChatPhoto(ch domain.Channel) tg.ChatPhotoClass { + if ch.PhotoID == 0 { + return &tg.ChatPhotoEmpty{} + } + p := &tg.ChatPhoto{PhotoID: ch.PhotoID, DCID: ch.PhotoDCID} + if len(ch.PhotoStripped) > 0 { + p.SetStrippedThumb(ch.PhotoStripped) + } + return p +} + +// tgChannelChatPhotoFull 为 channelFull.chat_photo 构造完整 Photo(合成 a/c 尺寸; +// getFile 按 photo:: 解析,忽略 access_hash,故合成尺寸也可下载)。 +func tgChannelChatPhotoFull(ch domain.Channel) tg.PhotoClass { + if ch.PhotoID == 0 { + return &tg.PhotoEmpty{} + } + photo := &tg.Photo{ID: ch.PhotoID, DCID: ch.PhotoDCID, Sizes: syntheticAvatarSizes()} + if len(ch.PhotoStripped) > 0 { + photo.Sizes = append([]tg.PhotoSizeClass{&tg.PhotoStrippedSize{Type: "i", Bytes: ch.PhotoStripped}}, photo.Sizes...) + } + return photo +} + +func syntheticAvatarSizes() []tg.PhotoSizeClass { + return []tg.PhotoSizeClass{ + &tg.PhotoSize{Type: "a", W: 160, H: 160, Size: 0}, + &tg.PhotoSize{Type: "c", W: 640, H: 640, Size: 0}, + } +} + +// tgPhoto 把 domain.Photo 转成 tg.PhotoClass。 +func tgPhoto(p domain.Photo) tg.PhotoClass { + if p.ID == 0 { + return &tg.PhotoEmpty{} + } + return &tg.Photo{ + ID: p.ID, + AccessHash: p.AccessHash, + FileReference: p.FileReference, + Date: p.Date, + Sizes: tgPhotoSizes(p.Sizes), + DCID: p.DCID, + HasStickers: p.HasStickers, + } +} + +// tgDocument 把 domain.Document 转成 tg.DocumentClass。 +func tgDocument(d domain.Document) tg.DocumentClass { + if d.ID == 0 { + return &tg.DocumentEmpty{} + } + return &tg.Document{ + ID: d.ID, + AccessHash: d.AccessHash, + FileReference: d.FileReference, + Date: d.Date, + MimeType: d.MimeType, + Size: d.Size, + Thumbs: tgDocumentThumbs(d.Thumbs), + DCID: d.DCID, + Attributes: tgDocumentAttributes(d.Attributes), + } +} + +func tgDocuments(docs []domain.Document) []tg.DocumentClass { + out := make([]tg.DocumentClass, 0, len(docs)) + for _, d := range docs { + out = append(out, tgDocument(d)) + } + return out +} + +func tgDocumentThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass { + if len(sizes) == 0 { + return nil + } + out := make([]tg.PhotoSizeClass, 0, len(sizes)) + for _, s := range sizes { + if s.Kind == domain.PhotoSizeKindCached && len(s.Bytes) > 0 { + size := s.Size + if size == 0 { + size = len(s.Bytes) + } + if s.Type != "" && s.W > 0 && s.H > 0 && size > 0 { + out = append(out, &tg.PhotoSize{Type: s.Type, W: s.W, H: s.H, Size: size}) + } + continue + } + out = append(out, tgPhotoSize(s)) + } + return compactPhotoSizeClasses(out) +} + +func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass { + if len(sizes) == 0 { + return nil + } + out := make([]tg.PhotoSizeClass, 0, len(sizes)) + for _, s := range sizes { + out = append(out, tgPhotoSize(s)) + } + return compactPhotoSizeClasses(out) +} + +func tgPhotoSize(s domain.PhotoSize) tg.PhotoSizeClass { + switch s.Kind { + case domain.PhotoSizeKindDefault: + return &tg.PhotoSize{Type: s.Type, W: s.W, H: s.H, Size: s.Size} + case domain.PhotoSizeKindStripped: + return &tg.PhotoStrippedSize{Type: s.Type, Bytes: s.Bytes} + case domain.PhotoSizeKindCached: + return &tg.PhotoCachedSize{Type: s.Type, W: s.W, H: s.H, Bytes: s.Bytes} + case domain.PhotoSizeKindPath: + return &tg.PhotoPathSize{Type: s.Type, Bytes: s.Bytes} + case domain.PhotoSizeKindProgressive: + return &tg.PhotoSizeProgressive{Type: s.Type, W: s.W, H: s.H, Sizes: s.Sizes} + default: + return nil + } +} + +func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass { + out := in[:0] + for _, s := range in { + if s != nil { + out = append(out, s) + } + } + return out +} + +func tgDocumentAttributes(attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass { + out := make([]tg.DocumentAttributeClass, 0, len(attrs)) + for _, a := range attrs { + switch a.Kind { + case domain.DocAttrImageSize: + out = append(out, &tg.DocumentAttributeImageSize{W: a.W, H: a.H}) + case domain.DocAttrAnimated: + out = append(out, &tg.DocumentAttributeAnimated{}) + case domain.DocAttrSticker: + out = append(out, &tg.DocumentAttributeSticker{ + Mask: a.Mask, + Alt: a.Alt, + Stickerset: tgInputStickerSetFromIDs(a.StickerSetID, a.StickerSetAccessHash), + }) + case domain.DocAttrVideo: + out = append(out, &tg.DocumentAttributeVideo{ + RoundMessage: a.RoundMessage, + SupportsStreaming: a.SupportsStreaming, + Duration: a.Duration, + W: a.W, + H: a.H, + }) + case domain.DocAttrAudio: + attr := &tg.DocumentAttributeAudio{ + Voice: a.Voice, + Duration: a.AudioDuration, + Title: a.Title, + Performer: a.Performer, + } + if len(a.Waveform) > 0 { + attr.SetWaveform(a.Waveform) + } + out = append(out, attr) + case domain.DocAttrFilename: + out = append(out, &tg.DocumentAttributeFilename{FileName: a.FileName}) + case domain.DocAttrCustomEmoji: + out = append(out, &tg.DocumentAttributeCustomEmoji{ + Free: a.Free, + TextColor: a.TextColor, + Alt: a.Alt, + Stickerset: tgInputStickerSetFromIDs(a.StickerSetID, a.StickerSetAccessHash), + }) + } + } + return out +} + +func tgInputStickerSetFromIDs(id, accessHash int64) tg.InputStickerSetClass { + if id == 0 { + return &tg.InputStickerSetEmpty{} + } + return &tg.InputStickerSetID{ID: id, AccessHash: accessHash} +} + +// ---- available reactions ---- + +// tgAvailableReactions 用真实文档构造 messages.availableReactions;docByID 由 handler 预加载。 +func tgAvailableReactions(reactions []domain.AvailableReaction, docByID map[int64]domain.Document, hash int) *tg.MessagesAvailableReactions { + out := &tg.MessagesAvailableReactions{Hash: hash, Reactions: make([]tg.AvailableReaction, 0, len(reactions))} + doc := func(id int64) tg.DocumentClass { + if d, ok := docByID[id]; ok { + return tgDocument(d) + } + return &tg.DocumentEmpty{ID: id} + } + for _, r := range reactions { + ar := tg.AvailableReaction{ + Inactive: r.Inactive, + Premium: r.Premium, + Reaction: r.Reaction, + Title: r.Title, + StaticIcon: doc(r.StaticIconID), + AppearAnimation: doc(r.AppearAnimationID), + SelectAnimation: doc(r.SelectAnimationID), + ActivateAnimation: doc(r.ActivateAnimationID), + EffectAnimation: doc(r.EffectAnimationID), + } + if r.AroundAnimationID != 0 { + ar.SetAroundAnimation(doc(r.AroundAnimationID)) + } + if r.CenterIconID != 0 { + ar.SetCenterIcon(doc(r.CenterIconID)) + } + out.Reactions = append(out.Reactions, ar) + } + return out +} + +// reactionDocumentIDs 收集一组 reaction 引用的全部文档 id(用于批量预加载)。 +func reactionDocumentIDs(reactions []domain.AvailableReaction) []int64 { + seen := make(map[int64]struct{}) + out := make([]int64, 0, len(reactions)*7) + for _, r := range reactions { + for _, id := range r.DocumentIDs() { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + return out +} + +// ---- sticker sets ---- + +func tgStickerSet(set domain.StickerSet) tg.StickerSet { + out := tg.StickerSet{ + Archived: set.Archived, + Official: set.Official, + Masks: set.Masks, + Emojis: set.Emojis, + ID: set.ID, + AccessHash: set.AccessHash, + Title: set.Title, + ShortName: set.ShortName, + Count: set.Count, + Hash: set.Hash, + } + if set.Installed { + date := set.InstalledDate + if date == 0 { + date = 1 + } + out.SetInstalledDate(date) + } + if thumbs := tgStickerSetThumbs(set.Thumbs); len(thumbs) > 0 { + out.SetThumbs(thumbs) + out.SetThumbDCID(set.ThumbDCID) + out.SetThumbVersion(set.ThumbVersion) + } + if set.ThumbDocumentID != 0 { + out.SetThumbDocumentID(set.ThumbDocumentID) + } + return out +} + +func tgStickerSetThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass { + if len(sizes) == 0 { + return nil + } + filtered := make([]domain.PhotoSize, 0, len(sizes)) + for _, s := range sizes { + if s.Downloadable() { + continue + } + filtered = append(filtered, s) + } + return tgPhotoSizes(filtered) +} + +func tgStickerSets(sets []domain.StickerSet) []tg.StickerSet { + out := make([]tg.StickerSet, 0, len(sets)) + for _, s := range sets { + out = append(out, tgStickerSet(s)) + } + return out +} + +func tgStickerPacks(packs []domain.StickerPack) []tg.StickerPack { + out := make([]tg.StickerPack, 0, len(packs)) + for _, p := range packs { + out = append(out, tg.StickerPack{Emoticon: p.Emoticon, Documents: append([]int64(nil), p.DocumentIDs...)}) + } + return out +} + +// tgMessagesStickerSet 构造完整 messages.stickerSet(set + packs + documents)。 +func tgMessagesStickerSet(set domain.StickerSet, docs []domain.Document) *tg.MessagesStickerSet { + return &tg.MessagesStickerSet{ + Set: tgStickerSet(set), + Packs: tgStickerPacks(set.Packs), + Keywords: []tg.StickerKeyword{}, + Documents: tgDocuments(docs), + } +} + +// stickerSetRefFromInput 把 tg.InputStickerSet 转成 domain.StickerSetRef。 +func stickerSetRefFromInput(input tg.InputStickerSetClass) (domain.StickerSetRef, bool) { + switch in := input.(type) { + case *tg.InputStickerSetID: + return domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: in.ID, AccessHash: in.AccessHash}, true + case *tg.InputStickerSetShortName: + return domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: in.ShortName}, true + case *tg.InputStickerSetAnimatedEmoji: + return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "animated_emoji"}, true + case *tg.InputStickerSetAnimatedEmojiAnimations: + return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "animated_emoji_animations"}, true + case *tg.InputStickerSetEmojiGenericAnimations: + return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "emoji_generic_animations"}, true + case *tg.InputStickerSetDice: + return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true + default: + return domain.StickerSetRef{}, false + } +} + +// mediaCatalogHash 用一组 int64(文档/集合 id)算稳定 hash,供 *NotModified 缓存判定。 +func mediaCatalogHash(values []int64) int64 { + var hash uint64 + for _, v := range values { + hash ^= uint64(v) + hash = hash*0x4f25 + uint64(v) + } + return int64(hash & 0x7fffffffffffffff) +} diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go new file mode 100644 index 00000000..49c07bd8 --- /dev/null +++ b/internal/rpc/deps.go @@ -0,0 +1,333 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" + + "telesrv/internal/domain" +) + +// 本文件按「消费者定义接口」惯例,在 rpc 包定义 Router 依赖的业务服务接口。 +// app/* 的 Service 实现它们;微服务化时 gRPC client 同样可实现,rpc 层无需改动。 +// 接口方法只用 domain 类型与基本类型,不依赖 app 具体包——这是 rpc↔业务的契约边界。 + +// AuthService 抽象登录/注册业务。 +type AuthService interface { + BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error + ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) + UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) + SendCode(ctx context.Context, phone string) (string, error) + SignIn(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) + SignUp(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) + LogOut(ctx context.Context, authKeyID [8]byte) error +} + +// SessionBinder 抽象登录后 session 与 user 的在线绑定。 +type SessionBinder interface { + BindAuthKey(sessionID int64, authKeyID [8]byte) + AuthKeyID(sessionID int64) ([8]byte, bool) + BindUser(sessionID, userID int64) + UserID(sessionID int64) (int64, bool) + UserIDResolved(sessionID int64) (userID int64, resolved bool) + UnbindAuthKey(authKeyID [8]byte) int + SetReceivesUpdates(sessionID int64, receives bool) + PushToSession(ctx context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error + PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) +} + +// ScopedSessionBinder 是 SessionBinder 的精确版本:所有定位都带 raw auth_key_id + session_id。 +// 生产 mtprotoedge.SessionManager 实现它;测试替身和旧实现可以只实现 SessionBinder。 +type ScopedSessionBinder interface { + BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) + AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int64) ([8]byte, bool) + BindUserForAuthKey(rawAuthKeyID [8]byte, sessionID, userID int64) + UserIDForAuthKey(rawAuthKeyID [8]byte, sessionID int64) (int64, bool) + UserIDResolvedForAuthKey(rawAuthKeyID [8]byte, sessionID int64) (userID int64, resolved bool) + SetReceivesUpdatesForAuthKey(rawAuthKeyID [8]byte, sessionID int64, receives bool) + PushToSessionForAuthKey(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error + PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) +} + +// BestEffortSessionBinder 是 updates fanout 的短超时推送接口;不用于 RPC result/ack。 +type BestEffortSessionBinder interface { + PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) +} + +// ScopedBestEffortSessionBinder 是带 raw auth_key_id 精确排除当前设备的 best-effort 版本。 +type ScopedBestEffortSessionBinder interface { + PushToUserExceptAuthKeySessionBestEffort(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) +} + +// OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout. +type OnlineUserProvider interface { + OnlineUserIDs(limit int) []int64 + IsUserOnline(userID int64) bool + OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 + TrackChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) + ClearChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64) + OnlineChannelUserIDs(channelID int64, limit int) []int64 + SetSessionChannelMemberships(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) + AddUserChannelMembership(userID, channelID int64) + RemoveUserChannelMembership(userID, channelID int64) + OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 +} + +// RateLimiter 抽象 RPC 高频写操作限流。 +type RateLimiter interface { + Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error) +} + +// UsersService 抽象用户查询。 +type UsersService interface { + Self(ctx context.Context, userID int64) (domain.User, error) + ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) + ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) +} + +// UserIdentityService 是 UsersService 的资料扩展能力,用于 username/phone 解析。 +type UserIdentityService interface { + CheckUsername(ctx context.Context, userID int64, username string) (bool, error) + UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) + UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) + ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error) + ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) +} + +// AccountService 抽象账号设置查询。 +type AccountService interface { + GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) +} + +// HelpService 抽象启动配置与国家区号目录。 +type HelpService interface { + GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) + GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error) +} + +// UpdatesService 抽象 update 状态查询。 +type UpdatesService interface { + GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) + CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error) + GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error) + ClearAuthKey(ctx context.Context, authKeyID [8]byte) error + RecordNewMessage(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) + RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) +} + +// ContactsService 抽象通讯录查询。 +type ContactsService interface { + GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) + ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) + AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) + ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) + Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) + DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) + UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) + GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) +} + +// DialogsService 抽象会话列表查询。 +type DialogsService interface { + GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) + GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) + SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error + DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) + ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) + ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) + TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) + ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error + MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) + UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error) + HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error) + PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) + GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) + SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error + DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error + ReorderDialogFolders(ctx context.Context, userID int64, order []int) error + ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error + EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error +} + +// MessagesService 抽象消息历史、搜索与已读。 +type MessagesService interface { + SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) + ForwardPrivateMessages(ctx context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) + GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) + GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) + Search(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) + ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) + ReadMessageContents(ctx context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) + GetOutboxReadDate(ctx context.Context, userID int64, req domain.OutboxReadDateRequest) (int, error) + SetMessageReactions(ctx context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) + GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) + EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) + DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) + DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) +} + +// ChannelsService 抽象超级群/频道业务。 +type ChannelsService interface { + CreateMegagroupFromCreateChat(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) + CreateChannel(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) + GetChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) + GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) + GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) + GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error) + InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) + JoinChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) + LeaveChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) + EditTitle(ctx context.Context, userID int64, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) + EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error) + EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) + EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) + EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) + DeleteChannel(ctx context.Context, userID int64, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) + CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) + UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) + ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error) + SearchPublicChannels(ctx context.Context, userID int64, query string, limit int) (domain.PublicChannelSearchResult, error) + SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) + SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) + SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) + SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) + SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) + SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) + SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) + SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) + ListAdminLog(ctx context.Context, userID int64, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) + GetChannelForChangeInfo(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) + SaveDefaultSendAs(ctx context.Context, userID int64, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) + GetMessageViews(ctx context.Context, userID int64, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) + SetMessageReactions(ctx context.Context, userID int64, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) + GetMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) + ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) + TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) + RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) + ClearRecentReactions(ctx context.Context, userID int64) error + SavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) + UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error + ReadMessageContents(ctx context.Context, userID int64, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) + GetMessageAuthor(ctx context.Context, userID int64, req domain.GetChannelMessageAuthorRequest) (domain.GetChannelMessageAuthorResult, error) + CreateForumTopic(ctx context.Context, userID int64, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) + EditForumTopic(ctx context.Context, userID int64, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) + UpdatePinnedForumTopic(ctx context.Context, userID int64, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) + ReorderPinnedForumTopics(ctx context.Context, userID int64, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) + DeleteForumTopicHistory(ctx context.Context, userID int64, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) + GetForumTopics(ctx context.Context, userID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) + GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) + SendMessage(ctx context.Context, userID int64, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) + EditMessage(ctx context.Context, userID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) + DeleteMessages(ctx context.Context, userID int64, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) + DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) + DeleteParticipantHistory(ctx context.Context, userID int64, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) + UpdatePinnedMessage(ctx context.Context, userID int64, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) + ExportInvite(ctx context.Context, userID int64, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) + CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) + ImportInvite(ctx context.Context, userID int64, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) + ListExportedInvites(ctx context.Context, userID int64, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) + GetExportedInvite(ctx context.Context, userID int64, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) + EditExportedInvite(ctx context.Context, userID int64, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) + DeleteExportedInvite(ctx context.Context, userID int64, req domain.DeleteChannelInviteRequest) error + DeleteRevokedExportedInvites(ctx context.Context, userID int64, req domain.DeleteRevokedChannelInvitesRequest) error + ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) + ListInviteImporters(ctx context.Context, userID int64, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) + PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) + HideChatJoinRequest(ctx context.Context, userID int64, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) + HideAllChatJoinRequests(ctx context.Context, userID int64, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) + CommonChannels(ctx context.Context, userID int64, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) + LeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) + InactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) + ChannelRecommendations(ctx context.Context, userID int64, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) + DiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) + SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) + SetViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) + GetHistory(ctx context.Context, userID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) + SearchPosts(ctx context.Context, userID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) + SearchJoinedMessages(ctx context.Context, userID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) + GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error) + GetReplies(ctx context.Context, userID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) + GetUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) + ReadMentions(ctx context.Context, userID int64, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) + GetUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) + ReadReactions(ctx context.Context, userID int64, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) + GetDiscussionMessage(ctx context.Context, userID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) + ReadHistory(ctx context.Context, userID int64, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) + GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) + GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) + ActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) + ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) + InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) + FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) +} + +// FilesService 抽象文件上传分片、下载与媒体(document/photo)组装。 +// 方法只用 domain 类型;rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 转换。 +type FilesService interface { + SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error) + SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64, part, totalParts int, bytes []byte) (bool, error) + GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) + // 资源读取(reaction / sticker / document)。 + ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) + GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) + ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (set domain.StickerSet, documents []domain.Document, found bool, err error) + ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) + // 头像(profile photo)与消息媒体组装。 + CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) + CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) + CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) + GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) + GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) + UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) + SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) + CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) + GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error) + DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) +} + +// LangPackService 抽象客户端语言包查询。 +type LangPackService interface { + GetLangPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) + GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) + GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) +} + +// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。 +type Deps struct { + Auth AuthService + Account AccountService + Help HelpService + Users UsersService + Updates UpdatesService + Contacts ContactsService + Dialogs DialogsService + Messages MessagesService + Channels ChannelsService + Files FilesService + LangPack LangPackService + Sessions SessionBinder + Limiter RateLimiter + Metrics Metrics +} diff --git a/internal/rpc/doc.go b/internal/rpc/doc.go new file mode 100644 index 00000000..eb8041f7 --- /dev/null +++ b/internal/rpc/doc.go @@ -0,0 +1,5 @@ +// Package rpc 是按 TypeID 路由的 RPC 层:封装 tg.ServerDispatcher(或等价薄封装), +// 在 handler 边界把 gotd/td/tg 类型转换为内部 domain command/query,统一 tgerr.Error 到 +// rpc_error 的映射,注入 auth_key_id/session_id/user_id/layer/设备/语言 等上下文, +// 并对未知 RPC 进入 compatibility trace(不静默吞掉,记入 docs/compatibility-matrix.md)。 +package rpc diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go new file mode 100644 index 00000000..986811ec --- /dev/null +++ b/internal/rpc/errors.go @@ -0,0 +1,194 @@ +package rpc + +import ( + "errors" + "fmt" + + "github.com/gotd/td/tgerr" + + "telesrv/internal/app/auth" + "telesrv/internal/domain" +) + +// 本文件集中构造所有 rpc_error:error_code 遵循 Telegram 约定(4xx 客户端 / 5xx 服务端), +// error_message 为 SCREAMING_SNAKE_CASE。统一用 tgerr.New,由它从 message 解析 Type/Argument +// (正确处理 FLOOD_WAIT_X 等带参数错误),避免手写 &tgerr.Error{} 时重复且易错。 + +// internalErr 是兜底的服务端错误。 +func internalErr() error { return tgerr.New(500, "INTERNAL_SERVER_ERROR") } + +// notImplementedErr 表示 RPC 未实现(落兼容矩阵),让客户端继续运行而非断连。 +func notImplementedErr() error { return tgerr.New(500, "NOT_IMPLEMENTED") } + +// wrapperTooDeepErr 表示 invokeWithLayer/initConnection 等 wrapper 嵌套过深。 +func wrapperTooDeepErr() error { return tgerr.New(400, "WRAPPER_TOO_DEEP") } + +// inputConstructorInvalidErr 表示客户端传入的 TL 构造器不在当前 RPC 接受范围内。 +func inputConstructorInvalidErr() error { return tgerr.New(400, "INPUT_CONSTRUCTOR_INVALID") } + +// folderIDInvalidErr 表示客户端传入多个 folder peer 或非法 folder。 +func folderIDInvalidErr() error { return tgerr.New(400, "FOLDER_ID_INVALID") } + +func filterIDInvalidErr() error { return tgerr.New(400, "FILTER_ID_INVALID") } + +func filterTitleEmptyErr() error { return tgerr.New(400, "FILTER_TITLE_EMPTY") } + +// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。 +func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") } + +func parentPeerInvalidErr() error { return tgerr.New(400, "PARENT_PEER_INVALID") } + +func sendAsPeerInvalidErr() error { return tgerr.New(400, "SEND_AS_PEER_INVALID") } + +func limitInvalidErr() error { return tgerr.New(400, "LIMIT_INVALID") } + +func secondsInvalidErr() error { return tgerr.New(400, "SECONDS_INVALID") } + +func ttlPeriodInvalidErr() error { return tgerr.New(400, "TTL_PERIOD_INVALID") } + +func chatInvalidErr() error { return tgerr.New(500, "CHAT_INVALID") } + +func addressInvalidErr() error { return tgerr.New(400, "ADDRESS_INVALID") } + +func mediaInvalidErr() error { return tgerr.New(400, "MEDIA_INVALID") } + +// 文件上传 / 下载相关错误。 +func filePartInvalidErr() error { return tgerr.New(400, "FILE_PART_INVALID") } +func filePartsInvalidErr() error { return tgerr.New(400, "FILE_PARTS_INVALID") } +func filePartTooBigErr() error { return tgerr.New(400, "FILE_PART_TOO_BIG") } +func fileReferenceInvalidErr() error { return tgerr.New(400, "FILE_REFERENCE_INVALID") } +func locationInvalidErr() error { return tgerr.New(400, "LOCATION_INVALID") } +func fileIDInvalidErr() error { return tgerr.New(400, "FILE_ID_INVALID") } + +func mediaEmptyErr() error { return tgerr.New(400, "MEDIA_EMPTY") } + +func photoInvalidErr() error { return tgerr.New(400, "PHOTO_INVALID") } + +func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID") } + +func mediaCaptionTooLongErr() error { return tgerr.New(400, "MEDIA_CAPTION_TOO_LONG") } + +func replyMarkupInvalidErr() error { return tgerr.New(400, "REPLY_MARKUP_INVALID") } + +func shortcutInvalidErr() error { return tgerr.New(400, "SHORTCUT_INVALID") } + +func effectIDInvalidErr() error { return tgerr.New(400, "EFFECT_ID_INVALID") } + +func paymentUnsupportedErr() error { return tgerr.New(406, "PAYMENT_UNSUPPORTED") } + +func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") } + +func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") } + +func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") } + +func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") } + +func replyToMonoforumPeerInvalidErr() error { return tgerr.New(400, "REPLY_TO_MONOFORUM_PEER_INVALID") } + +func optionsTooMuchErr() error { return tgerr.New(400, "OPTIONS_TOO_MUCH") } + +func optionInvalidErr() error { return tgerr.New(400, "OPTION_INVALID") } + +func pollOptionInvalidErr() error { return tgerr.New(400, "POLL_OPTION_INVALID") } + +func pollAnswerInvalidErr() error { return tgerr.New(400, "POLL_ANSWER_INVALID") } + +func reactionInvalidErr() error { return tgerr.New(400, "REACTION_INVALID") } + +func todoItemsEmptyErr() error { return tgerr.New(400, "TODO_ITEMS_EMPTY") } + +func todoNotModifiedErr() error { return tgerr.New(400, "TODO_NOT_MODIFIED") } + +func searchQueryEmptyErr() error { return tgerr.New(400, "SEARCH_QUERY_EMPTY") } + +func queryTooShortErr() error { return tgerr.New(400, "QUERY_TOO_SHORT") } + +func usernameInvalidErr() error { return tgerr.New(400, "USERNAME_INVALID") } + +func usernameOccupiedErr() error { return tgerr.New(400, "USERNAME_OCCUPIED") } + +func usernameNotOccupiedErr() error { return tgerr.New(400, "USERNAME_NOT_OCCUPIED") } + +func usernameNotModifiedErr() error { return tgerr.New(400, "USERNAME_NOT_MODIFIED") } + +func phoneNotOccupiedErr() error { return tgerr.New(400, "PHONE_NOT_OCCUPIED") } + +func userIDInvalidErr() error { return tgerr.New(400, "USER_ID_INVALID") } + +func usersTooFewErr() error { return tgerr.New(400, "USERS_TOO_FEW") } + +func firstNameInvalidErr() error { return tgerr.New(400, "FIRSTNAME_INVALID") } + +func aboutTooLongErr() error { return tgerr.New(400, "ABOUT_TOO_LONG") } + +func contactIDInvalidErr() error { return tgerr.New(400, "CONTACT_ID_INVALID") } + +func contactNameEmptyErr() error { return tgerr.New(400, "CONTACT_NAME_EMPTY") } + +// messageEmptyErr 表示发送空文本。 +func messageEmptyErr() error { return tgerr.New(400, "MESSAGE_EMPTY") } + +// messageTooLongErr 表示文本超出当前阶段限制。 +func messageTooLongErr() error { return tgerr.New(400, "MESSAGE_TOO_LONG") } + +func messageIDInvalidErr() error { return tgerr.New(400, "MESSAGE_ID_INVALID") } + +func msgIDInvalidErr() error { return tgerr.New(400, "MSG_ID_INVALID") } + +func messageAuthorRequiredErr() error { return tgerr.New(403, "MESSAGE_AUTHOR_REQUIRED") } + +func messageNotModifiedErr() error { return tgerr.New(400, "MESSAGE_NOT_MODIFIED") } + +func messageNotReadYetErr() error { return tgerr.New(400, "MESSAGE_NOT_READ_YET") } + +func replyMessageIDInvalidErr() error { return tgerr.New(400, "REPLY_MESSAGE_ID_INVALID") } + +func chatForwardsRestrictedErr() error { return tgerr.New(400, "CHAT_FORWARDS_RESTRICTED") } + +func inputRequestInvalidErr() error { return tgerr.New(400, "INPUT_REQUEST_INVALID") } + +func persistentTimestampInvalidErr() error { return tgerr.New(400, "PERSISTENT_TIMESTAMP_INVALID") } + +func channelForumMissingErr() error { return tgerr.New(400, "CHANNEL_FORUM_MISSING") } + +func topicTitleEmptyErr() error { return tgerr.New(400, "TOPIC_TITLE_EMPTY") } +func topicIDInvalidErr() error { return tgerr.New(400, "TOPIC_ID_INVALID") } + +// randomIDEmptyErr 表示发送消息缺少 random_id。 +func randomIDEmptyErr() error { return tgerr.New(400, "RANDOM_ID_EMPTY") } + +// scheduleDateInvalidErr 表示当前阶段不支持定时消息。 +func scheduleDateInvalidErr() error { return tgerr.New(400, "SCHEDULE_DATE_INVALID") } + +// floodWaitErr 表示触发写操作限流。 +func floodWaitErr(seconds int) error { + if seconds <= 0 { + seconds = 1 + } + return tgerr.New(420, fmt.Sprintf("FLOOD_WAIT_%d", seconds)) +} + +// signInErr 把登录业务错误映射为客户端可识别的 rpc_error。 +func signInErr(err error) error { + switch { + case errors.Is(err, auth.ErrCodeInvalid): + return tgerr.New(400, "PHONE_CODE_INVALID") + case errors.Is(err, auth.ErrCodeExpired): + return tgerr.New(400, "PHONE_CODE_EXPIRED") + case errors.Is(err, domain.ErrFirstNameInvalid): + return firstNameInvalidErr() + default: + return internalErr() + } +} + +// bindTempAuthKeyErr 映射 PFS temp auth key 绑定错误。 +func bindTempAuthKeyErr(err error) error { + switch { + case errors.Is(err, auth.ErrEncryptedMessageInvalid): + return tgerr.New(400, "ENCRYPTED_MESSAGE_INVALID") + default: + return internalErr() + } +} diff --git a/internal/rpc/folders.go b/internal/rpc/folders.go new file mode 100644 index 00000000..7fc60d61 --- /dev/null +++ b/internal/rpc/folders.go @@ -0,0 +1,67 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) registerFolders(d *tg.ServerDispatcher) { + d.OnFoldersEditPeerFolders(r.onFoldersEditPeerFolders) +} + +func (r *Router) onFoldersEditPeerFolders(ctx context.Context, folderPeers []tg.InputFolderPeer) (tg.UpdatesClass, error) { + if len(folderPeers) > maxDialogInputPeers { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + updates := make([]domain.FolderPeerUpdate, 0, len(folderPeers)) + seen := make(map[domain.Peer]struct{}, len(folderPeers)) + for _, item := range folderPeers { + if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { + return nil, folderIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, item.Peer) + if err != nil { + return nil, err + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + updates = append(updates, domain.FolderPeerUpdate{Peer: peer, FolderID: item.FolderID}) + } + if len(updates) == 0 { + return &tg.Updates{Date: int(r.clock.Now().Unix()), Seq: 0}, nil + } + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.EditPeerFolders(ctx, userID, updates); err != nil { + return nil, internalErr() + } + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventFolderPeers, + FolderPeers: updates, + PtsCount: 1, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordFolderPeers(ctx, authKeyID, userID, updates, sessionID) + if err != nil { + return nil, internalErr() + } + } + out := tgUpdateForOutboxEvent(event) + if out == nil { + out = &tg.Updates{Date: event.Date, Seq: 0} + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out) + return out, nil +} diff --git a/internal/rpc/help.go b/internal/rpc/help.go new file mode 100644 index 00000000..8d2b713e --- /dev/null +++ b/internal/rpc/help.go @@ -0,0 +1,63 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" +) + +// registerHelp 注册 help.* RPC handler(DC 配置、最近 DC)。 +func (r *Router) registerHelp(d *tg.ServerDispatcher) { + d.OnHelpGetConfig(func(ctx context.Context) (*tg.Config, error) { + return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now()), nil + }) + d.OnHelpGetNearestDC(func(ctx context.Context) (*tg.NearestDC, error) { + return tdesktop.NearestDC(r.cfg.DC), nil + }) + d.OnHelpGetAppConfig(func(ctx context.Context, hash int) (tg.HelpAppConfigClass, error) { + if r.deps.Help == nil { + return tdesktop.AppConfig(hash), nil + } + cfg, notModified, err := r.deps.Help.GetAppConfig(ctx, hash) + if err != nil { + return nil, internalErr() + } + if notModified { + return &tg.HelpAppConfigNotModified{}, nil + } + return &tg.HelpAppConfig{Hash: cfg.Hash, Config: tgJSONValue(cfg.JSON)}, nil + }) + d.OnHelpGetCountriesList(func(ctx context.Context, req *tg.HelpGetCountriesListRequest) (tg.HelpCountriesListClass, error) { + if r.deps.Help == nil { + return tdesktop.CountriesList(req.Hash), nil + } + list, notModified, err := r.deps.Help.GetCountries(ctx, req.LangCode, req.Hash) + if err != nil { + return nil, internalErr() + } + if notModified { + return &tg.HelpCountriesListNotModified{}, nil + } + return tgCountriesList(list), nil + }) + d.OnHelpGetTimezonesList(func(ctx context.Context, hash int) (tg.HelpTimezonesListClass, error) { + return tdesktop.TimezonesList(hash), nil + }) + d.OnHelpGetPeerColors(func(ctx context.Context, hash int) (tg.HelpPeerColorsClass, error) { + return tdesktop.PeerColors(), nil + }) + d.OnHelpGetPeerProfileColors(func(ctx context.Context, hash int) (tg.HelpPeerColorsClass, error) { + return tdesktop.PeerColors(), nil + }) + d.OnHelpGetPromoData(func(ctx context.Context) (tg.HelpPromoDataClass, error) { + return tdesktop.PromoData(r.clock.Now()), nil + }) + d.OnHelpGetTermsOfServiceUpdate(func(ctx context.Context) (tg.HelpTermsOfServiceUpdateClass, error) { + return tdesktop.TermsOfServiceUpdate(r.clock.Now()), nil + }) + d.OnHelpGetPremiumPromo(func(ctx context.Context) (*tg.HelpPremiumPromo, error) { + return tdesktop.PremiumPromo(), nil + }) +} diff --git a/internal/rpc/langpack.go b/internal/rpc/langpack.go new file mode 100644 index 00000000..5914ef5a --- /dev/null +++ b/internal/rpc/langpack.go @@ -0,0 +1,41 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" +) + +// registerLangpack 注册 langpack.* RPC handler。 +func (r *Router) registerLangpack(d *tg.ServerDispatcher) { + d.OnLangpackGetLangPack(func(ctx context.Context, req *tg.LangpackGetLangPackRequest) (*tg.LangPackDifference, error) { + if r.deps.LangPack == nil { + return &tg.LangPackDifference{LangCode: req.LangCode}, nil + } + pack, err := r.deps.LangPack.GetLangPack(ctx, req.LangPack, req.LangCode) + if err != nil { + return nil, internalErr() + } + return tgLangPackDifference(pack), nil + }) + d.OnLangpackGetDifference(func(ctx context.Context, req *tg.LangpackGetDifferenceRequest) (*tg.LangPackDifference, error) { + if r.deps.LangPack == nil { + return &tg.LangPackDifference{LangCode: req.LangCode, FromVersion: req.FromVersion}, nil + } + pack, err := r.deps.LangPack.GetDifference(ctx, req.LangPack, req.LangCode, req.FromVersion) + if err != nil { + return nil, internalErr() + } + return tgLangPackDifference(pack), nil + }) + d.OnLangpackGetStrings(func(ctx context.Context, req *tg.LangpackGetStringsRequest) ([]tg.LangPackStringClass, error) { + if r.deps.LangPack == nil { + return nil, nil + } + pack, err := r.deps.LangPack.GetStrings(ctx, req.LangPack, req.LangCode, req.Keys) + if err != nil { + return nil, internalErr() + } + return tgLangPackStrings(pack.Strings), nil + }) +} diff --git a/internal/rpc/messages.go b/internal/rpc/messages.go new file mode 100644 index 00000000..b490a956 --- /dev/null +++ b/internal/rpc/messages.go @@ -0,0 +1,6365 @@ +package rpc + +import ( + "context" + "errors" + "hash/fnv" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +const ( + maxSendMessageTextLength = domain.MaxMessageTextLength + maxReplyQuoteLength = domain.MaxMessageReplyQuoteLength + maxMessageSearchQLength = 256 + maxMessageEntityCount = domain.MaxMessageEntityCount + maxGetMessagesIDs = 100 + maxMessageSearchFilters = 32 + maxForumTopicIDs = 100 + maxDialogInputPeers = 100 + maxSearchResultsLimit = 100 + maxReactionVector = 16 + maxReactionListOffset = 128 + maxReportOptionLength = 32 + maxReportCommentLength = 1024 + maxReportRandomIDLength = 128 + maxReadMetrics = 100 + maxBusinessConnIDLength = 128 + maxSendMultiMediaItems = 10 + maxForumTopicTitleLength = 128 + maxPollVoteOptions = 10 + maxPollOptionBytes = 256 + maxPollVotesOffsetLength = 128 + maxTodoItems = 30 + maxTodoTitleLength = 200 + maxCommonChatsLimit = domain.MaxCommonChannelsLimit + maxStickerSearchQLength = 128 + maxStickerSearchLangs = 16 + maxEmojiLangCodeLength = 32 + maxEmojiDocuments = 100 + maxSavedReactionTagTitle = 12 + defaultTopReactionsLimit = 14 + sendMessageRateLimit = 30 + sendMessageRateWindow = time.Minute + forumGeneralTopicID = 1 + forumGeneralIconColor = 0x6FB9F0 +) + +type accountDefaultReactionService interface { + SetDefaultReaction(ctx context.Context, userID int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error) +} + +type accountPaidReactionPrivacyService interface { + GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) + SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error) +} + +type messageReactionUpdateRecorder interface { + RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) +} + +type messageReactionUsageRecorder interface { + RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error +} + +type channelParticipantReactionModerator interface { + DeleteParticipantReaction(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) + DeleteParticipantReactions(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) +} + +// registerMessages 注册 messages.* RPC handler。 +func (r *Router) registerMessages(d *tg.ServerDispatcher) { + d.OnMessagesSetTyping(r.onMessagesSetTyping) + d.OnMessagesSaveDraft(r.onMessagesSaveDraft) + d.OnMessagesSaveDefaultSendAs(r.onMessagesSaveDefaultSendAs) + d.OnMessagesGetAllDrafts(r.onMessagesGetAllDrafts) + d.OnMessagesClearAllDrafts(r.onMessagesClearAllDrafts) + d.OnMessagesGetAllStickers(r.onMessagesGetAllStickers) + d.OnMessagesGetEmojiStickers(r.onMessagesGetEmojiStickers) + d.OnMessagesGetFeaturedStickers(func(ctx context.Context, hash int64) (tg.MessagesFeaturedStickersClass, error) { + return messagesFeaturedStickersEmpty(hash), nil + }) + d.OnMessagesGetFeaturedEmojiStickers(func(ctx context.Context, hash int64) (tg.MessagesFeaturedStickersClass, error) { + return messagesFeaturedStickersEmpty(hash), nil + }) + d.OnMessagesGetRecentStickers(func(ctx context.Context, req *tg.MessagesGetRecentStickersRequest) (tg.MessagesRecentStickersClass, error) { + if req.Hash != 0 { + return &tg.MessagesRecentStickersNotModified{}, nil + } + return &tg.MessagesRecentStickers{ + Packs: []tg.StickerPack{}, + Stickers: []tg.DocumentClass{}, + Dates: []int{}, + }, nil + }) + d.OnMessagesGetFavedStickers(func(ctx context.Context, hash int64) (tg.MessagesFavedStickersClass, error) { + if hash != 0 { + return &tg.MessagesFavedStickersNotModified{}, nil + } + return &tg.MessagesFavedStickers{ + Packs: []tg.StickerPack{}, + Stickers: []tg.DocumentClass{}, + }, nil + }) + d.OnMessagesGetSavedGifs(func(ctx context.Context, hash int64) (tg.MessagesSavedGifsClass, error) { + if hash != 0 { + return &tg.MessagesSavedGifsNotModified{}, nil + } + return &tg.MessagesSavedGifs{Gifs: []tg.DocumentClass{}}, nil + }) + d.OnMessagesSendMessage(r.onMessagesSendMessage) + d.OnMessagesForwardMessages(r.onMessagesForwardMessages) + d.OnMessagesGetDialogFilters(r.onMessagesGetDialogFilters) + d.OnMessagesGetSuggestedDialogFilters(func(ctx context.Context) ([]tg.DialogFilterSuggested, error) { + return []tg.DialogFilterSuggested{}, nil + }) + d.OnMessagesUpdateDialogFilter(r.onMessagesUpdateDialogFilter) + d.OnMessagesUpdateDialogFiltersOrder(r.onMessagesUpdateDialogFiltersOrder) + d.OnMessagesToggleDialogFilterTags(r.onMessagesToggleDialogFilterTags) + d.OnMessagesGetSavedDialogs(func(ctx context.Context, req *tg.MessagesGetSavedDialogsRequest) (tg.MessagesSavedDialogsClass, error) { + if req.Hash != 0 { + return &tg.MessagesSavedDialogsNotModified{Count: 0}, nil + } + return &tg.MessagesSavedDialogs{}, nil + }) + d.OnMessagesGetPinnedSavedDialogs(func(ctx context.Context) (tg.MessagesSavedDialogsClass, error) { + return &tg.MessagesSavedDialogs{}, nil + }) + d.OnMessagesToggleSavedDialogPin(func(ctx context.Context, req *tg.MessagesToggleSavedDialogPinRequest) (bool, error) { + return true, nil + }) + d.OnMessagesReorderPinnedSavedDialogs(func(ctx context.Context, req *tg.MessagesReorderPinnedSavedDialogsRequest) (bool, error) { + return true, nil + }) + d.OnMessagesGetSavedDialogsByID(func(ctx context.Context, req *tg.MessagesGetSavedDialogsByIDRequest) (tg.MessagesSavedDialogsClass, error) { + return &tg.MessagesSavedDialogs{}, nil + }) + d.OnMessagesGetSavedHistory(r.onMessagesGetSavedHistory) + d.OnMessagesReadSavedHistory(r.onMessagesReadSavedHistory) + d.OnMessagesDeleteSavedHistory(r.onMessagesDeleteSavedHistory) + d.OnMessagesGetCommonChats(r.onMessagesGetCommonChats) + d.OnMessagesGetDefaultHistoryTTL(r.onMessagesGetDefaultHistoryTTL) + d.OnMessagesGetSponsoredMessages(r.onMessagesGetSponsoredMessages) + d.OnMessagesGetWebPagePreview(r.onMessagesGetWebPagePreview) + d.OnMessagesUploadMedia(r.onMessagesUploadMedia) + d.OnMessagesSendMedia(r.onMessagesSendMedia) + d.OnMessagesSendMultiMedia(r.onMessagesSendMultiMedia) + d.OnMessagesReportSpam(r.onMessagesReportSpam) + d.OnMessagesReport(r.onMessagesReport) + d.OnMessagesReportReaction(r.onMessagesReportReaction) + d.OnMessagesReportMessagesDelivery(r.onMessagesReportMessagesDelivery) + d.OnMessagesReportReadMetrics(r.onMessagesReportReadMetrics) + d.OnMessagesReportMusicListen(r.onMessagesReportMusicListen) + d.OnMessagesReportSponsoredMessage(r.onMessagesReportSponsoredMessage) + d.OnMessagesReadMessageContents(r.onMessagesReadMessageContents) + d.OnMessagesGetMessagesViews(r.onMessagesGetMessagesViews) + d.OnMessagesGetUnreadMentions(r.onMessagesGetUnreadMentions) + d.OnMessagesReadMentions(r.onMessagesReadMentions) + d.OnMessagesGetSearchCounters(r.onMessagesGetSearchCounters) + d.OnMessagesGetReplies(r.onMessagesGetReplies) + d.OnMessagesGetDiscussionMessage(r.onMessagesGetDiscussionMessage) + d.OnMessagesReadDiscussion(r.onMessagesReadDiscussion) + d.OnMessagesGetForumTopics(r.onMessagesGetForumTopics) + d.OnMessagesGetForumTopicsByID(r.onMessagesGetForumTopicsByID) + d.OnMessagesGetOnlines(r.onMessagesGetOnlines) + d.OnMessagesGetAvailableReactions(r.onMessagesGetAvailableReactions) + d.OnMessagesGetAvailableEffects(func(ctx context.Context, hash int) (tg.MessagesAvailableEffectsClass, error) { + return &tg.MessagesAvailableEffects{ + Hash: 0, + Effects: []tg.AvailableEffect{}, + Documents: []tg.DocumentClass{}, + }, nil + }) + d.OnMessagesGetStickers(func(ctx context.Context, req *tg.MessagesGetStickersRequest) (tg.MessagesStickersClass, error) { + return tdesktop.Stickers(), nil + }) + d.OnMessagesGetStickerSet(r.onMessagesGetStickerSet) + d.OnMessagesGetEmojiGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { + return tdesktop.EmojiGroups(), nil + }) + d.OnMessagesGetEmojiStickerGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { + return tdesktop.EmojiGroups(), nil + }) + d.OnMessagesGetEmojiProfilePhotoGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { + return tdesktop.EmojiProfilePhotoGroups(), nil + }) + d.OnMessagesGetEmojiKeywords(r.onMessagesGetEmojiKeywords) + d.OnMessagesGetEmojiKeywordsDifference(r.onMessagesGetEmojiKeywordsDifference) + d.OnMessagesGetEmojiKeywordsLanguages(func(ctx context.Context, langcodes []string) ([]tg.EmojiLanguage, error) { + return []tg.EmojiLanguage{}, nil + }) + d.OnMessagesGetCustomEmojiDocuments(r.onMessagesGetCustomEmojiDocuments) + d.OnMessagesGetAttachedStickers(r.onMessagesGetAttachedStickers) + d.OnMessagesSearchStickerSets(r.onMessagesSearchStickerSets) + d.OnMessagesSearchStickers(r.onMessagesSearchStickers) + d.OnMessagesGetAttachMenuBots(func(ctx context.Context, hash int64) (tg.AttachMenuBotsClass, error) { + return tdesktop.AttachMenuBots(), nil + }) + d.OnMessagesGetQuickReplies(func(ctx context.Context, hash int64) (tg.MessagesQuickRepliesClass, error) { + return tdesktop.QuickReplies(), nil + }) + d.OnMessagesGetWebPage(func(ctx context.Context, req *tg.MessagesGetWebPageRequest) (*tg.MessagesWebPage, error) { + return tdesktop.WebPage(req.URL), nil + }) + d.OnMessagesGetDialogs(func(ctx context.Context, req *tg.MessagesGetDialogsRequest) (tg.MessagesDialogsClass, error) { + if r.deps.Dialogs == nil { + return &tg.MessagesDialogs{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + filter, err := r.dialogFilterFromRequest(ctx, userID, req) + if err != nil { + return nil, err + } + list, err := r.deps.Dialogs.GetDialogs(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + if filter.Hash != 0 && list.Hash == filter.Hash { + return &tg.MessagesDialogsNotModified{Count: list.Count}, nil + } + return tgMessagesDialogs(userID, r.withDialogListPresence(list)), nil + }) + d.OnMessagesGetPinnedDialogs(func(ctx context.Context, folderID int) (*tg.MessagesPeerDialogs, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + var list domain.DialogList + if r.deps.Dialogs != nil { + list, err = r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{ + PinnedOnly: true, + HasFolderID: true, + FolderID: folderID, + Limit: 100, + }) + if err != nil { + return nil, internalErr() + } + } + st := domain.UpdateState{Date: int(r.clock.Now().Unix())} + if r.deps.Updates != nil { + var err error + st, err = r.deps.Updates.GetState(ctx, id, userID) + if err != nil { + return nil, internalErr() + } + } + return tgPeerDialogs(userID, r.withDialogListPresence(list), st), nil + }) + d.OnMessagesGetPeerDialogs(func(ctx context.Context, peers []tg.InputDialogPeerClass) (*tg.MessagesPeerDialogs, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + domainPeers, err := r.dialogPeersFromInput(ctx, userID, peers) + if err != nil { + return nil, err + } + var list domain.DialogList + if len(domainPeers) > 0 && r.deps.Dialogs != nil { + var err error + list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, domainPeers) + if err != nil { + return nil, internalErr() + } + } + st := domain.UpdateState{Date: int(r.clock.Now().Unix())} + if r.deps.Updates != nil { + var err error + st, err = r.deps.Updates.GetState(ctx, id, userID) + if err != nil { + return nil, internalErr() + } + } + r.trackChannelInterest(ctx, userID, channelIDsFromDialogs(list)...) + return tgPeerDialogs(userID, r.withDialogListPresence(list), st), nil + }) + d.OnMessagesGetPeerSettings(r.onMessagesGetPeerSettings) + d.OnMessagesToggleDialogPin(r.onMessagesToggleDialogPin) + d.OnMessagesReorderPinnedDialogs(r.onMessagesReorderPinnedDialogs) + d.OnMessagesMarkDialogUnread(r.onMessagesMarkDialogUnread) + d.OnMessagesGetDialogUnreadMarks(r.onMessagesGetDialogUnreadMarks) + d.OnMessagesHidePeerSettingsBar(r.onMessagesHidePeerSettingsBar) + d.OnMessagesGetMessageEditData(r.onMessagesGetMessageEditData) + d.OnMessagesEditMessage(r.onMessagesEditMessage) + d.OnMessagesGetOutboxReadDate(r.onMessagesGetOutboxReadDate) + d.OnMessagesGetMessageReadParticipants(r.onMessagesGetMessageReadParticipants) + d.OnMessagesDeleteMessages(r.onMessagesDeleteMessages) + d.OnMessagesDeleteHistory(r.onMessagesDeleteHistory) + d.OnMessagesGetMessages(r.onMessagesGetMessages) + d.OnMessagesGetHistory(func(ctx context.Context, req *tg.MessagesGetHistoryRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + filter, ok := r.messageFilterFromHistoryRequest(userID, req) + if !ok { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + if filter.Peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil { + return nil, err + } + if isLegacyInputPeerChat(req.Peer) { + return &tg.MessagesMessages{}, nil + } + history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{ + ChannelID: filter.Peer.ID, + OffsetID: filter.OffsetID, + OffsetDate: filter.OffsetDate, + AddOffset: filter.AddOffset, + Limit: filter.Limit, + MaxID: filter.MaxID, + MinID: filter.MinID, + Hash: filter.Hash, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + r.trackChannelInterest(ctx, userID, filter.Peer.ID) + if filter.Hash != 0 && history.Hash == filter.Hash { + return &tg.MessagesMessagesNotModified{Count: history.Count}, nil + } + return tgChannelHistoryMessages(userID, history), nil + } + r.clearChannelInterest(ctx, userID) + if r.deps.Messages == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + list, err := r.deps.Messages.GetHistory(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + if filter.Hash != 0 && list.Hash == filter.Hash { + return &tg.MessagesMessagesNotModified{Count: list.Count}, nil + } + return tgMessagesMessages(r.withMessageListPresence(list)), nil + }) + d.OnMessagesReadHistory(func(ctx context.Context, req *tg.MessagesReadHistoryRequest) (*tg.MessagesAffectedMessages, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, peerErr := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if peerErr == nil && peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ + UserID: userID, + ChannelID: peer.ID, + MaxID: req.MaxID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + event, err := r.recordChannelReadInbox(ctx, userID, read) + if err != nil { + return nil, err + } + r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) + if event.Pts != 0 { + return &tg.MessagesAffectedMessages{Pts: event.Pts, PtsCount: event.PtsCount}, nil + } + return r.affectedMessages(ctx, id, userID) + } + if peerErr != nil { + return nil, peerErr + } + if r.deps.Messages != nil { + sessionID, _ := SessionIDFrom(ctx) + read, err := r.deps.Messages.ReadHistory(ctx, userID, domain.ReadHistoryRequest{ + OwnerUserID: userID, + Peer: peer, + MaxID: req.MaxID, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: id, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, internalErr() + } + if read.Changed && read.InboxEvent.Pts != 0 { + r.pushReadHistoryEvent(ctx, read.OwnerUserID, read.InboxEvent) + if read.OutboxChanged && read.OutboxEvent.Pts != 0 { + r.pushReadHistoryEvent(ctx, read.OutboxUserID, read.OutboxEvent) + } + return &tg.MessagesAffectedMessages{Pts: read.InboxEvent.Pts, PtsCount: read.InboxEvent.PtsCount}, nil + } + } + return r.affectedMessages(ctx, id, userID) + }) + d.OnMessagesSearch(func(ctx context.Context, req *tg.MessagesSearchRequest) (tg.MessagesMessagesClass, error) { + if utf8.RuneCountInString(req.Q) > maxMessageSearchQLength { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + filter := r.messageFilterFromSearchRequest(userID, req) + if filter.HasPeer && filter.Peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil { + return nil, err + } + if isLegacyInputPeerChat(req.Peer) { + return &tg.MessagesMessages{}, nil + } + if searchFilterNeedsMediaStore(req.Filter) { + view, err := r.deps.Channels.GetChannel(ctx, userID, filter.Peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + return tgChannelHistoryMessages(userID, domain.ChannelHistory{Channel: view.Channel}), nil + } + chFilter, ok := r.channelHistoryFilterFromSearchRequest(userID, req, filter.Peer.ID) + if !ok { + return nil, peerIDInvalidErr() + } + history, err := r.deps.Channels.GetHistory(ctx, userID, chFilter) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + if chFilter.Hash != 0 && history.Hash == chFilter.Hash { + return &tg.MessagesMessagesNotModified{Count: history.Count}, nil + } + return tgChannelHistoryMessages(userID, history), nil + } + if r.deps.Messages == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + if searchFilterNeedsMediaStore(req.Filter) { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return &tg.MessagesMessages{}, nil + } + list, err := r.deps.Messages.Search(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + if filter.Hash != 0 && list.Hash == filter.Hash { + return &tg.MessagesMessagesNotModified{Count: list.Count}, nil + } + return tgMessagesMessages(r.withMessageListPresence(list)), nil + }) + d.OnMessagesSearchGlobal(r.onMessagesSearchGlobal) + d.OnMessagesGetSearchResultsCalendar(r.onMessagesGetSearchResultsCalendar) + d.OnMessagesGetSearchResultsPositions(r.onMessagesGetSearchResultsPositions) + d.OnMessagesSendReaction(r.onMessagesSendReaction) + d.OnMessagesGetMessagesReactions(r.onMessagesGetMessagesReactions) + d.OnMessagesGetMessageReactionsList(r.onMessagesGetMessageReactionsList) + d.OnMessagesSetDefaultReaction(r.onMessagesSetDefaultReaction) + d.OnMessagesGetPaidReactionPrivacy(r.onMessagesGetPaidReactionPrivacy) + d.OnMessagesTogglePaidReactionPrivacy(r.onMessagesTogglePaidReactionPrivacy) + d.OnMessagesSendPaidReaction(r.onMessagesSendPaidReaction) + d.OnMessagesDeleteParticipantReactions(r.onMessagesDeleteParticipantReactions) + d.OnMessagesDeleteParticipantReaction(r.onMessagesDeleteParticipantReaction) + d.OnMessagesGetUnreadReactions(r.onMessagesGetUnreadReactions) + d.OnMessagesReadReactions(r.onMessagesReadReactions) + d.OnMessagesGetTopReactions(r.onMessagesGetTopReactions) + d.OnMessagesGetRecentReactions(r.onMessagesGetRecentReactions) + d.OnMessagesClearRecentReactions(r.onMessagesClearRecentReactions) + d.OnMessagesGetSavedReactionTags(r.onMessagesGetSavedReactionTags) + d.OnMessagesUpdateSavedReactionTag(r.onMessagesUpdateSavedReactionTag) + d.OnMessagesGetDefaultTagReactions(r.onMessagesGetDefaultTagReactions) + d.OnMessagesSendVote(r.onMessagesSendVote) + d.OnMessagesGetPollResults(r.onMessagesGetPollResults) + d.OnMessagesGetPollVotes(r.onMessagesGetPollVotes) + d.OnMessagesAddPollAnswer(r.onMessagesAddPollAnswer) + d.OnMessagesDeletePollAnswer(r.onMessagesDeletePollAnswer) + d.OnMessagesGetUnreadPollVotes(r.onMessagesGetUnreadPollVotes) + d.OnMessagesReadPollVotes(r.onMessagesReadPollVotes) + d.OnMessagesAppendTodoList(r.onMessagesAppendTodoList) + d.OnMessagesToggleTodoCompleted(r.onMessagesToggleTodoCompleted) + d.OnMessagesGetScheduledHistory(r.onMessagesGetScheduledHistory) + d.OnMessagesGetScheduledMessages(r.onMessagesGetScheduledMessages) + d.OnMessagesSendScheduledMessages(r.onMessagesSendScheduledMessages) + d.OnMessagesDeleteScheduledMessages(r.onMessagesDeleteScheduledMessages) + d.OnMessagesCreateForumTopic(r.onMessagesCreateForumTopic) + d.OnMessagesEditForumTopic(r.onMessagesEditForumTopic) + d.OnMessagesUpdatePinnedForumTopic(r.onMessagesUpdatePinnedForumTopic) + d.OnMessagesReorderPinnedForumTopics(r.onMessagesReorderPinnedForumTopics) + d.OnMessagesDeleteTopicHistory(r.onMessagesDeleteTopicHistory) +} + +func (r *Router) onMessagesGetSavedHistory(ctx context.Context, req *tg.MessagesGetSavedHistoryRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateSavedHistoryBounds(req.OffsetID, req.OffsetDate, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { + return nil, err + } + parentPeer, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer) + if err != nil { + return nil, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if req.Hash != 0 { + return &tg.MessagesMessagesNotModified{Count: 0}, nil + } + chats := r.savedHistoryChats(ctx, userID, hasParent, parentPeer, req.Peer) + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: chats, + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesReadSavedHistory(ctx context.Context, req *tg.MessagesReadSavedHistoryRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + if err := r.validateRequiredSavedHistoryParentPeer(ctx, userID, req.ParentPeer); err != nil { + return false, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesDeleteSavedHistory(ctx context.Context, req *tg.MessagesDeleteSavedHistoryRequest) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if minDate, ok := req.GetMinDate(); ok && minDate < 0 { + return nil, limitInvalidErr() + } + if maxDate, ok := req.GetMaxDate(); ok && maxDate < 0 { + return nil, limitInvalidErr() + } + if _, _, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil { + return nil, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return r.affectedHistory(ctx, authKeyID, userID, 0) +} + +func (r *Router) onMessagesGetCommonChats(ctx context.Context, req *tg.MessagesGetCommonChatsRequest) (tg.MessagesChatsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > maxCommonChatsLimit { + return nil, limitInvalidErr() + } + if req.MaxID < 0 { + return nil, messageIDInvalidErr() + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found || target.ID == 0 || target.ID == userID { + return nil, userIDInvalidErr() + } + if req.Limit == 0 || r.deps.Channels == nil { + return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil + } + common, err := r.deps.Channels.CommonChannels(ctx, userID, domain.CommonChannelsRequest{ + UserID: userID, + TargetUserID: target.ID, + MaxID: req.MaxID, + Limit: req.Limit, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + chats := make([]tg.ChatClass, 0, len(common.Channels)) + for _, ch := range common.Channels { + chats = append(chats, tgChannelChat(userID, ch, nil)) + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onMessagesGetAttachedStickers(ctx context.Context, media tg.InputStickeredMediaClass) ([]tg.StickerSetCoveredClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if media == nil { + return nil, mediaEmptyErr() + } + return []tg.StickerSetCoveredClass{}, nil +} + +func (r *Router) onMessagesGetCustomEmojiDocuments(ctx context.Context, documentIDs []int64) ([]tg.DocumentClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if len(documentIDs) > maxEmojiDocuments { + return nil, limitInvalidErr() + } + for _, id := range documentIDs { + if id <= 0 { + return nil, messageIDInvalidErr() + } + } + if r.deps.Files == nil || len(documentIDs) == 0 { + return []tg.DocumentClass{}, nil + } + docs, err := r.deps.Files.GetDocuments(ctx, documentIDs) + if err != nil { + return nil, internalErr() + } + byID := documentsByID(docs) + out := make([]tg.DocumentClass, 0, len(documentIDs)) + for _, id := range documentIDs { + if d, ok := byID[id]; ok { + out = append(out, tgDocument(d)) + } else { + out = append(out, &tg.DocumentEmpty{ID: id}) + } + } + return out, nil +} + +func (r *Router) onMessagesSearchStickerSets(ctx context.Context, req *tg.MessagesSearchStickerSetsRequest) (tg.MessagesFoundStickerSetsClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength { + return nil, limitInvalidErr() + } + if req.Hash != 0 { + return &tg.MessagesFoundStickerSetsNotModified{}, nil + } + return &tg.MessagesFoundStickerSets{ + Hash: 0, + Sets: []tg.StickerSetCoveredClass{}, + }, nil +} + +func (r *Router) onMessagesSearchStickers(ctx context.Context, req *tg.MessagesSearchStickersRequest) (tg.MessagesFoundStickersClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if req.Offset < 0 || req.Offset > domain.MaxMessageBoxID || req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength || utf8.RuneCountInString(req.Emoticon) > maxStickerSearchQLength { + return nil, limitInvalidErr() + } + if len(req.LangCode) > maxStickerSearchLangs { + return nil, limitInvalidErr() + } + for _, lang := range req.LangCode { + if err := validateEmojiLangCode(lang); err != nil { + return nil, err + } + } + if req.Hash != 0 { + return &tg.MessagesFoundStickersNotModified{}, nil + } + return &tg.MessagesFoundStickers{ + Hash: 0, + Stickers: []tg.DocumentClass{}, + }, nil +} + +func (r *Router) onMessagesGetEmojiKeywords(ctx context.Context, langcode string) (*tg.EmojiKeywordsDifference, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if err := validateEmojiLangCode(langcode); err != nil { + return nil, err + } + return &tg.EmojiKeywordsDifference{ + LangCode: langcode, + FromVersion: 0, + Version: 0, + Keywords: []tg.EmojiKeywordClass{}, + }, nil +} + +func (r *Router) onMessagesGetEmojiKeywordsDifference(ctx context.Context, req *tg.MessagesGetEmojiKeywordsDifferenceRequest) (*tg.EmojiKeywordsDifference, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if err := validateEmojiLangCode(req.LangCode); err != nil { + return nil, err + } + if req.FromVersion < 0 { + return nil, limitInvalidErr() + } + return &tg.EmojiKeywordsDifference{ + LangCode: req.LangCode, + FromVersion: req.FromVersion, + Version: req.FromVersion, + Keywords: []tg.EmojiKeywordClass{}, + }, nil +} + +func (r *Router) onMessagesGetExtendedMedia(ctx context.Context, req *tg.MessagesGetExtendedMediaRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.ID); err != nil { + return nil, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} + +func (r *Router) onMessagesGetTopReactions(ctx context.Context, req *tg.MessagesGetTopReactionsRequest) (tg.MessagesReactionsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + limit := req.Limit + if limit == 0 { + limit = defaultTopReactionsLimit + } + reactions := []domain.MessageReaction{} + if r.deps.Channels != nil { + var err error + reactions, err = r.deps.Channels.TopReactions(ctx, userID, limit) + if err != nil { + return nil, channelInvalidErr(err) + } + } + return messagesReactionsFromDomain(r.reactionsWithCatalogFallback(ctx, reactions, limit), req.Hash), nil +} + +func (r *Router) onMessagesGetRecentReactions(ctx context.Context, req *tg.MessagesGetRecentReactionsRequest) (tg.MessagesReactionsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if r.deps.Channels == nil { + return messagesReactionsEmpty(req.Hash), nil + } + reactions, err := r.deps.Channels.RecentReactions(ctx, userID, req.Limit) + if err != nil { + return nil, channelInvalidErr(err) + } + return messagesReactionsFromDomain(reactions, req.Hash), nil +} + +func (r *Router) onMessagesClearRecentReactions(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Channels != nil { + if err := r.deps.Channels.ClearRecentReactions(ctx, userID); err != nil { + return false, channelInvalidErr(err) + } + } + return true, nil +} + +func (r *Router) onMessagesGetSavedReactionTags(ctx context.Context, req *tg.MessagesGetSavedReactionTagsRequest) (tg.MessagesSavedReactionTagsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if peer, ok := req.GetPeer(); ok && peer != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { + return nil, err + } + return savedReactionTagsEmpty(req.Hash), nil + } + if r.deps.Channels == nil { + return savedReactionTagsEmpty(req.Hash), nil + } + tags, err := r.deps.Channels.SavedReactionTags(ctx, userID, domain.MaxSavedReactionTags) + if err != nil { + return nil, channelInvalidErr(err) + } + return savedReactionTagsFromDomain(tags, req.Hash), nil +} + +func (r *Router) onMessagesUpdateSavedReactionTag(ctx context.Context, req *tg.MessagesUpdateSavedReactionTagRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + reaction, err := domainMessageReactionFromTL(req.Reaction) + if err != nil { + return false, err + } + title, ok := req.GetTitle() + if !ok { + title = "" + } + if utf8.RuneCountInString(title) > maxSavedReactionTagTitle { + return false, limitInvalidErr() + } + if r.deps.Channels != nil { + if err := r.deps.Channels.UpdateSavedReactionTag(ctx, userID, domain.SavedReactionTag{ + UserID: userID, + Reaction: reaction, + Title: title, + }); err != nil { + return false, channelInvalidErr(err) + } + } + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateSavedReactionTags{}}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) + return true, nil +} + +func (r *Router) onMessagesGetDefaultTagReactions(ctx context.Context, hash int64) (tg.MessagesReactionsClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return messagesReactionsEmpty(hash), nil +} + +func (r *Router) onMessagesGetWebPagePreview(ctx context.Context, req *tg.MessagesGetWebPagePreviewRequest) (*tg.MessagesWebPagePreview, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + message := strings.TrimSpace(req.Message) + if message == "" { + return nil, messageEmptyErr() + } + if utf8.RuneCountInString(message) > maxSendMessageTextLength || len(req.Entities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + return &tg.MessagesWebPagePreview{ + Media: &tg.MessageMediaEmpty{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil +} + +// onMessagesUploadMedia / onMessagesSendMedia / onMessagesSendMultiMedia 实现见 send_media.go。 + +func (r *Router) onMessagesGetScheduledMessages(ctx context.Context, req *tg.MessagesGetScheduledMessagesRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.ID); err != nil { + return nil, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesGetScheduledHistory(ctx context.Context, req *tg.MessagesGetScheduledHistoryRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if req.Hash != 0 { + return &tg.MessagesMessagesNotModified{Count: 0}, nil + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesSendScheduledMessages(ctx context.Context, req *tg.MessagesSendScheduledMessagesRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.ID); err != nil { + return nil, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if len(req.ID) == 0 { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesDeleteScheduledMessages(ctx context.Context, req *tg.MessagesDeleteScheduledMessagesRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.ID); err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateDeleteScheduledMessages{ + Peer: tgPeer(peer), + Messages: append([]int(nil), req.ID...), + }}, + Users: []tg.UserClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Date: int(r.clock.Now().Unix()), + Seq: 0, + }, nil +} + +func (r *Router) onMessagesCreateForumTopic(ctx context.Context, req *tg.MessagesCreateForumTopicRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateForumTopicTitle(req.Title, req.TitleMissing); err != nil { + return nil, err + } + if req.RandomID == 0 { + return nil, randomIDEmptyErr() + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + var sendAs *domain.Peer + if req.SendAs != nil { + sendAs, err = r.forumSendAsPeer(ctx, req.Peer, req.SendAs) + if err != nil { + return nil, err + } + } + res, err := r.deps.Channels.CreateForumTopic(ctx, userID, domain.CreateChannelForumTopicRequest{ + UserID: userID, + ChannelID: peer.ID, + Title: strings.TrimSpace(req.Title), + TitleMissing: req.TitleMissing, + IconColor: req.IconColor, + IconEmojiID: req.IconEmojiID, + RandomID: req.RandomID, + SendAs: sendAs, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, forumTopicError(err) + } + sendRes := domain.SendChannelMessageResult{ + Channel: res.Channel, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + Duplicate: res.Duplicate, + } + updates := r.channelMessageUpdates(ctx, userID, sendRes, req.RandomID) + if !res.Duplicate { + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelMessageUpdates(ctx, viewerUserID, sendRes, 0) + }) + } + return updates, nil +} + +func (r *Router) onMessagesEditForumTopic(ctx context.Context, req *tg.MessagesEditForumTopicRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if title, ok := req.GetTitle(); ok { + if err := validateForumTopicTitle(title, false); err != nil { + return nil, err + } + } + if req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + edit := domain.EditChannelForumTopicRequest{ + UserID: userID, + ChannelID: peer.ID, + TopicID: req.TopicID, + Date: int(r.clock.Now().Unix()), + } + if title, ok := req.GetTitle(); ok { + edit.Title = &title + } + if iconEmojiID, ok := req.GetIconEmojiID(); ok { + edit.IconEmojiID = &iconEmojiID + } + if closed, ok := req.GetClosed(); ok { + edit.Closed = &closed + } + if hidden, ok := req.GetHidden(); ok { + edit.Hidden = &hidden + } + res, err := r.deps.Channels.EditForumTopic(ctx, userID, edit) + if err != nil { + return nil, forumTopicError(err) + } + sendRes := domain.SendChannelMessageResult{ + Channel: res.Channel, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + } + updates := r.channelMessageUpdates(ctx, userID, sendRes, 0) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelMessageUpdates(ctx, viewerUserID, sendRes, 0) + }) + return updates, nil +} + +func (r *Router) onMessagesUpdatePinnedForumTopic(ctx context.Context, req *tg.MessagesUpdatePinnedForumTopicRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + res, err := r.deps.Channels.UpdatePinnedForumTopic(ctx, userID, domain.UpdateChannelForumTopicPinnedRequest{ + UserID: userID, + ChannelID: peer.ID, + TopicID: req.TopicID, + Pinned: req.Pinned, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, forumTopicError(err) + } + updates := r.pinnedForumTopicUpdates(userID, res.Channel, res.Topic.TopicID, res.Topic.Pinned) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.pinnedForumTopicUpdates(viewerUserID, res.Channel, res.Topic.TopicID, res.Topic.Pinned) + }) + return updates, nil +} + +func (r *Router) onMessagesReorderPinnedForumTopics(ctx context.Context, req *tg.MessagesReorderPinnedForumTopicsRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.Order); err != nil { + return nil, err + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + res, err := r.deps.Channels.ReorderPinnedForumTopics(ctx, userID, domain.ReorderChannelPinnedForumTopicsRequest{ + UserID: userID, + ChannelID: peer.ID, + Order: append([]int(nil), req.Order...), + Force: req.Force, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, forumTopicError(err) + } + updates := r.pinnedForumTopicsOrderUpdates(userID, res.Channel, res.Order) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.pinnedForumTopicsOrderUpdates(viewerUserID, res.Channel, res.Order) + }) + return updates, nil +} + +func (r *Router) onMessagesDeleteTopicHistory(ctx context.Context, req *tg.MessagesDeleteTopicHistoryRequest) (*tg.MessagesAffectedHistory, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.TopMsgID <= 0 || req.TopMsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + res, err := r.deps.Channels.DeleteForumTopicHistory(ctx, userID, domain.DeleteChannelForumTopicHistoryRequest{ + UserID: userID, + ChannelID: peer.ID, + TopicID: req.TopMsgID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, forumTopicError(err) + } + if res.Event.Pts != 0 { + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{tgChannelUpdate(viewerUserID, res.Event)}, + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, nil)}, + Date: res.Event.Date, + Seq: 0, + } + }) + return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil + } + return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil +} + +func validateMessageIDVector(ids []int) error { + if len(ids) > maxGetMessagesIDs { + return limitInvalidErr() + } + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return messageIDInvalidErr() + } + } + return nil +} + +func validateForumTopicTitle(title string, titleMissing bool) error { + title = strings.TrimSpace(title) + if title == "" && !titleMissing { + return topicTitleEmptyErr() + } + if utf8.RuneCountInString(title) > maxForumTopicTitleLength { + return limitInvalidErr() + } + return nil +} + +func forumTopicError(err error) error { + switch { + case errors.Is(err, domain.ErrChannelForumMissing): + return channelForumMissingErr() + case errors.Is(err, domain.ErrMessageIDInvalid): + return topicIDInvalidErr() + case errors.Is(err, domain.ErrChannelNotModified): + return tgerr400("CHAT_NOT_MODIFIED") + default: + return channelInvalidErr(err) + } +} + +func (r *Router) forumTopicPeer(ctx context.Context, input tg.InputPeerClass) (domain.Peer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return domain.Peer{}, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return domain.Peer{}, err + } + if peer.Type != domain.PeerTypeChannel { + return domain.Peer{}, peerIDInvalidErr() + } + return peer, nil +} + +func (r *Router) validateForumSendAs(ctx context.Context, peerInput, sendAsInput tg.InputPeerClass) error { + _, err := r.forumSendAsPeer(ctx, peerInput, sendAsInput) + return err +} + +func (r *Router) forumSendAsPeer(ctx context.Context, peerInput, sendAsInput tg.InputPeerClass) (*domain.Peer, error) { + peer, err := r.forumTopicPeer(ctx, peerInput) + if err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + sendAs, err := r.checkedDomainPeerFromInputPeer(ctx, userID, sendAsInput) + if err != nil { + return nil, sendAsPeerInvalidErr() + } + if sendAs.Type == domain.PeerTypeUser && sendAs.ID == userID { + return &sendAs, nil + } + if sendAs.Type == domain.PeerTypeChannel && sendAs.ID == peer.ID { + return &sendAs, nil + } + return nil, sendAsPeerInvalidErr() +} + +func (r *Router) onMessagesReportSpam(ctx context.Context, peer tg.InputPeerClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportRequest) (tg.ReportResultClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if len(req.ID) == 0 { + return nil, tgerr.New(400, "MESSAGE_REQUIRED") + } + if len(req.ID) > maxGetMessagesIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength { + return nil, limitInvalidErr() + } + for _, msgID := range req.ID { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + } + return reportResultForOption(string(req.Option)) +} + +func reportResultForOption(option string) (tg.ReportResultClass, error) { + switch option { + case "": + return &tg.ReportResultChooseOption{ + Title: "Report", + Options: []tg.MessageReportOption{ + {Text: "Spam", Option: []byte("spam")}, + {Text: "Violence", Option: []byte("violence")}, + {Text: "Illegal goods", Option: []byte("illegal_goods")}, + {Text: "Child abuse", Option: []byte("child_abuse")}, + {Text: "Personal data", Option: []byte("personal_data")}, + {Text: "Copyright", Option: []byte("copyright")}, + {Text: "Other", Option: []byte("other")}, + }, + }, nil + case "other": + return &tg.ReportResultAddComment{Optional: false, Option: []byte("other:comment")}, nil + case "spam", "violence", "illegal_goods", "child_abuse", "personal_data", "copyright", "other:comment": + return &tg.ReportResultReported{}, nil + default: + return nil, tgerr.New(400, "OPTION_INVALID") + } +} + +func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesReportReactionRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.MessagesReportMessagesDeliveryRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if len(req.ID) > maxGetMessagesIDs { + return false, limitInvalidErr() + } + for _, msgID := range req.ID { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesReportReadMetrics(ctx context.Context, req *tg.MessagesReportReadMetricsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if len(req.Metrics) > maxReadMetrics { + return false, limitInvalidErr() + } + for _, metric := range req.Metrics { + if metric.MsgID <= 0 || metric.MsgID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + if metric.TimeInViewMs < 0 || metric.ActiveTimeInViewMs < 0 || metric.HeightToViewportRatioPermille < 0 || metric.SeenRangeRatioPermille < 0 { + return false, limitInvalidErr() + } + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesReportMusicListen(ctx context.Context, req *tg.MessagesReportMusicListenRequest) (bool, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + if req.ID == nil { + return false, tgerr.New(400, "DOCUMENT_INVALID") + } + if req.ListenedDuration < 0 { + return false, limitInvalidErr() + } + return true, nil +} + +func (r *Router) onMessagesReportSponsoredMessage(ctx context.Context, req *tg.MessagesReportSponsoredMessageRequest) (tg.ChannelsSponsoredMessageReportResultClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if len(req.RandomID) == 0 || len(req.RandomID) > maxReportRandomIDLength || len(req.Option) > maxReportOptionLength { + return nil, limitInvalidErr() + } + return &tg.ChannelsSponsoredMessageReportResultReported{}, nil +} + +func (r *Router) onMessagesGetSearchResultsCalendar(ctx context.Context, req *tg.MessagesGetSearchResultsCalendarRequest) (*tg.MessagesSearchResultsCalendar, error) { + if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || req.OffsetDate < 0 { + return nil, limitInvalidErr() + } + if err := r.validateSearchResultsPeer(ctx, req.Peer, req.GetSavedPeerID); err != nil { + return nil, err + } + minDate := req.OffsetDate + if minDate == 0 { + minDate = int(r.clock.Now().Unix()) + } + return &tg.MessagesSearchResultsCalendar{ + Count: 0, + MinDate: minDate, + MinMsgID: req.OffsetID, + Periods: []tg.SearchResultsCalendarPeriod{}, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesGetSearchResultsPositions(ctx context.Context, req *tg.MessagesGetSearchResultsPositionsRequest) (*tg.MessagesSearchResultsPositions, error) { + if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if err := r.validateSearchResultsPeer(ctx, req.Peer, req.GetSavedPeerID); err != nil { + return nil, err + } + return &tg.MessagesSearchResultsPositions{ + Count: 0, + Positions: []tg.SearchResultPosition{}, + }, nil +} + +func (r *Router) validateSearchResultsPeer(ctx context.Context, peer tg.InputPeerClass, savedPeer func() (tg.InputPeerClass, bool)) error { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { + return err + } + if savedPeer == nil { + return nil + } + if input, ok := savedPeer(); ok && input != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input); err != nil { + return err + } + } + return nil +} + +func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSendReactionRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if reactions, ok := req.GetReaction(); ok && len(reactions) > maxReactionVector { + return nil, limitInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + reactions, err := domainMessageReactionsFromTL(req) + if err != nil { + return nil, err + } + date := int(r.clock.Now().Unix()) + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + res, err := r.deps.Channels.SetMessageReactions(ctx, userID, domain.SetChannelMessageReactionsRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.MsgID, + Reactions: reactions, + Big: req.Big, + AddToRecent: req.GetAddToRecent(), + Date: date, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + updates := r.channelMessageReactionsUpdates(ctx, userID, res) + r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, []int64{userID}, func(viewerUserID int64) *tg.Updates { + return r.channelMessageReactionsUpdates(ctx, viewerUserID, res) + }) + return updates, nil + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + res, err := r.deps.Messages.SetMessageReactions(ctx, userID, domain.SetPrivateMessageReactionsRequest{ + UserID: userID, + Peer: peer, + MessageID: req.MsgID, + Reactions: reactions, + Big: req.Big, + AddToRecent: req.GetAddToRecent(), + Date: date, + }) + if err != nil { + return nil, messageReactionErr(err) + } + if err := r.recordMessageReactionUse(ctx, userID, reactions, req.GetAddToRecent(), date); err != nil { + return nil, internalErr() + } + if err := r.recordPrivateMessageReactionEvents(ctx, userID, res); err != nil { + return nil, internalErr() + } + updates := r.privateMessageReactionsUpdates(ctx, userID, peer, res) + r.pushUserUpdates(ctx, userID, updates) + for _, msg := range res.Messages { + if msg.OwnerUserID == 0 || msg.OwnerUserID == userID { + continue + } + viewerPeer := msg.Peer + viewerUpdates := r.privateMessageReactionsUpdates(ctx, msg.OwnerUserID, viewerPeer, res) + r.pushUserUpdates(ctx, msg.OwnerUserID, viewerUpdates) + } + return updates, nil + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} + +func (r *Router) recordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { + if len(reactions) == 0 || r.deps.Channels == nil { + return nil + } + recorder, ok := r.deps.Channels.(messageReactionUsageRecorder) + if !ok { + return nil + } + return recorder.RecordMessageReactionUse(ctx, userID, reactions, addToRecent, date) +} + +func (r *Router) recordPrivateMessageReactionEvents(ctx context.Context, requestUserID int64, res domain.PrivateMessageReactionsResult) error { + if r.deps.Updates == nil { + return nil + } + recorder, ok := r.deps.Updates.(messageReactionUpdateRecorder) + if !ok { + return nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + for _, msg := range res.Messages { + if msg.OwnerUserID == 0 || msg.ID == 0 { + continue + } + eventAuthKeyID := [8]byte{} + if msg.OwnerUserID == requestUserID { + eventAuthKeyID = authKeyID + } + if _, _, err := recorder.RecordMessageReactions(ctx, eventAuthKeyID, msg.OwnerUserID, msg); err != nil { + return err + } + } + return nil +} + +func (r *Router) onMessagesSetDefaultReaction(ctx context.Context, reaction tg.ReactionClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + parsed, err := domainMessageReactionFromTL(reaction) + if err != nil { + return false, err + } + if svc, ok := r.deps.Account.(accountDefaultReactionService); ok { + if _, err := svc.SetDefaultReaction(ctx, userID, parsed); err != nil { + return false, internalErr() + } + } + return true, nil +} + +func (r *Router) onMessagesGetPaidReactionPrivacy(ctx context.Context) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + settings := domain.DefaultAccountReactionSettings() + if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok { + next, err := svc.GetReactionSettings(ctx, userID) + if err != nil { + return nil, internalErr() + } + settings = next + } + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{ + Private: r.tgPaidReactionPrivacy(ctx, userID, settings.PaidPrivacy), + }}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }, nil +} + +func (r *Router) onMessagesTogglePaidReactionPrivacy(ctx context.Context, req *tg.MessagesTogglePaidReactionPrivacyRequest) (bool, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + privacy, err := r.domainPaidReactionPrivacy(ctx, userID, req.Private) + if err != nil { + return false, err + } + if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok { + next, err := svc.SetPaidReactionPrivacy(ctx, userID, privacy) + if err != nil { + return false, internalErr() + } + privacy = next.PaidPrivacy + } + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{Private: r.tgPaidReactionPrivacy(ctx, userID, privacy)}}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) + return true, nil +} + +func (r *Router) onMessagesSendPaidReaction(ctx context.Context, req *tg.MessagesSendPaidReactionRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Count <= 0 { + return nil, starsAmountInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if private, ok := req.GetPrivate(); ok { + if _, err := r.domainPaidReactionPrivacy(ctx, userID, private); err != nil { + return nil, err + } + } + return nil, balanceTooLowErr() +} + +func (r *Router) onMessagesDeleteParticipantReaction(ctx context.Context, req *tg.MessagesDeleteParticipantReactionRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + participant, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant) + if err != nil { + return nil, err + } + if participant.Type != domain.PeerTypeUser || participant.ID == 0 { + return nil, userIDInvalidErr() + } + if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + moderator, ok := r.deps.Channels.(channelParticipantReactionModerator) + if !ok { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + res, err := moderator.DeleteParticipantReaction(ctx, userID, domain.DeleteChannelParticipantReactionRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.MsgID, + ParticipantUserID: participant.ID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + updates := r.channelMessageReactionsUpdates(ctx, userID, res) + r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelMessageReactionsUpdates(ctx, viewerUserID, res) + }) + return updates, nil +} + +func (r *Router) onMessagesDeleteParticipantReactions(ctx context.Context, req *tg.MessagesDeleteParticipantReactionsRequest) (bool, error) { + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return false, err + } + participant, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant) + if err != nil { + return false, err + } + if participant.Type != domain.PeerTypeUser || participant.ID == 0 { + return false, userIDInvalidErr() + } + if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return true, nil + } + moderator, ok := r.deps.Channels.(channelParticipantReactionModerator) + if !ok { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + res, err := moderator.DeleteParticipantReactions(ctx, userID, domain.DeleteChannelParticipantReactionsRequest{ + UserID: userID, + ChannelID: peer.ID, + ParticipantUserID: participant.ID, + Limit: domain.MaxDeleteParticipantReactionsBatch, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return false, channelInvalidErr(err) + } + if len(res.Messages) > 0 { + reactionRes := domain.ChannelMessageReactionsResult{ + Channel: res.Channel, + Messages: res.Messages, + Recipients: res.Recipients, + } + ids := make([]int, 0, len(res.Messages)) + for _, msg := range res.Messages { + if msg.ID > 0 { + ids = append(ids, msg.ID) + } + } + r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelMessagesReactionsUpdates(ctx, viewerUserID, reactionRes, ids) + }) + } + return true, nil +} + +func (r *Router) onMessagesGetMessagesReactions(ctx context.Context, req *tg.MessagesGetMessagesReactionsRequest) (tg.UpdatesClass, error) { + if len(req.ID) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + res, err := r.deps.Channels.GetMessageReactions(ctx, userID, domain.ChannelMessageReactionsRequest{ + UserID: userID, + ChannelID: peer.ID, + IDs: append([]int(nil), req.ID...), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return r.channelMessagesReactionsUpdates(ctx, userID, res, req.ID), nil + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + res, err := r.deps.Messages.GetMessageReactions(ctx, userID, domain.PrivateMessageReactionsRequest{ + OwnerUserID: userID, + Peer: peer, + IDs: append([]int(nil), req.ID...), + }) + if err != nil { + return nil, messageReactionErr(err) + } + return r.privateMessagesReactionsUpdates(ctx, userID, peer, res, req.ID), nil + } + updates := make([]tg.UpdateClass, 0, len(req.ID)) + tgPeer := tgPeer(peer) + for _, msgID := range req.ID { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + updates = append(updates, &tg.UpdateMessageReactions{ + Peer: tgPeer, + MsgID: msgID, + Reactions: tg.MessageReactions{ + Results: []tg.ReactionCount{}, + }, + }) + } + return &tg.Updates{ + Updates: updates, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }, nil +} + +func (r *Router) onMessagesGetMessageReactionsList(ctx context.Context, req *tg.MessagesGetMessageReactionsListRequest) (*tg.MessagesMessageReactionsList, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if offset, ok := req.GetOffset(); ok && len(offset) > maxReactionListOffset { + return nil, limitInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + filter, err := optionalDomainMessageReaction(req.Reaction) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.ListMessageReactions(ctx, userID, domain.ChannelMessageReactionsListRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.ID, + Reaction: filter, + Offset: optionalString(req.GetOffset), + Limit: req.Limit, + }) + if errors.Is(err, domain.ErrChannelRightForbidden) { + return nil, tgerr.New(403, "BROADCAST_FORBIDDEN") + } + if err != nil { + return nil, channelInvalidErr(err) + } + userIDs := make([]int64, 0, len(res.Reactions)) + reactions := make([]tg.MessagePeerReaction, 0, len(res.Reactions)) + for _, item := range res.Reactions { + if item.UserID != 0 { + userIDs = append(userIDs, item.UserID) + } + if converted := tgMessagePeerReaction(userID, item); converted != nil { + reactions = append(reactions, *converted) + } + } + out := &tg.MessagesMessageReactionsList{ + Count: res.Count, + Reactions: reactions, + Chats: tgChannels(userID, []domain.Channel{res.Channel}), + Users: r.tgUsersForIDs(ctx, userID, userIDs), + } + if res.NextOffset != "" { + out.SetNextOffset(res.NextOffset) + } + return out, nil + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + filter, err := optionalDomainMessageReaction(req.Reaction) + if err != nil { + return nil, err + } + res, err := r.deps.Messages.GetMessageReactions(ctx, userID, domain.PrivateMessageReactionsRequest{ + OwnerUserID: userID, + Peer: peer, + IDs: []int{req.ID}, + }) + if err != nil { + return nil, messageReactionErr(err) + } + var source domain.ChannelMessageReactions + if len(res.Messages) > 0 && res.Messages[0].Reactions != nil { + source = *res.Messages[0].Reactions + } else { + source = res.Reactions + } + limit := req.Limit + if limit <= 0 || limit > len(source.Recent) { + limit = len(source.Recent) + } + userIDs := []int64{userID, peer.ID} + reactions := make([]tg.MessagePeerReaction, 0, limit) + count := 0 + for _, item := range source.Recent { + if filter != nil && (item.Reaction.Type != filter.Type || item.Reaction.Emoticon != filter.Emoticon) { + continue + } + count++ + if len(reactions) >= limit { + continue + } + if item.UserID != 0 { + userIDs = append(userIDs, item.UserID) + } + if converted := tgMessagePeerReaction(userID, item); converted != nil { + reactions = append(reactions, *converted) + } + } + return &tg.MessagesMessageReactionsList{ + Count: count, + Reactions: reactions, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil + } + return &tg.MessagesMessageReactionsList{ + Count: 0, + Reactions: []tg.MessagePeerReaction{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) channelMessageReactionsUpdates(ctx context.Context, viewerUserID int64, res domain.ChannelMessageReactionsResult) *tg.Updates { + ids := []int{res.Message.ID} + if res.Message.ID <= 0 && len(res.Messages) > 0 { + ids = []int{res.Messages[0].ID} + } + return r.channelMessagesReactionsUpdates(ctx, viewerUserID, res, ids) +} + +func (r *Router) privateMessageReactionsUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, res domain.PrivateMessageReactionsResult) *tg.Updates { + ids := make([]int, 0, 1) + for _, msg := range res.Messages { + if msg.OwnerUserID == viewerUserID && msg.ID > 0 { + ids = append(ids, msg.ID) + break + } + } + return r.privateMessagesReactionsUpdates(ctx, viewerUserID, peer, res, ids) +} + +func (r *Router) privateMessagesReactionsUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, res domain.PrivateMessageReactionsResult, ids []int) *tg.Updates { + updates := make([]tg.UpdateClass, 0, len(ids)) + messagesByID := make(map[int]domain.Message, len(res.Messages)) + userIDs := []int64{viewerUserID} + if peer.Type == domain.PeerTypeUser && peer.ID != 0 { + userIDs = append(userIDs, peer.ID) + } + for _, msg := range res.Messages { + if msg.OwnerUserID != viewerUserID || msg.ID == 0 { + continue + } + messagesByID[msg.ID] = msg + if msg.Peer.Type == domain.PeerTypeUser && msg.Peer.ID != 0 { + userIDs = append(userIDs, msg.Peer.ID) + } + if msg.From.Type == domain.PeerTypeUser && msg.From.ID != 0 { + userIDs = append(userIDs, msg.From.ID) + } + if msg.Reactions != nil { + userIDs = append(userIDs, channelMessageReactionUserIDs(*msg.Reactions)...) + } + } + userIDs = append(userIDs, channelMessageReactionUserIDs(res.Reactions)...) + fallbackPeer := tgPeer(peer) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + msg, ok := messagesByID[id] + outPeer := fallbackPeer + reactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } + if ok { + outPeer = tgPeer(msg.Peer) + if msg.Reactions != nil { + reactions = *msg.Reactions + } + } + if outPeer == nil { + continue + } + converted := tgMessageReactions(viewerUserID, &reactions) + if converted == nil { + converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} + } + updates = append(updates, &tg.UpdateMessageReactions{ + Peer: outPeer, + MsgID: id, + Reactions: *converted, + }) + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelMessagesReactionsUpdates(ctx context.Context, viewerUserID int64, res domain.ChannelMessageReactionsResult, ids []int) *tg.Updates { + updates := make([]tg.UpdateClass, 0, len(ids)) + messagesByID := make(map[int]domain.ChannelMessage, len(res.Messages)+1) + if res.Message.ID != 0 { + messagesByID[res.Message.ID] = res.Message + } + for _, msg := range res.Messages { + if msg.ID != 0 { + messagesByID[msg.ID] = msg + } + } + userIDs := make([]int64, 0) + for _, msg := range messagesByID { + if msg.Reactions != nil { + userIDs = append(userIDs, channelMessageReactionUserIDs(*msg.Reactions)...) + } + } + userIDs = append(userIDs, channelMessageReactionUserIDs(res.Reactions)...) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + msg, ok := messagesByID[id] + reactions := domain.ChannelMessageReactions{ + CanSeeList: !res.Channel.Broadcast || res.Channel.Megagroup, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } + if ok && msg.Reactions != nil { + reactions = *msg.Reactions + } else if ok && len(res.Reactions.Results) > 0 && res.Message.ID == id { + reactions = res.Reactions + } + converted := tgMessageReactions(viewerUserID, &reactions) + if converted == nil { + converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} + } + update := &tg.UpdateMessageReactions{ + Peer: &tg.PeerChannel{ChannelID: res.Channel.ID}, + MsgID: id, + Reactions: *converted, + } + if ok { + if topID := channelMessageThreadRootID(msg); topID > 0 && topID != id { + update.SetTopMsgID(topID) + } + } + updates = append(updates, update) + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), + Chats: tgChannels(viewerUserID, []domain.Channel{res.Channel}), + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func channelMessageReactionUserIDs(reactions domain.ChannelMessageReactions) []int64 { + out := make([]int64, 0, len(reactions.Recent)) + for _, item := range reactions.Recent { + if item.UserID != 0 { + out = append(out, item.UserID) + } + } + return out +} + +func (r *Router) onMessagesGetUnreadReactions(ctx context.Context, req *tg.MessagesGetUnreadReactionsRequest) (tg.MessagesMessagesClass, error) { + if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { + return nil, err + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, req.GetSavedPeerID) + if err != nil { + return nil, err + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + history, err := r.deps.Channels.GetUnreadReactions(ctx, userID, domain.ChannelUnreadReactionsFilter{ + ChannelID: peer.ID, + TopMsgID: req.TopMsgID, + OffsetID: req.OffsetID, + AddOffset: req.AddOffset, + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return tgChannelHistoryMessages(userID, r.enrichChannelHistory(ctx, userID, history)), nil + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Topics: []tg.ForumTopicClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesReadReactions(ctx context.Context, req *tg.MessagesReadReactionsRequest) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if savedPeer, ok := req.GetSavedPeerID(); ok && savedPeer != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeer); err != nil { + return nil, err + } + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + res, err := r.deps.Channels.ReadReactions(ctx, userID, domain.ReadChannelReactionsRequest{ + UserID: userID, + ChannelID: peer.ID, + TopMsgID: req.TopMsgID, + Limit: domain.MaxChannelReadReactionsBatch, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return &tg.MessagesAffectedHistory{Pts: res.ChannelPts, PtsCount: 0, Offset: res.Offset}, nil + } + return r.affectedHistory(ctx, authKeyID, userID, 0) +} + +func (r *Router) onMessagesSendVote(ctx context.Context, req *tg.MessagesSendVoteRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := validatePollOptions(req.Options, true); err != nil { + return nil, err + } + if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesGetPollResults(ctx context.Context, req *tg.MessagesGetPollResultsRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { + return nil, err + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} + +func (r *Router) onMessagesGetPollVotes(ctx context.Context, req *tg.MessagesGetPollVotesRequest) (*tg.MessagesVotesList, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if option, ok := req.GetOption(); ok { + if err := validatePollOption(option); err != nil { + return nil, err + } + } + if offset, ok := req.GetOffset(); ok && len(offset) > maxPollVotesOffsetLength { + return nil, limitInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + if view.Channel.Broadcast && !view.Channel.Megagroup { + return nil, tgerr.New(403, "BROADCAST_FORBIDDEN") + } + } + return &tg.MessagesVotesList{ + Count: 0, + Votes: []tg.MessagePeerVoteClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesAddPollAnswer(ctx context.Context, req *tg.MessagesAddPollAnswerRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := validatePollAnswer(req.Answer); err != nil { + return nil, err + } + if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesDeletePollAnswer(ctx context.Context, req *tg.MessagesDeletePollAnswerRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := validatePollOption(req.Option); err != nil { + return nil, err + } + if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesGetUnreadPollVotes(ctx context.Context, req *tg.MessagesGetUnreadPollVotesRequest) (tg.MessagesMessagesClass, error) { + if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { + return nil, err + } + userID, _, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Topics: []tg.ForumTopicClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesReadPollVotes(ctx context.Context, req *tg.MessagesReadPollVotesRequest) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + return r.affectedHistory(ctx, authKeyID, userID, 0) +} + +func (r *Router) onMessagesAppendTodoList(ctx context.Context, req *tg.MessagesAppendTodoListRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if len(req.List) == 0 { + return nil, todoNotModifiedErr() + } + if err := validateTodoItems(req.List); err != nil { + return nil, err + } + if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesToggleTodoCompleted(ctx context.Context, req *tg.MessagesToggleTodoCompletedRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if len(req.Completed) == 0 && len(req.Incompleted) == 0 { + return nil, todoNotModifiedErr() + } + if err := validateTodoIDVector(req.Completed, req.Incompleted); err != nil { + return nil, err + } + if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) reactionPeer(ctx context.Context, peer tg.InputPeerClass, savedPeer func() (tg.InputPeerClass, bool)) (int64, domain.Peer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.Peer{}, internalErr() + } + out, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return 0, domain.Peer{}, err + } + if savedPeer != nil { + if input, ok := savedPeer(); ok && input != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input); err != nil { + return 0, domain.Peer{}, err + } + } + } + return userID, out, nil +} + +func (r *Router) domainPaidReactionPrivacy(ctx context.Context, userID int64, in tg.PaidReactionPrivacyClass) (domain.PaidReactionPrivacy, error) { + switch typed := in.(type) { + case nil, *tg.PaidReactionPrivacyDefault: + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault}, nil + case *tg.PaidReactionPrivacyAnonymous: + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous}, nil + case *tg.PaidReactionPrivacyPeer: + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, typed.Peer) + if err != nil { + return domain.PaidReactionPrivacy{}, err + } + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer}, nil + default: + return domain.PaidReactionPrivacy{}, inputConstructorInvalidErr() + } +} + +func (r *Router) tgPaidReactionPrivacy(ctx context.Context, userID int64, in domain.PaidReactionPrivacy) tg.PaidReactionPrivacyClass { + switch in.Kind { + case domain.PaidReactionPrivacyAnonymous: + return &tg.PaidReactionPrivacyAnonymous{} + case domain.PaidReactionPrivacyPeer: + if in.Peer == nil { + return &tg.PaidReactionPrivacyDefault{} + } + if peer := r.inputPeerForDomainPeer(ctx, userID, *in.Peer); peer != nil { + return &tg.PaidReactionPrivacyPeer{Peer: peer} + } + } + return &tg.PaidReactionPrivacyDefault{} +} + +func (r *Router) inputPeerForDomainPeer(ctx context.Context, currentUserID int64, peer domain.Peer) tg.InputPeerClass { + switch peer.Type { + case domain.PeerTypeUser: + switch { + case peer.ID == domain.OfficialSystemUserID: + u := domain.OfficialSystemUser() + return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} + case r.deps.Users == nil: + return nil + case peer.ID == currentUserID: + u, err := r.deps.Users.Self(ctx, currentUserID) + if err != nil || u.ID == 0 { + return nil + } + return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} + default: + u, found, err := r.deps.Users.ByID(ctx, currentUserID, peer.ID) + if err != nil || !found { + return nil + } + return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} + } + case domain.PeerTypeChannel: + if r.deps.Channels == nil || peer.ID == 0 { + return nil + } + view, err := r.deps.Channels.GetChannel(ctx, currentUserID, peer.ID) + if err != nil || view.Channel.ID == 0 { + return nil + } + return &tg.InputPeerChannel{ChannelID: view.Channel.ID, AccessHash: view.Channel.AccessHash} + default: + return nil + } +} + +func domainMessageReactionsFromTL(req *tg.MessagesSendReactionRequest) ([]domain.MessageReaction, error) { + if req == nil { + return nil, nil + } + reactions, ok := req.GetReaction() + if !ok || len(reactions) == 0 { + return nil, nil + } + out := make([]domain.MessageReaction, 0, len(reactions)) + seen := make(map[string]struct{}, len(reactions)) + for _, reaction := range reactions { + parsed, err := domainMessageReactionFromTL(reaction) + if err != nil { + return nil, err + } + key := string(parsed.Type) + "\x00" + parsed.Emoticon + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, parsed) + } + return out, nil +} + +func optionalDomainMessageReaction(reaction tg.ReactionClass) (*domain.MessageReaction, error) { + if reaction == nil { + return nil, nil + } + out, err := domainMessageReactionFromTL(reaction) + if err != nil { + return nil, err + } + return &out, nil +} + +func domainMessageReactionFromTL(reaction tg.ReactionClass) (domain.MessageReaction, error) { + switch typed := reaction.(type) { + case *tg.ReactionEmoji: + emoticon := strings.TrimSpace(typed.Emoticon) + if emoticon == "" || utf8.RuneCountInString(emoticon) > domain.MaxChannelReactionEmoticonLength { + return domain.MessageReaction{}, reactionInvalidErr() + } + return domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}, nil + case nil, *tg.ReactionEmpty, *tg.ReactionCustomEmoji, *tg.ReactionPaid: + return domain.MessageReaction{}, reactionInvalidErr() + default: + return domain.MessageReaction{}, inputConstructorInvalidErr() + } +} + +func optionalString(get func() (string, bool)) string { + if get == nil { + return "" + } + value, ok := get() + if !ok { + return "" + } + return value +} + +func validateEmojiLangCode(langcode string) error { + if langcode == "" || len(langcode) > maxEmojiLangCodeLength { + return limitInvalidErr() + } + for _, c := range langcode { + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + case c == '-' || c == '_': + default: + return limitInvalidErr() + } + } + return nil +} + +func messagesReactionsEmpty(hash int64) tg.MessagesReactionsClass { + if hash != 0 { + return &tg.MessagesReactionsNotModified{} + } + return &tg.MessagesReactions{ + Hash: 0, + Reactions: []tg.ReactionClass{}, + } +} + +func messagesReactionsFromDomain(reactions []domain.MessageReaction, requestHash int64) tg.MessagesReactionsClass { + hash := messageReactionListHash(reactions) + if hash != 0 && requestHash == hash { + return &tg.MessagesReactionsNotModified{} + } + out := make([]tg.ReactionClass, 0, len(reactions)) + for _, reaction := range reactions { + tgReaction := tgMessageReaction(reaction) + if tgReaction != nil { + out = append(out, tgReaction) + } + } + return &tg.MessagesReactions{ + Hash: hash, + Reactions: out, + } +} + +func savedReactionTagsEmpty(_ int64) tg.MessagesSavedReactionTagsClass { + return &tg.MessagesSavedReactionTags{ + Tags: []tg.SavedReactionTag{}, + Hash: 0, + } +} + +func savedReactionTagsFromDomain(tags []domain.SavedReactionTag, requestHash int64) tg.MessagesSavedReactionTagsClass { + hash := savedReactionTagListHash(tags) + if hash != 0 && requestHash == hash { + return &tg.MessagesSavedReactionTagsNotModified{} + } + out := make([]tg.SavedReactionTag, 0, len(tags)) + for _, tag := range tags { + reaction := tgMessageReaction(tag.Reaction) + if reaction == nil { + continue + } + item := tg.SavedReactionTag{ + Reaction: reaction, + Count: tag.Count, + } + if tag.Title != "" { + item.SetTitle(tag.Title) + } + out = append(out, item) + } + if len(out) == 0 { + return savedReactionTagsEmpty(requestHash) + } + return &tg.MessagesSavedReactionTags{ + Tags: out, + Hash: hash, + } +} + +func (r *Router) reactionsWithCatalogFallback(ctx context.Context, reactions []domain.MessageReaction, limit int) []domain.MessageReaction { + return mergeReactionCatalogFallback(reactions, r.availableReactionCatalog(ctx, limit), limit) +} + +func reactionsWithCatalogFallback(reactions []domain.MessageReaction, limit int) []domain.MessageReaction { + return mergeReactionCatalogFallback(reactions, staticReactionCatalog(), limit) +} + +func (r *Router) availableReactionCatalog(ctx context.Context, limit int) []domain.MessageReaction { + if limit <= 0 { + return nil + } + if r.deps.Files != nil { + catalog, err := r.deps.Files.ListAvailableReactions(ctx) + if err == nil { + out := make([]domain.MessageReaction, 0, min(limit, len(catalog))) + for _, item := range catalog { + emoticon := strings.TrimSpace(item.Reaction) + if item.Inactive || emoticon == "" { + continue + } + out = append(out, domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}) + if len(out) >= limit { + return out + } + } + if len(out) > 0 { + return out + } + } + } + return staticReactionCatalog() +} + +func staticReactionCatalog() []domain.MessageReaction { + emoticons := tdesktop.DefaultReactionEmoticons() + out := make([]domain.MessageReaction, 0, len(emoticons)) + for _, emoticon := range emoticons { + out = append(out, domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}) + } + return out +} + +func mergeReactionCatalogFallback(reactions, fallback []domain.MessageReaction, limit int) []domain.MessageReaction { + if limit <= 0 { + return []domain.MessageReaction{} + } + if limit > domain.MaxTopMessageReactions { + limit = domain.MaxTopMessageReactions + } + out := make([]domain.MessageReaction, 0, limit) + seen := make(map[string]struct{}, limit) + for _, reaction := range reactions { + if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { + continue + } + key := string(reaction.Type) + "\x00" + reaction.Emoticon + if _, ok := seen[key]; ok { + continue + } + out = append(out, reaction) + seen[key] = struct{}{} + if len(out) >= limit { + return out + } + } + for _, reaction := range fallback { + key := string(reaction.Type) + "\x00" + reaction.Emoticon + if _, ok := seen[key]; ok { + continue + } + out = append(out, reaction) + seen[key] = struct{}{} + if len(out) >= limit { + return out + } + } + return out +} + +func messageReactionListHash(reactions []domain.MessageReaction) int64 { + if len(reactions) == 0 { + return 0 + } + h := fnv.New64a() + for _, reaction := range reactions { + _, _ = h.Write([]byte(reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(reaction.Emoticon)) + _, _ = h.Write([]byte{0xff}) + } + sum := int64(h.Sum64() & 0x7fffffffffffffff) + if sum == 0 { + return 1 + } + return sum +} + +func savedReactionTagListHash(tags []domain.SavedReactionTag) int64 { + if len(tags) == 0 { + return 0 + } + h := fnv.New64a() + for _, tag := range tags { + _, _ = h.Write([]byte(tag.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(tag.Reaction.Emoticon)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(tag.Title)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(strconv.Itoa(tag.Count))) + _, _ = h.Write([]byte{0xff}) + } + sum := int64(h.Sum64() & 0x7fffffffffffffff) + if sum == 0 { + return 1 + } + return sum +} + +func validateReactionClass(reaction tg.ReactionClass) error { + switch typed := reaction.(type) { + case *tg.ReactionEmoji: + if strings.TrimSpace(typed.Emoticon) == "" || utf8.RuneCountInString(typed.Emoticon) > maxReportOptionLength { + return reactionInvalidErr() + } + case *tg.ReactionCustomEmoji: + if typed.DocumentID <= 0 { + return reactionInvalidErr() + } + case *tg.ReactionPaid: + case nil, *tg.ReactionEmpty: + return reactionInvalidErr() + default: + return inputConstructorInvalidErr() + } + return nil +} + +func validatePollOptions(options [][]byte, allowEmpty bool) error { + if len(options) == 0 { + if allowEmpty { + return nil + } + return optionInvalidErr() + } + if len(options) > maxPollVoteOptions { + return optionsTooMuchErr() + } + for _, option := range options { + if err := validatePollOption(option); err != nil { + return err + } + } + return nil +} + +func validatePollOption(option []byte) error { + if len(option) == 0 || len(option) > maxPollOptionBytes { + return optionInvalidErr() + } + return nil +} + +func validatePollAnswer(answer tg.PollAnswerClass) error { + if answer == nil { + return pollAnswerInvalidErr() + } + text := answer.GetText() + if strings.TrimSpace(text.Text) == "" || utf8.RuneCountInString(text.Text) > maxTodoTitleLength { + return pollAnswerInvalidErr() + } + if len(text.Entities) > maxMessageEntityCount { + return limitInvalidErr() + } + switch typed := answer.(type) { + case *tg.PollAnswer: + if err := validatePollOption(typed.Option); err != nil { + return err + } + if typed.Media != nil { + return mediaInvalidErr() + } + case *tg.InputPollAnswer: + if typed.Media != nil { + return mediaInvalidErr() + } + default: + return inputConstructorInvalidErr() + } + return nil +} + +func validateTodoItems(items []tg.TodoItem) error { + if len(items) == 0 { + return todoItemsEmptyErr() + } + if len(items) > maxTodoItems { + return limitInvalidErr() + } + seen := make(map[int]struct{}, len(items)) + for _, item := range items { + if item.ID < 0 || item.ID > maxTodoItems { + return messageIDInvalidErr() + } + if item.ID != 0 { + if _, ok := seen[item.ID]; ok { + return tgerr.New(400, "TODO_ITEM_DUPLICATE") + } + seen[item.ID] = struct{}{} + } + if strings.TrimSpace(item.Title.Text) == "" || utf8.RuneCountInString(item.Title.Text) > maxTodoTitleLength { + return limitInvalidErr() + } + if len(item.Title.Entities) > maxMessageEntityCount { + return limitInvalidErr() + } + } + return nil +} + +func validateTodoIDVector(vectors ...[]int) error { + total := 0 + seen := map[int]struct{}{} + for _, ids := range vectors { + total += len(ids) + if total > maxTodoItems { + return limitInvalidErr() + } + for _, id := range ids { + if id <= 0 || id > maxTodoItems { + return messageIDInvalidErr() + } + if _, ok := seen[id]; ok { + return todoNotModifiedErr() + } + seen[id] = struct{}{} + } + } + return nil +} + +func validateHistoryBounds(offsetID, addOffset, limit, maxID, minID int) error { + if offsetID < 0 || offsetID > domain.MaxMessageBoxID || maxID < 0 || maxID > domain.MaxMessageBoxID || minID < 0 || minID > domain.MaxMessageBoxID { + return messageIDInvalidErr() + } + if addOffset < -100 || addOffset > 100 || limit < 0 || limit > maxSearchResultsLimit { + return limitInvalidErr() + } + return nil +} + +func validateSavedHistoryBounds(offsetID, offsetDate, addOffset, limit, maxID, minID int) error { + if err := validateHistoryBounds(offsetID, addOffset, limit, maxID, minID); err != nil { + return err + } + if offsetDate < 0 { + return limitInvalidErr() + } + return nil +} + +func (r *Router) validateSavedHistoryParentPeer(ctx context.Context, userID int64, getParent func() (tg.InputPeerClass, bool)) (domain.Peer, bool, error) { + parentPeer, ok := getParent() + if !ok { + return domain.Peer{}, false, nil + } + if err := r.validateRequiredSavedHistoryParentPeer(ctx, userID, parentPeer); err != nil { + return domain.Peer{}, false, err + } + parent, _ := r.domainPeerFromInputPeer(userID, parentPeer) + return parent, true, nil +} + +func (r *Router) validateRequiredSavedHistoryParentPeer(ctx context.Context, userID int64, parentPeer tg.InputPeerClass) error { + parent, err := r.checkedDomainPeerFromInputPeer(ctx, userID, parentPeer) + if err != nil || parent.Type != domain.PeerTypeChannel { + return parentPeerInvalidErr() + } + return nil +} + +func (r *Router) savedHistoryChats(ctx context.Context, userID int64, hasParent bool, parent domain.Peer, peer tg.InputPeerClass) []tg.ChatClass { + if r.deps.Channels == nil { + return []tg.ChatClass{} + } + seen := make(map[int64]struct{}, 2) + out := make([]tg.ChatClass, 0, 2) + add := func(channelID int64) { + if channelID == 0 { + return + } + if _, ok := seen[channelID]; ok { + return + } + seen[channelID] = struct{}{} + view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) + if err != nil || view.Channel.ID == 0 { + return + } + out = append(out, tgChannelChat(userID, view.Channel, &view.Self)) + } + if hasParent && parent.Type == domain.PeerTypeChannel { + add(parent.ID) + } + if p, ok := r.domainPeerFromInputPeer(userID, peer); ok && p.Type == domain.PeerTypeChannel { + add(p.ID) + } + return out +} + +func (r *Router) onMessagesGetDefaultHistoryTTL(ctx context.Context) (*tg.DefaultHistoryTTL, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.DefaultHistoryTTL{Period: 0}, nil +} + +func (r *Router) onMessagesGetSponsoredMessages(ctx context.Context, req *tg.MessagesGetSponsoredMessagesRequest) (tg.MessagesSponsoredMessagesClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.MessagesSponsoredMessagesEmpty{}, nil +} + +func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (*tg.MessagesAffectedMessages, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(ids) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + for _, msgID := range ids { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + } + read := domain.ReadMessageContentsResult{OwnerUserID: userID} + if r.deps.Messages != nil { + read, err = r.deps.Messages.ReadMessageContents(ctx, userID, domain.ReadMessageContentsRequest{ + OwnerUserID: userID, + IDs: ids, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, internalErr() + } + } + affected, err := r.affectedMessages(ctx, id, userID) + if err != nil { + return nil, err + } + if contentIDs := readMessageContentIDs(read.MessageIDs); len(contentIDs) > 0 { + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateReadMessagesContents{ + Messages: contentIDs, + Pts: affected.Pts, + PtsCount: affected.PtsCount, + }}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) + } + return affected, nil +} + +func readMessageContentIDs(ids []int) []int { + if len(ids) == 0 { + return nil + } + out := make([]int, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (r *Router) onMessagesGetMessagesViews(ctx context.Context, req *tg.MessagesGetMessagesViewsRequest) (*tg.MessagesMessageViews, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(req.ID) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + for _, msgID := range req.ID { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + } + views := make([]tg.MessageViews, len(req.ID)) + peer, peerErr := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if peerErr != nil { + return nil, peerErr + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil && len(req.ID) > 0 { + history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, req.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + viewCounters, err := r.deps.Channels.GetMessageViews(ctx, userID, domain.ChannelMessageViewsRequest{ + UserID: userID, + ChannelID: peer.ID, + IDs: req.ID, + Increment: req.Increment, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + byID := make(map[int]domain.ChannelMessage, len(history.Messages)) + for _, msg := range history.Messages { + byID[msg.ID] = msg + } + for i, id := range req.ID { + if count, ok := viewCounters.Views[id]; ok { + views[i].SetViews(count) + } + if replies := tgChannelMessageReplies(byID[id].Replies); replies != nil { + views[i].SetReplies(*replies) + } + } + channels := make([]domain.Channel, 0, 1+len(history.Channels)) + channels = append(channels, history.Channel) + channels = append(channels, history.Channels...) + return &tg.MessagesMessageViews{ + Views: views, + Chats: tgChannels(userID, channels), + Users: r.tgUsers(history.Users), + }, nil + } + return &tg.MessagesMessageViews{ + Views: views, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesGetUnreadMentions(ctx context.Context, req *tg.MessagesGetUnreadMentionsRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > domain.MaxChannelUnreadMentionsLimit { + return nil, limitInvalidErr() + } + if req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID || + req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || + req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID || + req.MinID < 0 || req.MinID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + history, err := r.deps.Channels.GetUnreadMentions(ctx, userID, domain.ChannelUnreadMentionsFilter{ + ChannelID: peer.ID, + TopMsgID: req.TopMsgID, + OffsetID: req.OffsetID, + AddOffset: req.AddOffset, + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return tgChannelHistoryMessages(userID, r.enrichChannelHistory(ctx, userID, history)), nil + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesReadMentions(ctx context.Context, req *tg.MessagesReadMentionsRequest) (*tg.MessagesAffectedHistory, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + res, err := r.deps.Channels.ReadMentions(ctx, userID, domain.ReadChannelMentionsRequest{ + UserID: userID, + ChannelID: peer.ID, + TopMsgID: req.TopMsgID, + Limit: domain.MaxChannelReadMentionsBatch, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return &tg.MessagesAffectedHistory{Pts: res.ChannelPts, PtsCount: 0, Offset: res.Offset}, nil + } + return r.affectedHistory(ctx, id, userID, 0) +} + +func (r *Router) onMessagesGetSearchCounters(ctx context.Context, req *tg.MessagesGetSearchCountersRequest) ([]tg.MessagesSearchCounter, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(req.Filters) > maxMessageSearchFilters { + return nil, limitInvalidErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + counters := make([]tg.MessagesSearchCounter, 0, len(req.Filters)) + for _, filter := range req.Filters { + if filter == nil { + continue + } + counters = append(counters, tg.MessagesSearchCounter{Filter: filter, Count: 0}) + } + return counters, nil +} + +func (r *Router) onMessagesGetReplies(ctx context.Context, req *tg.MessagesGetRepliesRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + replies, err := r.deps.Channels.GetReplies(ctx, userID, domain.ChannelRepliesFilter{ + ChannelID: peer.ID, + RootMessageID: req.MsgID, + OffsetID: req.OffsetID, + OffsetDate: req.OffsetDate, + AddOffset: req.AddOffset, + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + if req.Hash != 0 && replies.Hash == req.Hash { + return &tg.MessagesMessagesNotModified{Count: replies.Count}, nil + } + return tgChannelHistoryMessages(userID, r.enrichChannelHistory(ctx, userID, replies)), nil + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesGetDiscussionMessage(ctx context.Context, req *tg.MessagesGetDiscussionMessageRequest) (*tg.MessagesDiscussionMessage, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel { + return nil, peerIDInvalidErr() + } + if r.deps.Channels == nil { + return &tg.MessagesDiscussionMessage{Chats: r.chatsForInputPeer(ctx, userID, req.Peer)}, nil + } + discussion, err := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID) + if err != nil { + return nil, channelInvalidErr(err) + } + return tgMessagesDiscussionMessage(userID, discussion), nil +} + +func (r *Router) onMessagesReadDiscussion(ctx context.Context, req *tg.MessagesReadDiscussionRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID || req.ReadMaxID < 0 || req.ReadMaxID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if peer.Type != domain.PeerTypeChannel { + return false, peerIDInvalidErr() + } + if r.deps.Channels == nil { + return true, nil + } + discussion, err := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID) + if err != nil { + return false, channelInvalidErr(err) + } + readChannelID := peer.ID + if discussion.DiscussionChannel.ID != 0 { + readChannelID = discussion.DiscussionChannel.ID + } + read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ + UserID: userID, + ChannelID: readChannelID, + MaxID: req.ReadMaxID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return false, channelInvalidErr(err) + } + if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil { + return false, err + } + r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) + return read.Changed, nil +} + +func (r *Router) onMessagesGetForumTopics(ctx context.Context, req *tg.MessagesGetForumTopicsRequest) (*tg.MessagesForumTopics, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > 100 || utf8.RuneCountInString(req.Q) > maxMessageSearchQLength { + return nil, limitInvalidErr() + } + view, err := r.forumTopicPeerView(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if !view.Channel.Forum { + return nil, channelForumMissingErr() + } + includeGeneral := req.Limit > 0 && forumTopicQueryMatchesGeneral(req.Q) + list := domain.ChannelForumTopicList{} + if r.deps.Channels != nil && req.Limit > 0 { + limit := req.Limit + if includeGeneral { + limit-- + } + if limit > 0 { + list, err = r.deps.Channels.GetForumTopics(ctx, userID, domain.ChannelForumTopicFilter{ + ChannelID: view.Channel.ID, + Query: req.Q, + OffsetDate: req.OffsetDate, + OffsetID: req.OffsetID, + OffsetTopic: req.OffsetTopic, + Limit: limit, + }) + if err != nil { + return nil, forumTopicError(err) + } + } + } + return r.forumTopicsResponse(ctx, userID, view, list, includeGeneral), nil +} + +func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.MessagesGetForumTopicsByIDRequest) (*tg.MessagesForumTopics, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(req.Topics) > maxForumTopicIDs { + return nil, limitInvalidErr() + } + view, err := r.forumTopicPeerView(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if !view.Channel.Forum { + return nil, channelForumMissingErr() + } + includeGeneral := false + ids := make([]int, 0, len(req.Topics)) + for _, topicID := range req.Topics { + if topicID <= 0 || topicID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if topicID == forumGeneralTopicID { + includeGeneral = true + continue + } + ids = append(ids, topicID) + } + list := domain.ChannelForumTopicList{} + if r.deps.Channels != nil && len(ids) > 0 { + list, err = r.deps.Channels.GetForumTopicsByID(ctx, userID, view.Channel.ID, ids) + if err != nil { + return nil, forumTopicError(err) + } + } + return r.forumTopicsResponse(ctx, userID, view, list, includeGeneral), nil +} + +func (r *Router) onMessagesGetOnlines(ctx context.Context, peer tg.InputPeerClass) (*tg.ChatOnlines, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + domainPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + if domainPeer.Type == domain.PeerTypeChannel && domainPeer.ID != 0 { + return &tg.ChatOnlines{Onlines: r.channelOnlineCount(ctx, userID, domainPeer.ID)}, nil + } + return &tg.ChatOnlines{Onlines: 1}, nil +} + +func (r *Router) channelOnlineCount(ctx context.Context, userID, channelID int64) int { + if channelID == 0 || r.deps.Channels == nil || r.deps.Sessions == nil { + return 1 + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return 1 + } + online := provider.OnlineChannelUserIDs(channelID, domain.MaxChannelRealtimeFanout) + candidates := make([]int64, 0, len(online)+1) + if userID != 0 { + candidates = append(candidates, userID) + } + candidates = append(candidates, online...) + active, err := r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, candidates) + if err != nil { + return 1 + } + return len(active) +} + +func (r *Router) onMessagesSetTyping(ctx context.Context, req *tg.MessagesSetTypingRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + topMsgID, topMsgIDSet := req.GetTopMsgID() + if !topMsgIDSet && req.TopMsgID != 0 { + topMsgID, topMsgIDSet = req.TopMsgID, true + } + if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return false, msgIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID { + if peer.Type == domain.PeerTypeChannel && peer.ID != 0 && r.deps.Channels != nil { + action := req.Action + if action == nil { + action = &tg.SendMessageCancelAction{} + } + updates := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateChannelUserTyping{ + ChannelID: peer.ID, + FromID: &tg.PeerUser{UserID: userID}, + TopMsgID: topMsgID, + Action: action, + }}, + Date: int(r.clock.Now().Unix()), + } + r.pushChannelViewerUpdates(ctx, 0, peer.ID, nil, func(int64) *tg.Updates { + return updates + }) + } + return true, nil + } + action := req.Action + if action == nil { + action = &tg.SendMessageCancelAction{} + } + update := &tg.UpdateUserTyping{ + UserID: userID, + TopMsgID: topMsgID, + Action: action, + } + updates := &tg.UpdateShort{ + Update: update, + Date: int(r.clock.Now().Unix()), + } + r.pushTypingUpdate(ctx, peer.ID, updates) + return true, nil +} + +func (r *Router) pushTypingUpdate(ctx context.Context, targetUserID int64, updates *tg.UpdateShort) { + r.pushUserMessage(ctx, targetUserID, "push typing update", updates) +} + +func (r *Router) onMessagesGetMessages(ctx context.Context, ids []tg.InputMessageClass) (tg.MessagesMessagesClass, error) { + if len(ids) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + if r.deps.Messages == nil || len(ids) == 0 { + return &tg.MessagesMessages{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + + out := make([]tg.MessageClass, 0, len(ids)) + requestedIDs := make([]int, 0, len(ids)) + for _, input := range ids { + id, ok := inputMessageBoxID(input) + if !ok || id <= 0 || id > domain.MaxMessageBoxID { + continue + } + requestedIDs = append(requestedIDs, id) + } + list, err := r.deps.Messages.GetMessages(ctx, userID, requestedIDs) + if err != nil { + return nil, internalErr() + } + foundByID := make(map[int]domain.Message, len(list.Messages)) + for _, msg := range list.Messages { + foundByID[msg.ID] = msg + } + found := make([]domain.Message, 0, len(list.Messages)) + for _, input := range ids { + id, ok := inputMessageBoxID(input) + if !ok || id <= 0 || id > domain.MaxMessageBoxID { + out = append(out, &tg.MessageEmpty{ID: id}) + continue + } + msg, ok := foundByID[id] + if !ok { + out = append(out, &tg.MessageEmpty{ID: id}) + continue + } + found = append(found, msg) + out = append(out, tgMessage(msg)) + } + chats := r.chatsForMessageUpdates(ctx, userID, found) + return &tg.MessagesMessages{ + Messages: out, + Users: r.usersForMessageUpdates(ctx, userID, found), + Chats: chats, + }, nil +} + +func inputMessageBoxID(input tg.InputMessageClass) (int, bool) { + switch msg := input.(type) { + case *tg.InputMessageID: + return msg.ID, true + default: + return 0, false + } +} + +func (r *Router) lookupOwnerMessage(ctx context.Context, userID int64, id int) (domain.Message, bool, error) { + filter := domain.MessageFilter{ + MinID: id - 1, + Limit: 1, + } + if id < domain.MaxMessageBoxID { + filter.MaxID = id + 1 + } + list, err := r.deps.Messages.Search(ctx, userID, filter) + if err != nil { + return domain.Message{}, false, err + } + if len(list.Messages) == 0 || list.Messages[0].ID != id { + return domain.Message{}, false, nil + } + return list.Messages[0], true, nil +} + +func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDraftRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + peerTL := tgPeer(peer) + if peerTL == nil { + return true, nil + } + date := int(r.clock.Now().Unix()) + draft, err := r.dialogDraftFromSaveDraft(ctx, userID, peer, req, date) + if err != nil { + return false, err + } + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.SaveDraft(ctx, userID, draft); err != nil { + return false, dialogDraftErr(err) + } + } + update := &tg.UpdateDraftMessage{ + Peer: peerTL, + Draft: tgDraftMessageFromSaveDraft(req, date), + } + if draft.TopMessageID > 0 { + update.SetTopMsgID(draft.TopMessageID) + } + updates := &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: r.usersForDraftUpdate(ctx, userID, peer), + Chats: r.chatsForDraftUpdate(ctx, userID, peer), + Date: date, + Seq: 0, + } + r.pushDraftUpdate(ctx, userID, updates) + return true, nil +} + +func (r *Router) onMessagesGetAllDrafts(ctx context.Context) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + date := int(r.clock.Now().Unix()) + if r.deps.Dialogs == nil { + return &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: date, Seq: 0}, nil + } + drafts, err := r.deps.Dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser) + if err != nil { + return nil, dialogDraftErr(err) + } + updates := make([]tg.UpdateClass, 0, len(drafts)) + users := r.usersForDrafts(ctx, userID, drafts) + chats := r.chatsForDrafts(ctx, userID, drafts) + for _, draft := range drafts { + peer := tgPeer(draft.Peer) + if peer == nil { + continue + } + update := &tg.UpdateDraftMessage{Peer: peer, Draft: tgDialogDraft(draft)} + if draft.TopMessageID > 0 { + update.SetTopMsgID(draft.TopMessageID) + } + updates = append(updates, update) + } + return &tg.Updates{Updates: updates, Users: users, Chats: chats, Date: date, Seq: 0}, nil +} + +func (r *Router) onMessagesClearAllDrafts(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Dialogs == nil { + return true, nil + } + drafts, err := r.deps.Dialogs.ClearDrafts(ctx, userID, domain.MaxDialogDraftsPerUser) + if err != nil { + return false, dialogDraftErr(err) + } + if len(drafts) == 0 { + return true, nil + } + date := int(r.clock.Now().Unix()) + updates := make([]tg.UpdateClass, 0, len(drafts)) + for _, draft := range drafts { + update := draftClearUpdate(draft.Peer, draft.TopMessageID, date) + if update != nil { + updates = append(updates, update) + } + } + r.pushDraftUpdate(ctx, userID, &tg.Updates{ + Updates: updates, + Users: r.usersForDrafts(ctx, userID, drafts), + Chats: r.chatsForDrafts(ctx, userID, drafts), + Date: date, + Seq: 0, + }) + return true, nil +} + +func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSaveDraftRequest, date int) (domain.DialogDraft, error) { + if req == nil { + return domain.DialogDraft{Peer: peer, Date: date}, nil + } + if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { + return domain.DialogDraft{}, messageTooLongErr() + } + if len(req.Entities) > maxMessageEntityCount { + return domain.DialogDraft{}, limitInvalidErr() + } + if !req.SuggestedPost.Zero() { + return domain.DialogDraft{}, suggestedPostPeerInvalidErr() + } + replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo) + if err != nil { + return domain.DialogDraft{}, err + } + webpage, err := dialogDraftWebPageFromInput(req.Media) + if err != nil { + return domain.DialogDraft{}, err + } + topMessageID := 0 + if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 { + topMessageID = replyTo.TopMessageID + } + return domain.DialogDraft{ + Peer: peer, + TopMessageID: topMessageID, + Date: date, + NoWebpage: req.NoWebpage, + InvertMedia: req.InvertMedia, + Message: req.Message, + Entities: domainMessageEntities(req.Entities), + ReplyTo: replyTo, + WebPage: webpage, + Effect: req.Effect, + }, nil +} + +func dialogDraftWebPageFromInput(media tg.InputMediaClass) (*domain.DialogDraftWebPage, error) { + switch m := media.(type) { + case nil, *tg.InputMediaEmpty: + return nil, nil + case *tg.InputMediaWebPage: + if m.URL == "" { + return nil, mediaInvalidErr() + } + return &domain.DialogDraftWebPage{ + URL: m.URL, + ForceLargeMedia: m.ForceLargeMedia, + ForceSmallMedia: m.ForceSmallMedia, + Optional: m.Optional, + }, nil + default: + return nil, mediaInvalidErr() + } +} + +func draftClearUpdate(peer domain.Peer, topMessageID, date int) *tg.UpdateDraftMessage { + peerTL := tgPeer(peer) + if peerTL == nil { + return nil + } + draft := &tg.DraftMessageEmpty{} + draft.SetDate(date) + update := &tg.UpdateDraftMessage{Peer: peerTL, Draft: draft} + if topMessageID > 0 { + update.SetTopMsgID(topMessageID) + } + return update +} + +func tgDraftMessageFromSaveDraft(req *tg.MessagesSaveDraftRequest, date int) tg.DraftMessageClass { + if req == nil || saveDraftIsEmpty(req) { + draft := &tg.DraftMessageEmpty{} + draft.SetDate(date) + return draft + } + return &tg.DraftMessage{ + NoWebpage: req.NoWebpage, + InvertMedia: req.InvertMedia, + ReplyTo: req.ReplyTo, + Message: req.Message, + Entities: req.Entities, + Media: draftInputMedia(req.Media), + Date: date, + Effect: req.Effect, + SuggestedPost: req.SuggestedPost, + } +} + +func saveDraftIsEmpty(req *tg.MessagesSaveDraftRequest) bool { + return !req.NoWebpage && + !req.InvertMedia && + draftReplyIsEmpty(req.ReplyTo) && + req.Message == "" && + len(req.Entities) == 0 && + draftInputMedia(req.Media) == nil && + req.Effect == 0 && + req.SuggestedPost.Zero() +} + +func draftReplyIsEmpty(reply tg.InputReplyToClass) bool { + if reply == nil { + return true + } + input, ok := reply.(*tg.InputReplyToMessage) + if !ok { + return false + } + topMsgID, hasTopMsgID := input.GetTopMsgID() + return input.ReplyToMsgID == 0 && hasTopMsgID && topMsgID > 0 +} + +func draftInputMedia(media tg.InputMediaClass) tg.InputMediaClass { + switch media.(type) { + case nil, *tg.InputMediaEmpty: + return nil + default: + return media + } +} + +func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass { + if r.deps.Users == nil { + return []tg.UserClass{} + } + users := make([]tg.UserClass, 0, 2) + seen := map[int64]struct{}{} + if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { + users = append(users, r.tgSelfUser(self)) + seen[self.ID] = struct{}{} + } + if peer.Type == domain.PeerTypeUser { + if _, ok := seen[peer.ID]; !ok { + if u, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err == nil && found && u.ID != 0 { + users = append(users, r.tgUser(u)) + } + } + } + return users +} + +func (r *Router) usersForDrafts(ctx context.Context, userID int64, drafts []domain.DialogDraft) []tg.UserClass { + users := make([]tg.UserClass, 0, len(drafts)+1) + seen := map[int64]struct{}{} + if r.deps.Users != nil { + if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { + users = append(users, r.tgSelfUser(self)) + seen[self.ID] = struct{}{} + } + } + for _, draft := range drafts { + if draft.Peer.Type != domain.PeerTypeUser || draft.Peer.ID == 0 { + continue + } + if _, ok := seen[draft.Peer.ID]; ok { + continue + } + if r.deps.Users != nil { + if u, found, err := r.deps.Users.ByID(ctx, userID, draft.Peer.ID); err == nil && found && u.ID != 0 { + users = append(users, r.tgUser(u)) + seen[u.ID] = struct{}{} + } + } + } + return users +} + +func (r *Router) chatsForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.ChatClass { + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 || r.deps.Channels == nil { + return []tg.ChatClass{} + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil || view.Channel.ID == 0 { + return []tg.ChatClass{} + } + return []tg.ChatClass{tgChannelChat(userID, view.Channel, &view.Self)} +} + +func (r *Router) chatsForDrafts(ctx context.Context, userID int64, drafts []domain.DialogDraft) []tg.ChatClass { + if r.deps.Channels == nil { + return []tg.ChatClass{} + } + chats := make([]tg.ChatClass, 0) + seen := map[int64]struct{}{} + for _, draft := range drafts { + if draft.Peer.Type != domain.PeerTypeChannel || draft.Peer.ID == 0 { + continue + } + if _, ok := seen[draft.Peer.ID]; ok { + continue + } + seen[draft.Peer.ID] = struct{}{} + view, err := r.deps.Channels.GetChannel(ctx, userID, draft.Peer.ID) + if err != nil || view.Channel.ID == 0 { + continue + } + chats = append(chats, tgChannelChat(userID, view.Channel, &view.Self)) + } + return chats +} + +func dialogDraftErr(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, domain.ErrReplyMessageIDInvalid): + return replyMessageIDInvalidErr() + case errors.Is(err, domain.ErrChannelInvalid): + return peerIDInvalidErr() + default: + return internalErr() + } +} + +func (r *Router) pushDraftUpdate(ctx context.Context, userID int64, updates *tg.Updates) { + r.pushUserMessage(ctx, userID, "push draft update", updates) +} + +func (r *Router) clearDraftAfterSend(ctx context.Context, userID int64, peer domain.Peer, replyTo *domain.MessageReply) { + if r.deps.Dialogs == nil || userID == 0 || peer.ID == 0 { + return + } + topMessageID := 0 + if peer.Type == domain.PeerTypeChannel && replyTo != nil && replyTo.TopMessageID > 0 { + topMessageID = replyTo.TopMessageID + } + changed, err := r.deps.Dialogs.DeleteDraft(ctx, userID, peer, topMessageID) + if err != nil { + r.log.Debug("clear draft after send", zap.Int64("user_id", userID), zap.Error(err)) + return + } + if !changed { + return + } + date := int(r.clock.Now().Unix()) + update := draftClearUpdate(peer, topMessageID, date) + if update == nil { + return + } + r.pushDraftUpdate(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: r.usersForDraftUpdate(ctx, userID, peer), + Chats: r.chatsForDraftUpdate(ctx, userID, peer), + Date: date, + Seq: 0, + }) +} + +func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSearchGlobalRequest) (tg.MessagesMessagesClass, error) { + if req.BroadcastsOnly && req.GroupsOnly { + return &tg.MessagesMessages{}, nil + } + query := normalizeSearchQuery(req.Q) + if query == "" { + return nil, searchQueryEmptyErr() + } + if utf8.RuneCountInString(query) > maxMessageSearchQLength { + return nil, limitInvalidErr() + } + if searchFilterNeedsMediaStore(req.Filter) { + return &tg.MessagesMessages{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { + limit = domain.MaxChannelGlobalSearchLimit + } + folderID, hasFolderID := req.GetFolderID() + if hasFolderID && folderID < 0 { + return nil, folderIDInvalidErr() + } + channelOffsetID, err := r.searchGlobalChannelOffsetID(ctx, userID, req.OffsetPeer) + if err != nil { + return nil, err + } + var private domain.MessageList + if !req.BroadcastsOnly && !req.GroupsOnly && r.deps.Messages != nil { + filter := domain.MessageFilter{ + Query: query, + OffsetID: req.OffsetID, + OffsetDate: req.OffsetRate, + Limit: limit + 1, + } + if req.MaxDate > 0 { + filter.OffsetDate = req.MaxDate + } + if req.UsersOnly || !req.BroadcastsOnly && !req.GroupsOnly { + private, err = r.deps.Messages.Search(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + private = r.withMessageListPresence(private) + } + } + if req.UsersOnly || r.deps.Channels == nil { + return tgMessagesMessages(r.withMessageListPresence(limitMessageList(private, limit))), nil + } + channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{ + Query: query, + BroadcastsOnly: req.BroadcastsOnly, + GroupsOnly: req.GroupsOnly, + HasFolderID: hasFolderID, + FolderID: folderID, + OffsetRate: req.OffsetRate, + OffsetChannelID: channelOffsetID, + OffsetID: req.OffsetID, + MinDate: req.MinDate, + MaxDate: req.MaxDate, + Limit: limit, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + channelHistory = r.enrichChannelHistory(ctx, userID, channelHistory) + if req.BroadcastsOnly || req.GroupsOnly { + return tgGlobalChannelMessages(userID, limitChannelHistory(channelHistory, limit)), nil + } + return tgGlobalSearchMessages(userID, limit, private, channelHistory), nil +} + +func (r *Router) searchGlobalChannelOffsetID(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { + if peer == nil { + return 0, nil + } + switch peer.(type) { + case *tg.InputPeerEmpty: + return 0, nil + } + ref, ok := inputPeerChannelRef(peer) + if !ok { + return 0, nil + } + if ref.ID <= 0 { + return 0, peerIDInvalidErr() + } + if ref.CheckAccessHash && r.deps.Channels != nil { + view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) + if err != nil { + return 0, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, channelInvalidErr(domain.ErrChannelPrivate) + } + } + return ref.ID, nil +} + +func limitMessageList(list domain.MessageList, limit int) domain.MessageList { + if limit <= 0 { + limit = domain.MaxChannelGlobalSearchLimit + } + if len(list.Messages) > limit { + list.Messages = list.Messages[:limit] + list.Count = limit + 1 + } + return list +} + +func limitChannelHistory(history domain.ChannelHistory, limit int) domain.ChannelHistory { + if limit <= 0 { + limit = domain.MaxChannelGlobalSearchLimit + } + if len(history.Messages) > limit { + history.Messages = history.Messages[:limit] + history.Count = limit + 1 + } + return history +} + +func tgGlobalChannelMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { + out := tgChannelHistoryMessages(viewerUserID, history) + if slice, ok := out.(*tg.MessagesMessagesSlice); ok && len(history.Messages) > 0 { + slice.SetNextRate(history.Messages[len(history.Messages)-1].Date) + } + return out +} + +type globalSearchHit struct { + date int + peerRank int64 + messageID int + message tg.MessageClass +} + +func tgGlobalSearchMessages(viewerUserID int64, limit int, private domain.MessageList, channel domain.ChannelHistory) tg.MessagesMessagesClass { + if limit <= 0 { + limit = domain.MaxChannelGlobalSearchLimit + } + hits := make([]globalSearchHit, 0, len(private.Messages)+len(channel.Messages)) + for _, msg := range private.Messages { + item := tgMessage(msg) + if item == nil { + continue + } + hits = append(hits, globalSearchHit{ + date: msg.Date, + peerRank: msg.Peer.ID, + messageID: msg.ID, + message: item, + }) + } + for _, msg := range channel.Messages { + item := tgChannelMessage(viewerUserID, msg) + if item == nil { + continue + } + hits = append(hits, globalSearchHit{ + date: msg.Date, + peerRank: msg.ChannelID, + messageID: msg.ID, + message: item, + }) + } + sort.Slice(hits, func(i, j int) bool { + a, b := hits[i], hits[j] + if a.date != b.date { + return a.date > b.date + } + if a.peerRank != b.peerRank { + return a.peerRank > b.peerRank + } + return a.messageID > b.messageID + }) + hasMore := private.Count > len(private.Messages) || channel.Count > len(channel.Messages) || len(hits) > limit + if len(hits) > limit { + hits = hits[:limit] + } + messages := make([]tg.MessageClass, 0, len(hits)) + for _, hit := range hits { + messages = append(messages, hit.message) + } + users := append(tgUsers(private.Users), tgUsers(channel.Users)...) + chats := tgChannels(viewerUserID, channel.Channels) + if hasMore { + out := &tg.MessagesMessagesSlice{ + Count: limit + 1, + Messages: messages, + Chats: chats, + Users: users, + } + if len(hits) > 0 { + out.SetNextRate(hits[len(hits)-1].date) + } + return out + } + return &tg.MessagesMessages{Messages: messages, Chats: chats, Users: users} +} + +func (r *Router) onMessagesGetDialogFilters(ctx context.Context) (*tg.MessagesDialogFilters, error) { + if r.deps.Dialogs == nil { + return tgDialogFilters(domain.DialogFolderList{}), nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + list, err := r.deps.Dialogs.GetDialogFolders(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgDialogFilters(list), nil +} + +func (r *Router) onMessagesUpdateDialogFilter(ctx context.Context, req *tg.MessagesUpdateDialogFilterRequest) (bool, error) { + if req.ID < domain.DialogCustomFolderMinID { + return false, filterIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + filter, ok := req.GetFilter() + var folder *domain.DialogFolder + if ok { + parsed, err := r.dialogFolderFromTG(ctx, userID, req.ID, filter) + if err != nil { + return false, err + } + folder = &parsed + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.SaveDialogFolder(ctx, userID, parsed); err != nil { + return false, internalErr() + } + } + } else if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.DeleteDialogFolder(ctx, userID, req.ID); err != nil { + return false, internalErr() + } + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilter, + FilterID: req.ID, + DialogFilter: folder, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, req.ID, folder, sessionID) + if err != nil { + return false, internalErr() + } + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) + return true, nil +} + +func (r *Router) onMessagesUpdateDialogFiltersOrder(ctx context.Context, order []int) (bool, error) { + if len(order) > domain.MaxDialogFolders { + return false, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + clean := cleanDialogFilterOrder(order) + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.ReorderDialogFolders(ctx, userID, clean); err != nil { + return false, internalErr() + } + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilterOrder, + FilterOrder: clean, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordDialogFilterOrder(ctx, authKeyID, userID, clean, sessionID) + if err != nil { + return false, internalErr() + } + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) + return true, nil +} + +func (r *Router) onMessagesToggleDialogFilterTags(ctx context.Context, enabled bool) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.ToggleDialogFolderTags(ctx, userID, enabled); err != nil { + return false, internalErr() + } + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilters, + TagsEnabled: enabled, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordDialogFiltersReload(ctx, authKeyID, userID, sessionID) + if err != nil { + return false, internalErr() + } + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) + return true, nil +} + +func (r *Router) onMessagesSaveDefaultSendAs(ctx context.Context, req *tg.MessagesSaveDefaultSendAsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if userID == 0 { + return false, peerIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if peer.Type != domain.PeerTypeChannel { + return false, peerIDInvalidErr() + } + sendAs, err := r.sendAsPeerFromInput(ctx, userID, peer, req.SendAs) + if err != nil { + return false, err + } + if r.deps.Channels == nil { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + if _, err := r.deps.Channels.SaveDefaultSendAs(ctx, userID, domain.SaveChannelDefaultSendAsRequest{ + UserID: userID, + ChannelID: peer.ID, + SendAs: sendAs, + }); err != nil { + return false, channelInvalidErr(err) + } + return true, nil +} + +func (r *Router) sendAsPeerFromInput(ctx context.Context, userID int64, to domain.Peer, input tg.InputPeerClass) (*domain.Peer, error) { + if input == nil { + return nil, nil + } + if to.Type != domain.PeerTypeChannel { + return nil, sendAsPeerInvalidErr() + } + sendAs, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return nil, sendAsPeerInvalidErr() + } + switch sendAs.Type { + case domain.PeerTypeUser: + if sendAs.ID != userID { + return nil, sendAsPeerInvalidErr() + } + return nil, nil + case domain.PeerTypeChannel: + if sendAs.ID != to.ID { + return nil, sendAsPeerInvalidErr() + } + if err := r.validateCurrentChannelSendAs(ctx, userID, to.ID); err != nil { + return nil, err + } + out := sendAs + return &out, nil + default: + return nil, sendAsPeerInvalidErr() + } +} + +func (r *Router) resolveSendAsPeer(ctx context.Context, userID int64, to domain.Peer, input tg.InputPeerClass) (*domain.Peer, error) { + if input != nil { + return r.sendAsPeerFromInput(ctx, userID, to, input) + } + if to.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return nil, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, to.ID) + if err != nil { + return nil, nil + } + return validDefaultSendAsPeer(view), nil +} + +func validDefaultSendAsPeer(view domain.ChannelView) *domain.Peer { + if view.Dialog.DefaultSendAs == nil || view.Dialog.DefaultSendAs.ID == 0 { + return nil + } + switch view.Dialog.DefaultSendAs.Type { + case domain.PeerTypeUser: + return nil + case domain.PeerTypeChannel: + if view.Dialog.DefaultSendAs.ID != view.Channel.ID || !canCurrentChannelSendAs(view) { + return nil + } + out := *view.Dialog.DefaultSendAs + return &out + default: + return nil + } +} + +func (r *Router) validateCurrentChannelSendAs(ctx context.Context, userID, channelID int64) error { + if r.deps.Channels == nil { + return sendAsPeerInvalidErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) + if err != nil { + return sendAsPeerInvalidErr() + } + if canCurrentChannelSendAs(view) { + return nil + } + return sendAsPeerInvalidErr() +} + +func canCurrentChannelSendAs(view domain.ChannelView) bool { + if view.Self.Role == domain.ChannelRoleCreator { + return true + } + if view.Channel.Broadcast && view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.PostMessages { + return true + } + if !view.Channel.Broadcast && view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.Anonymous { + return true + } + return false +} + +func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) { + start := r.clock.Now() + var duplicate bool + var sendErr error + defer func() { + r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr) + }() + if req.Message == "" { + sendErr = messageEmptyErr() + return nil, messageEmptyErr() + } + if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { + sendErr = messageTooLongErr() + return nil, sendErr + } + if len(req.Entities) > maxMessageEntityCount { + sendErr = limitInvalidErr() + return nil, sendErr + } + if req.RandomID == 0 { + sendErr = randomIDEmptyErr() + return nil, sendErr + } + if req.ScheduleDate != 0 || req.ScheduleRepeatPeriod != 0 { + sendErr = scheduleDateInvalidErr() + return nil, sendErr + } + if err := sendMessageUnsupportedOptionErr(req); err != nil { + sendErr = err + return nil, sendErr + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + sendErr = internalErr() + return nil, sendErr + } + if userID == 0 { + sendErr = peerIDInvalidErr() + return nil, sendErr + } + if r.deps.Limiter != nil { + allowed, retryAfter, err := r.deps.Limiter.Allow(ctx, "messages:send:"+strconv.FormatInt(userID, 10), sendMessageRateLimit, sendMessageRateWindow) + if err != nil { + sendErr = internalErr() + return nil, sendErr + } + if !allowed { + r.metrics().MessageRateLimited(retryAfter) + sendErr = floodWaitErr(retryAfter) + return nil, sendErr + } + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + sendErr = err + return nil, sendErr + } + updates, dup, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ + randomID: req.RandomID, + message: req.Message, + entities: req.Entities, + silent: req.Silent, + noforwards: req.Noforwards, + replyToInput: req.ReplyTo, + sendAsInput: req.SendAs, + clearDraft: req.ClearDraft, + }) + duplicate = dup + if err != nil { + sendErr = err + return nil, sendErr + } + return updates, nil +} + +func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.MessagesForwardMessagesRequest) (tg.UpdatesClass, error) { + if len(req.ID) == 0 || len(req.ID) != len(req.RandomID) { + return nil, inputRequestInvalidErr() + } + if len(req.ID) > domain.MaxForwardMessageIDs { + return nil, limitInvalidErr() + } + if req.ScheduleDate != 0 || req.ScheduleRepeatPeriod != 0 { + return nil, scheduleDateInvalidErr() + } + if err := forwardMessagesUnsupportedOptionErr(req); err != nil { + return nil, err + } + topMsgID, topMsgIDSet := req.GetTopMsgID() + if !topMsgIDSet && req.TopMsgID != 0 { + topMsgID, topMsgIDSet = req.TopMsgID, true + } + if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, replyMessageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.FromPeer) + if err != nil { + return nil, err + } + toPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToPeer) + if err != nil { + return nil, err + } + sendAs, err := r.resolveSendAsPeer(ctx, userID, toPeer, req.SendAs) + if err != nil { + return nil, err + } + replyTo, err := r.messageReplyFromInput(ctx, userID, toPeer, req.ReplyTo) + if err != nil { + return nil, err + } + replyTo, err = mergeForwardTopMsgID(toPeer, replyTo, topMsgID, topMsgIDSet) + if err != nil { + return nil, err + } + if r.deps.Users != nil { + for _, peer := range []domain.Peer{fromPeer, toPeer} { + if peer.Type != domain.PeerTypeUser || peer.ID == userID { + continue + } + if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil { + return nil, internalErr() + } else if !found { + return nil, peerIDInvalidErr() + } + } + } + for i, id := range req.ID { + if id <= 0 || id > domain.MaxMessageBoxID || req.RandomID[i] == 0 { + return nil, messageIDInvalidErr() + } + } + if r.deps.Limiter != nil { + allowed, retryAfter, err := r.deps.Limiter.Allow(ctx, "messages:forward:"+strconv.FormatInt(userID, 10), sendMessageRateLimit, sendMessageRateWindow) + if err != nil { + return nil, internalErr() + } + if !allowed { + r.metrics().MessageRateLimited(retryAfter) + return nil, floodWaitErr(retryAfter) + } + } + if toPeer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return nil, peerIDInvalidErr() + } + sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID) + if err != nil { + return nil, messageForwardErr(err) + } + recipients := make([]int64, 0) + results := make([]domain.SendChannelMessageResult, 0, len(sources)) + extraUserIDs := make([]int64, 0, len(sources)) + for i, source := range sources { + forward := source.forward + if req.DropAuthor { + forward = nil + } + res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{ + UserID: userID, + ChannelID: toPeer.ID, + RandomID: req.RandomID[i], + Message: source.body, + Entities: source.entities, + Silent: req.Silent, + NoForwards: req.Noforwards, + ReplyTo: replyTo, + Forward: forward, + SendAs: sendAs, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + results = append(results, res) + recipients = append(recipients, res.Recipients...) + if sourceUserID := source.userID(); sourceUserID != 0 { + extraUserIDs = append(extraUserIDs, sourceUserID) + } + } + updates := r.channelMessagesUpdates(ctx, userID, results, req.RandomID, true, extraUserIDs) + r.pushChannelUpdates(ctx, userID, toPeer.ID, recipients, func(viewerUserID int64) *tg.Updates { + return r.channelMessagesUpdates(ctx, viewerUserID, results, nil, false, extraUserIDs) + }) + for _, res := range results { + r.pushChannelDiscussionUpdate(ctx, userID, res.Discussion) + } + return updates, nil + } + if fromPeer.Type == domain.PeerTypeChannel && toPeer.Type == domain.PeerTypeUser { + if r.deps.Channels == nil || r.deps.Messages == nil { + return nil, peerIDInvalidErr() + } + sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID) + if err != nil { + return nil, messageForwardErr(err) + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res := domain.ForwardPrivateMessagesResult{OwnerUserID: userID} + for i, source := range sources { + forward := source.forward + if req.DropAuthor { + forward = nil + } + sent, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: toPeer.ID, + RandomID: req.RandomID[i], + Message: source.body, + Entities: source.entities, + Silent: req.Silent, + NoForwards: req.Noforwards, + ReplyTo: replyTo, + Forward: forward, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, messageForwardErr(err) + } + res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) + res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) + res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) + res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) + res.Duplicates = append(res.Duplicates, sent.Duplicate) + } + return tgForwardMessagesUpdates(res, req.RandomID, r.usersForMessageUpdates(ctx, userID, res.SenderMessages), r.chatsForMessageUpdates(ctx, userID, res.SenderMessages)), nil + } + if fromPeer.Type != domain.PeerTypeUser || toPeer.Type != domain.PeerTypeUser || r.deps.Messages == nil { + return nil, peerIDInvalidErr() + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res, err := r.deps.Messages.ForwardPrivateMessages(ctx, userID, domain.ForwardPrivateMessagesRequest{ + OwnerUserID: userID, + FromPeer: fromPeer, + ToUserID: toPeer.ID, + MessageIDs: append([]int(nil), req.ID...), + RandomIDs: append([]int64(nil), req.RandomID...), + Silent: req.Silent, + NoForwards: req.Noforwards, + DropAuthor: req.DropAuthor, + ReplyTo: replyTo, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, messageForwardErr(err) + } + return tgForwardMessagesUpdates(res, req.RandomID, r.usersForMessageUpdates(ctx, userID, res.SenderMessages), r.chatsForMessageUpdates(ctx, userID, res.SenderMessages)), nil +} + +func mergeForwardTopMsgID(toPeer domain.Peer, replyTo *domain.MessageReply, topMsgID int, topMsgIDSet bool) (*domain.MessageReply, error) { + if !topMsgIDSet || topMsgID == 0 { + return replyTo, nil + } + if topMsgID < 0 || topMsgID > domain.MaxMessageBoxID || toPeer.Type != domain.PeerTypeChannel { + return nil, replyMessageIDInvalidErr() + } + if replyTo == nil { + return &domain.MessageReply{ + Peer: toPeer, + TopMessageID: topMsgID, + ForumTopic: true, + }, nil + } + if replyTo.Peer.ID != 0 && replyTo.Peer != toPeer { + return nil, replyMessageIDInvalidErr() + } + if replyTo.TopMessageID != 0 && replyTo.TopMessageID != topMsgID { + return nil, replyMessageIDInvalidErr() + } + merged := *replyTo + merged.Peer = toPeer + merged.TopMessageID = topMsgID + merged.QuoteEntities = append([]domain.MessageEntity(nil), replyTo.QuoteEntities...) + if merged.MessageID == 0 { + merged.ForumTopic = true + } + return &merged, nil +} + +type forwardSource struct { + body string + entities []domain.MessageEntity + forward *domain.MessageForward + from domain.Peer + date int + noForward bool +} + +func (s forwardSource) userID() int64 { + if s.from.Type == domain.PeerTypeUser { + return s.from.ID + } + if s.forward != nil && s.forward.From.Type == domain.PeerTypeUser { + return s.forward.From.ID + } + return 0 +} + +func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int) ([]forwardSource, error) { + out := make([]forwardSource, 0, len(ids)) + for _, id := range ids { + switch fromPeer.Type { + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return nil, domain.ErrMessageIDInvalid + } + list, err := r.deps.Messages.GetHistory(ctx, userID, domain.MessageFilter{ + HasPeer: true, + Peer: fromPeer, + Limit: 1, + MaxID: id, + MinID: id - 1, + }) + if err != nil || len(list.Messages) != 1 || list.Messages[0].ID != id { + return nil, domain.ErrMessageIDInvalid + } + msg := list.Messages[0] + if msg.NoForwards { + return nil, domain.ErrChatForwardsRestricted + } + forward := cloneDomainMessageForward(msg.Forward) + if forward == nil { + forward = &domain.MessageForward{From: msg.From, Date: msg.Date} + } + out = append(out, forwardSource{ + body: msg.Body, + entities: append([]domain.MessageEntity(nil), + msg.Entities...), + forward: forward, + from: msg.From, + date: msg.Date, + }) + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return nil, domain.ErrMessageIDInvalid + } + history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{ + ChannelID: fromPeer.ID, + Limit: 1, + MaxID: id, + MinID: id - 1, + }) + if err != nil || len(history.Messages) != 1 || history.Messages[0].ID != id { + return nil, domain.ErrMessageIDInvalid + } + msg := history.Messages[0] + if msg.NoForwards || history.Channel.NoForwards { + return nil, domain.ErrChatForwardsRestricted + } + if msg.Body == "" || msg.Action != nil { + return nil, domain.ErrMessageIDInvalid + } + forward := cloneDomainMessageForward(msg.Forward) + from := msg.From + if from.ID == 0 && msg.SenderUserID != 0 { + from = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID} + } + if msg.Post { + from = domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID} + } + if forward == nil { + forward = &domain.MessageForward{From: from, Date: msg.Date} + if from.Type == domain.PeerTypeChannel { + forward.ChannelPost = msg.ID + } + } + out = append(out, forwardSource{ + body: msg.Body, + entities: append([]domain.MessageEntity(nil), + msg.Entities...), + forward: forward, + from: from, + date: msg.Date, + }) + default: + return nil, domain.ErrMessageIDInvalid + } + } + return out, nil +} + +func cloneDomainMessageForward(in *domain.MessageForward) *domain.MessageForward { + if in == nil { + return nil + } + out := *in + return &out +} + +func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEditMessageRequest) (tg.UpdatesClass, error) { + if _, ok := req.GetScheduleDate(); ok { + return nil, scheduleDateInvalidErr() + } + if _, ok := req.GetScheduleRepeatPeriod(); ok { + return nil, scheduleDateInvalidErr() + } + if _, ok := req.GetQuickReplyShortcutID(); ok { + return nil, messageIDInvalidErr() + } + if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) { + return nil, mediaInvalidErr() + } + if _, ok := req.GetReplyMarkup(); ok { + return nil, replyMarkupInvalidErr() + } + message, ok := req.GetMessage() + if !ok { + return nil, messageEmptyErr() + } + if message == "" { + return nil, messageEmptyErr() + } + if utf8.RuneCountInString(message) > maxSendMessageTextLength { + return nil, messageTooLongErr() + } + entities, _ := req.GetEntities() + if len(entities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.EditMessage(ctx, userID, domain.EditChannelMessageRequest{ + UserID: userID, + ChannelID: peer.ID, + ID: req.ID, + Message: message, + Entities: domainMessageEntities(entities), + EditDate: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelEditErr(err) + } + updates := r.channelEditMessageUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelEditMessageUpdates(ctx, viewerUserID, res) + }) + return updates, nil + } + if peer.Type != domain.PeerTypeUser || r.deps.Messages == nil { + return nil, peerIDInvalidErr() + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{ + OwnerUserID: userID, + Peer: peer, + ID: req.ID, + Message: message, + Entities: domainMessageEntities(entities), + EditDate: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, messageEditErr(err) + } + self := res.Self() + if self.Event.Pts == 0 || self.Message.ID == 0 { + return nil, messageIDInvalidErr() + } + users := r.usersForMessageUpdate(ctx, userID, self.Message) + chats := r.chatsForMessageUpdate(ctx, userID, self.Message) + return tgEditMessageUpdates(self.Event, self.Message, users, chats), nil +} + +func editMessageMediaCanDegradeToText(media tg.InputMediaClass) bool { + switch media.(type) { + case *tg.InputMediaEmpty, *tg.InputMediaWebPage: + return true + default: + return false + } +} + +func (r *Router) onMessagesGetMessageEditData(ctx context.Context, req *tg.MessagesGetMessageEditDataRequest) (*tg.MessagesMessageEditData, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + switch peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return nil, peerIDInvalidErr() + } + history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{ + ChannelID: peer.ID, + Limit: 1, + MaxID: req.ID, + MinID: req.ID - 1, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + if len(history.Messages) != 1 || history.Messages[0].ID != req.ID { + return nil, messageIDInvalidErr() + } + msg := history.Messages[0] + if msg.Deleted || msg.Action != nil { + return nil, messageIDInvalidErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + if msg.SenderUserID != userID && !canEditChannelMessageForRPC(view.Self) { + return nil, messageAuthorRequiredErr() + } + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return nil, messageIDInvalidErr() + } + msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.ID) + if err != nil { + return nil, internalErr() + } + if !ok || msg.Peer != peer { + return nil, messageIDInvalidErr() + } + if !msg.Out || msg.From.ID != userID { + return nil, messageAuthorRequiredErr() + } + default: + return nil, peerIDInvalidErr() + } + return &tg.MessagesMessageEditData{}, nil +} + +func canEditChannelMessageForRPC(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) +} + +func (r *Router) onMessagesGetOutboxReadDate(ctx context.Context, req *tg.MessagesGetOutboxReadDateRequest) (*tg.OutboxReadDate, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Messages == nil { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + date, err := r.deps.Messages.GetOutboxReadDate(ctx, userID, domain.OutboxReadDateRequest{ + OwnerUserID: userID, + Peer: peer, + ID: req.MsgID, + }) + if err != nil { + return nil, messageReadDateErr(err) + } + return &tg.OutboxReadDate{Date: date}, nil +} + +func (r *Router) onMessagesGetMessageReadParticipants(ctx context.Context, req *tg.MessagesGetMessageReadParticipantsRequest) ([]tg.ReadParticipantDate, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + if r.deps.Channels == nil { + return []tg.ReadParticipantDate{}, nil + } + res, err := r.deps.Channels.GetMessageReadParticipants(ctx, userID, domain.ChannelReadParticipantsRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.MsgID, + Limit: domain.MaxChannelReadParticipants, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, channelInvalidErr(err) + } + out := make([]tg.ReadParticipantDate, 0, len(res.Participants)) + for _, p := range res.Participants { + if p.UserID == 0 { + continue + } + out = append(out, tg.ReadParticipantDate{UserID: p.UserID, Date: p.Date}) + } + return out, nil +} + +func (r *Router) onMessagesDeleteMessages(ctx context.Context, req *tg.MessagesDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(req.ID) == 0 || r.deps.Messages == nil { + return r.affectedMessages(ctx, authKeyID, userID) + } + if len(req.ID) > domain.MaxDeleteMessageIDs { + return nil, limitInvalidErr() + } + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.DeleteMessages(ctx, userID, domain.DeleteMessagesRequest{ + OwnerUserID: userID, + IDs: req.ID, + Revoke: req.GetRevoke(), + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, internalErr() + } + self := res.Self() + if len(self.MessageIDs) == 0 || self.Event.Pts == 0 { + return r.affectedMessages(ctx, authKeyID, userID) + } + return &tg.MessagesAffectedMessages{Pts: self.Event.Pts, PtsCount: self.Event.PtsCount}, nil +} + +func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDeleteHistoryRequest) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, ok := req.GetMinDate(); ok { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + if _, ok := req.GetMaxDate(); ok { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ + UserID: userID, + ChannelID: peer.ID, + MaxID: req.MaxID, + ForEveryone: req.GetRevoke(), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelDeleteErr(err) + } + if res.Event.Pts != 0 { + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) + }) + return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil + } + if res.AvailableMinID > 0 { + event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID) + updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID) + r.pushUserUpdates(ctx, userID, updates) + if event.Pts != 0 { + return &tg.MessagesAffectedHistory{Pts: event.Pts, PtsCount: event.PtsCount, Offset: res.Offset}, nil + } + } + return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil + } + if peer.Type != domain.PeerTypeUser { + return nil, peerIDInvalidErr() + } + if r.deps.Messages == nil { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.DeleteHistory(ctx, userID, domain.DeleteHistoryRequest{ + OwnerUserID: userID, + Peer: peer, + MaxID: req.MaxID, + JustClear: req.GetJustClear(), + Revoke: req.GetRevoke(), + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, internalErr() + } + self := res.Self() + if len(self.MessageIDs) == 0 || self.Event.Pts == 0 { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + return &tg.MessagesAffectedHistory{ + Pts: self.Event.Pts, + PtsCount: self.Event.PtsCount, + Offset: res.Offset, + }, nil +} + +func messageEditErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrMessageAuthorRequired): + return messageAuthorRequiredErr() + case errors.Is(err, domain.ErrMessageNotModified): + return messageNotModifiedErr() + default: + return internalErr() + } +} + +func channelEditErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrMessageAuthorRequired): + return messageAuthorRequiredErr() + case errors.Is(err, domain.ErrMessageNotModified): + return messageNotModifiedErr() + default: + return channelInvalidErr(err) + } +} + +func messageReadDateErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrMessageNotReadYet): + return messageNotReadYetErr() + default: + return internalErr() + } +} + +func messageReactionErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + default: + return internalErr() + } +} + +func messageSendErr(err error) error { + switch { + case errors.Is(err, domain.ErrReplyMessageIDInvalid): + return replyMessageIDInvalidErr() + default: + return internalErr() + } +} + +func messageForwardErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrChatForwardsRestricted): + return chatForwardsRestrictedErr() + case errors.Is(err, domain.ErrReplyMessageIDInvalid): + return replyMessageIDInvalidErr() + default: + return internalErr() + } +} + +func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer domain.Peer, input tg.InputReplyToClass) (*domain.MessageReply, error) { + if input == nil { + return nil, nil + } + reply, ok := input.(*tg.InputReplyToMessage) + if !ok { + switch input.(type) { + case *tg.InputReplyToStory: + return nil, storyIDInvalidErr() + case *tg.InputReplyToMonoForum: + return nil, replyToMonoforumPeerInvalidErr() + default: + return nil, inputConstructorInvalidErr() + } + } + if _, ok := reply.GetMonoforumPeerID(); ok { + return nil, replyToMonoforumPeerInvalidErr() + } + if _, ok := reply.GetTodoItemID(); ok { + return nil, replyMessageIDInvalidErr() + } + if _, ok := reply.GetPollOption(); ok { + return nil, pollOptionInvalidErr() + } + replyPeer := peer + if inputPeer, ok := reply.GetReplyToPeerID(); ok { + parsed, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer) + if err != nil || parsed != peer { + return nil, replyMessageIDInvalidErr() + } + replyPeer = parsed + } + topMsgID, ok := reply.GetTopMsgID() + if ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, replyMessageIDInvalidErr() + } + if reply.ReplyToMsgID < 0 || reply.ReplyToMsgID > domain.MaxMessageBoxID { + return nil, replyMessageIDInvalidErr() + } + if reply.ReplyToMsgID == 0 && topMsgID == 0 { + return nil, replyMessageIDInvalidErr() + } + quoteText, _ := reply.GetQuoteText() + if utf8.RuneCountInString(quoteText) > maxReplyQuoteLength { + return nil, limitInvalidErr() + } + quoteEntities, _ := reply.GetQuoteEntities() + if len(quoteEntities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + quoteOffset, ok := reply.GetQuoteOffset() + if ok && (quoteOffset < 0 || quoteOffset > domain.MaxMessageReplyQuoteOffset) { + return nil, replyMessageIDInvalidErr() + } + return &domain.MessageReply{ + MessageID: reply.ReplyToMsgID, + Peer: replyPeer, + TopMessageID: topMsgID, + QuoteText: quoteText, + QuoteEntities: domainMessageEntities(quoteEntities), + QuoteOffset: quoteOffset, + }, nil +} + +func sendMessageUnsupportedOptionErr(req *tg.MessagesSendMessageRequest) error { + switch { + case req.ReplyMarkup != nil: + return replyMarkupInvalidErr() + case req.QuickReplyShortcut != nil: + return shortcutInvalidErr() + case req.Effect != 0: + return effectIDInvalidErr() + case req.AllowPaidStars < 0: + return starsAmountInvalidErr() + case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: + return paymentUnsupportedErr() + case !req.SuggestedPost.Zero(): + return suggestedPostPeerInvalidErr() + default: + return nil + } +} + +func forwardMessagesUnsupportedOptionErr(req *tg.MessagesForwardMessagesRequest) error { + switch { + case req.QuickReplyShortcut != nil: + return shortcutInvalidErr() + case req.Effect != 0: + return effectIDInvalidErr() + case req.VideoTimestamp != 0: + return mediaInvalidErr() + case req.AllowPaidStars < 0: + return starsAmountInvalidErr() + case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: + return paymentUnsupportedErr() + case !req.SuggestedPost.Zero(): + return suggestedPostPeerInvalidErr() + default: + return nil + } +} + +func (r *Router) onMessagesGetPeerSettings(ctx context.Context, input tg.InputPeerClass) (*tg.MessagesPeerSettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return nil, err + } + settings := domain.PeerSettings{} + if r.deps.Contacts != nil { + settings, err = r.deps.Contacts.GetPeerSettings(ctx, userID, peer) + if err != nil { + return nil, internalErr() + } + } + if r.deps.Dialogs != nil { + hidden, err := r.deps.Dialogs.PeerSettingsBarHidden(ctx, userID, peer) + if err != nil { + return nil, internalErr() + } + settings.HiddenPeerSettingsBar = hidden + } + return &tg.MessagesPeerSettings{ + Settings: tgPeerSettings(settings), + Users: r.peerSettingsUsers(ctx, userID, input), + }, nil +} + +func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.MessagesToggleDialogPinRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) + if err != nil { + return false, err + } + if len(peers) != 1 { + return false, peerIDInvalidErr() + } + pinned := req.GetPinned() + if r.deps.Dialogs == nil { + return true, nil + } + changed, err := r.deps.Dialogs.TogglePinned(ctx, userID, peers[0], pinned) + if err != nil { + return false, internalErr() + } + if changed { + date := int(r.clock.Now().Unix()) + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + _, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, peers[0], pinned, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateDialogPinned{ + Pinned: pinned, + Peer: tgDialogPeer(peers[0]), + }}, + Date: date, + Seq: 0, + }) + } + return true, nil +} + +func (r *Router) onMessagesReorderPinnedDialogs(ctx context.Context, req *tg.MessagesReorderPinnedDialogsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req.FolderID != 0 { + return false, folderIDInvalidErr() + } + if len(req.Order) > maxDialogInputPeers { + return false, limitInvalidErr() + } + peers, err := r.dialogPeersFromInput(ctx, userID, req.Order) + if err != nil { + return false, err + } + if r.deps.Dialogs == nil { + return true, nil + } + if err := r.deps.Dialogs.ReorderPinned(ctx, userID, peers, req.GetForce()); err != nil { + return false, internalErr() + } + date := int(r.clock.Now().Unix()) + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + _, state, err := r.deps.Updates.RecordPinnedDialogs(ctx, authKeyID, userID, peers, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePinnedDialogs{Order: tgDialogPeers(peers)}}, + Date: date, + Seq: 0, + }) + return true, nil +} + +func (r *Router) onMessagesMarkDialogUnread(ctx context.Context, req *tg.MessagesMarkDialogUnreadRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if parentPeer, ok := req.GetParentPeer(); ok { + if err := r.validateDialogUnreadParentPeer(ctx, userID, parentPeer); err != nil { + return false, err + } + peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) + if err != nil { + return false, err + } + if len(peers) != 1 { + return false, peerIDInvalidErr() + } + return true, nil + } + peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) + if err != nil { + return false, err + } + if len(peers) != 1 { + return false, peerIDInvalidErr() + } + unread := req.GetUnread() + if r.deps.Dialogs == nil { + return true, nil + } + changed, err := r.deps.Dialogs.MarkUnread(ctx, userID, peers[0], unread) + if err != nil { + return false, internalErr() + } + if changed { + date := int(r.clock.Now().Unix()) + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + _, state, err := r.deps.Updates.RecordDialogUnreadMark(ctx, authKeyID, userID, peers[0], unread, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateDialogUnreadMark{ + Unread: unread, + Peer: tgDialogPeer(peers[0]), + }}, + Date: date, + Seq: 0, + }) + } + return true, nil +} + +func (r *Router) onMessagesGetDialogUnreadMarks(ctx context.Context, req *tg.MessagesGetDialogUnreadMarksRequest) ([]tg.DialogPeerClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if parentPeer, ok := req.GetParentPeer(); ok { + if err := r.validateDialogUnreadParentPeer(ctx, userID, parentPeer); err != nil { + return nil, err + } + return []tg.DialogPeerClass{}, nil + } + if r.deps.Dialogs == nil { + return nil, nil + } + peers, err := r.deps.Dialogs.UnreadMarks(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgDialogPeers(peers), nil +} + +func (r *Router) validateDialogUnreadParentPeer(ctx context.Context, userID int64, parentPeer tg.InputPeerClass) error { + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, parentPeer) + if err != nil { + return parentPeerInvalidErr() + } + if peer.Type != domain.PeerTypeChannel { + return parentPeerInvalidErr() + } + return nil +} + +func (r *Router) onMessagesHidePeerSettingsBar(ctx context.Context, input tg.InputPeerClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return false, err + } + changed := true + if r.deps.Dialogs != nil { + var err error + changed, err = r.deps.Dialogs.HidePeerSettingsBar(ctx, userID, peer) + if err != nil { + return false, internalErr() + } + } + if !changed { + return true, nil + } + date := int(r.clock.Now().Unix()) + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + _, state, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, domain.PeerSettings{HiddenPeerSettingsBar: true}, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePeerSettings{ + Peer: tgPeer(peer), + Settings: tg.PeerSettings{}, + }}, + Date: date, + Seq: 0, + }) + return true, nil +} + +func (r *Router) metrics() Metrics { + if r.deps.Metrics == nil { + return NopMetrics{} + } + return r.deps.Metrics +} + +func (r *Router) dialogFilterFromRequest(ctx context.Context, userID int64, req *tg.MessagesGetDialogsRequest) (domain.DialogFilter, error) { + limit := req.Limit + if limit > 500 { + limit = 500 + } + filter := domain.DialogFilter{ + ExcludePinned: req.ExcludePinned, + OffsetDate: req.OffsetDate, + OffsetID: req.OffsetID, + Limit: limit, + Hash: req.Hash, + } + if folderID, ok := req.GetFolderID(); ok { + filter.HasFolderID = true + filter.FolderID = folderID + } + if peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.OffsetPeer); err == nil { + filter.HasOffsetPeer = true + filter.OffsetPeer = peer + } else if _, ok := req.OffsetPeer.(*tg.InputPeerEmpty); !ok && req.OffsetPeer != nil { + return domain.DialogFilter{}, err + } + return filter, nil +} + +func (r *Router) dialogPeersFromInput(ctx context.Context, userID int64, items []tg.InputDialogPeerClass) ([]domain.Peer, error) { + if len(items) > maxDialogInputPeers { + return nil, limitInvalidErr() + } + peers := make([]domain.Peer, 0, len(items)) + hasFolder := false + for _, item := range items { + switch p := item.(type) { + case *tg.InputDialogPeer: + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, p.Peer) + if err != nil { + return nil, err + } + peers = append(peers, peer) + case *tg.InputDialogPeerFolder: + if hasFolder { + return nil, folderIDInvalidErr() + } + hasFolder = true + // 第一阶段不维护 archived/folder 会话。若请求同时包含普通 peer, + // 按 Telegram 企业版路径优先返回普通 peer;纯 folder 请求返回空摘要。 + default: + return nil, inputConstructorInvalidErr() + } + } + return peers, nil +} + +func (r *Router) dialogFolderFromTG(ctx context.Context, userID int64, id int, filter tg.DialogFilterClass) (domain.DialogFolder, error) { + if id < domain.DialogCustomFolderMinID { + return domain.DialogFolder{}, filterIDInvalidErr() + } + switch f := filter.(type) { + case *tg.DialogFilter: + title := f.Title.Text + if title == "" { + return domain.DialogFolder{}, filterTitleEmptyErr() + } + if utf8.RuneCountInString(title) > domain.MaxDialogFolderTitleRunes { + return domain.DialogFolder{}, limitInvalidErr() + } + pinned, err := r.dialogFolderPeersFromInput(ctx, userID, f.PinnedPeers) + if err != nil { + return domain.DialogFolder{}, err + } + include, err := r.dialogFolderPeersFromInput(ctx, userID, f.IncludePeers) + if err != nil { + return domain.DialogFolder{}, err + } + exclude, err := r.dialogFolderPeersFromInput(ctx, userID, f.ExcludePeers) + if err != nil { + return domain.DialogFolder{}, err + } + emoticon, hasEmoticon := f.GetEmoticon() + color, hasColor := f.GetColor() + return domain.DialogFolder{ + ID: id, + Contacts: f.Contacts, + NonContacts: f.NonContacts, + Groups: f.Groups, + Broadcasts: f.Broadcasts, + Bots: f.Bots, + ExcludeMuted: f.ExcludeMuted, + ExcludeRead: f.ExcludeRead, + ExcludeArchived: f.ExcludeArchived, + TitleNoanimate: f.TitleNoanimate, + Title: title, + TitleEntities: domainMessageEntities(f.Title.Entities), + Emoticon: emoticon, + HasEmoticon: hasEmoticon, + Color: color, + HasColor: hasColor, + PinnedPeers: pinned, + IncludePeers: include, + ExcludePeers: exclude, + }, nil + case *tg.DialogFilterChatlist: + title := f.Title.Text + if title == "" { + return domain.DialogFolder{}, filterTitleEmptyErr() + } + if utf8.RuneCountInString(title) > domain.MaxDialogFolderTitleRunes { + return domain.DialogFolder{}, limitInvalidErr() + } + pinned, err := r.dialogFolderPeersFromInput(ctx, userID, f.PinnedPeers) + if err != nil { + return domain.DialogFolder{}, err + } + include, err := r.dialogFolderPeersFromInput(ctx, userID, f.IncludePeers) + if err != nil { + return domain.DialogFolder{}, err + } + emoticon, hasEmoticon := f.GetEmoticon() + color, hasColor := f.GetColor() + return domain.DialogFolder{ + ID: id, + TitleNoanimate: f.TitleNoanimate, + Title: title, + TitleEntities: domainMessageEntities(f.Title.Entities), + Emoticon: emoticon, + HasEmoticon: hasEmoticon, + Color: color, + HasColor: hasColor, + PinnedPeers: pinned, + IncludePeers: include, + IsChatlist: true, + }, nil + default: + return domain.DialogFolder{}, inputConstructorInvalidErr() + } +} + +func (r *Router) dialogFolderPeersFromInput(ctx context.Context, userID int64, peers []tg.InputPeerClass) ([]domain.DialogFolderPeer, error) { + if len(peers) > domain.MaxDialogFolderPeers { + return nil, limitInvalidErr() + } + out := make([]domain.DialogFolderPeer, 0, len(peers)) + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, input := range peers { + peer, accessHash, err := r.domainFolderPeerFromInputPeer(ctx, userID, input) + if err != nil { + return nil, err + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + out = append(out, domain.DialogFolderPeer{Peer: peer, AccessHash: accessHash}) + } + return out, nil +} + +func (r *Router) domainFolderPeerFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.Peer, int64, error) { + switch p := peer.(type) { + case *tg.InputPeerUser: + return domain.Peer{Type: domain.PeerTypeUser, ID: p.UserID}, p.AccessHash, nil + case *tg.InputPeerChannel: + out := domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID} + if err := r.validateInputPeerChannelAccess(ctx, userID, peer, p.ChannelID); err != nil { + return domain.Peer{}, 0, err + } + return out, p.AccessHash, nil + case *tg.InputPeerChannelFromMessage: + out := domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID} + if p.ChannelID <= 0 { + return domain.Peer{}, 0, peerIDInvalidErr() + } + return out, 0, nil + case *tg.InputPeerSelf: + if userID == 0 { + return domain.Peer{}, 0, peerIDInvalidErr() + } + var accessHash int64 + if r.deps.Users != nil { + if self, err := r.deps.Users.Self(ctx, userID); err == nil { + accessHash = self.AccessHash + } + } + return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, accessHash, nil + default: + return domain.Peer{}, 0, peerIDInvalidErr() + } +} + +func (r *Router) domainPeerFromInputPeer(userID int64, peer tg.InputPeerClass) (domain.Peer, bool) { + switch p := peer.(type) { + case *tg.InputPeerEmpty, nil: + return domain.Peer{}, false + case *tg.InputPeerUser: + return domain.Peer{Type: domain.PeerTypeUser, ID: p.UserID}, true + case *tg.InputPeerChannel: + return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID}, true + case *tg.InputPeerChannelFromMessage: + return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID}, p.ChannelID > 0 + case *tg.InputPeerChat: + return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChatID}, p.ChatID > 0 + case *tg.InputPeerSelf: + if userID == 0 { + return domain.Peer{}, false + } + return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, true + default: + return domain.Peer{}, false + } +} + +func isLegacyInputPeerChat(peer tg.InputPeerClass) bool { + _, ok := peer.(*tg.InputPeerChat) + return ok +} + +func inputPeerChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) { + switch p := peer.(type) { + case *tg.InputPeerChannel: + return channelInputRef{ + ID: p.ChannelID, + AccessHash: p.AccessHash, + CheckAccessHash: p.AccessHash != 0, + }, p.ChannelID > 0 + case *tg.InputPeerChannelFromMessage: + return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0 + default: + return channelInputRef{}, false + } +} + +func (r *Router) validateInputPeerChannelAccess(ctx context.Context, userID int64, peer tg.InputPeerClass, channelID int64) error { + ref, ok := inputPeerChannelRef(peer) + if !ok || ref.ID != channelID || channelID <= 0 { + return nil + } + if !ref.CheckAccessHash || r.deps.Channels == nil { + return nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) + if err != nil { + return channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return channelInvalidErr(domain.ErrChannelPrivate) + } + return nil +} + +func (r *Router) checkedDomainPeerFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.Peer, error) { + out, ok := r.domainPeerFromInputPeer(userID, peer) + if !ok || out.ID == 0 { + return domain.Peer{}, peerIDInvalidErr() + } + if out.Type == domain.PeerTypeChannel { + if err := r.validateInputPeerChannelAccess(ctx, userID, peer, out.ID); err != nil { + return domain.Peer{}, err + } + } + return out, nil +} + +func cleanDialogFilterOrder(order []int) []int { + out := make([]int, 0, len(order)) + seen := make(map[int]struct{}, len(order)) + for _, id := range order { + if id < domain.DialogCustomFolderMinID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, bool) { + peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) + if !ok { + return domain.MessageFilter{}, false + } + limit := req.Limit + if limit > 50 { + limit = 50 + } + return domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: req.OffsetID, + OffsetDate: req.OffsetDate, + AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), + Limit: limit, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + }, true +} + +func (r *Router) messageFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest) domain.MessageFilter { + limit := req.Limit + if limit > 500 { + limit = 500 + } + filter := domain.MessageFilter{ + Query: req.Q, + OffsetID: req.OffsetID, + AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), + Limit: limit, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + NeedTotalCount: req.OffsetID == 0 && req.MinDate == 0 && req.MaxDate == 0 && req.AddOffset >= 0 && req.Hash == 0, + } + if peer, ok := r.domainPeerFromInputPeer(userID, req.Peer); ok { + filter.HasPeer = true + filter.Peer = peer + } + return filter +} + +func (r *Router) channelHistoryFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest, channelID int64) (domain.ChannelHistoryFilter, bool) { + limit := req.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + filter := domain.ChannelHistoryFilter{ + ChannelID: channelID, + Query: req.Q, + OffsetID: req.OffsetID, + AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), + Limit: limit, + MinDate: req.MinDate, + MaxDate: req.MaxDate, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + } + if req.FromID != nil { + from, ok := r.domainPeerFromInputPeer(userID, req.FromID) + if !ok || from.Type != domain.PeerTypeUser || from.ID == 0 { + return domain.ChannelHistoryFilter{}, false + } + filter.SenderUserID = from.ID + } + return filter, true +} + +func searchFilterNeedsMediaStore(filter tg.MessagesFilterClass) bool { + switch filter.(type) { + case nil, *tg.InputMessagesFilterEmpty: + return false + case *tg.InputMessagesFilterPhotos, + *tg.InputMessagesFilterVideo, + *tg.InputMessagesFilterPhotoVideo, + *tg.InputMessagesFilterDocument, + *tg.InputMessagesFilterURL, + *tg.InputMessagesFilterGif, + *tg.InputMessagesFilterVoice, + *tg.InputMessagesFilterRoundVoice, + *tg.InputMessagesFilterRoundVideo, + *tg.InputMessagesFilterMusic, + *tg.InputMessagesFilterPoll: + return true + default: + return false + } +} + +func (r *Router) peerSettingsUsers(ctx context.Context, userID int64, peer tg.InputPeerClass) []tg.UserClass { + if r.deps.Users == nil { + return nil + } + switch p := peer.(type) { + case *tg.InputPeerSelf: + u, err := r.deps.Users.Self(ctx, userID) + if err == nil && u.ID != 0 { + return []tg.UserClass{r.tgSelfUser(u)} + } + case *tg.InputPeerUser: + u, found, err := r.deps.Users.ByID(ctx, userID, p.UserID) + if err == nil && found { + return []tg.UserClass{r.tgUser(u)} + } + } + return nil +} + +func (r *Router) affectedMessages(ctx context.Context, authKeyID [8]byte, userID int64) (*tg.MessagesAffectedMessages, error) { + st := domain.UpdateState{Date: int(r.clock.Now().Unix())} + if r.deps.Updates != nil { + var err error + st, err = r.deps.Updates.GetState(ctx, authKeyID, userID) + if err != nil { + return nil, internalErr() + } + } + return &tg.MessagesAffectedMessages{Pts: st.Pts, PtsCount: 0}, nil +} + +func (r *Router) mentionedUserIDsFromMessage(ctx context.Context, currentUserID int64, message string, entities []tg.MessageEntityClass) ([]int64, error) { + if r.deps.Users == nil { + return nil, nil + } + identity, _ := r.deps.Users.(UserIdentityService) + seen := make(map[int64]struct{}) + out := make([]int64, 0) + add := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, entity := range entities { + input, ok := entity.(*tg.InputMessageEntityMentionName) + if !ok || input.UserID == nil { + continue + } + user, found, err := r.userFromInput(ctx, currentUserID, input.UserID) + if err != nil { + return nil, internalErr() + } + if found { + add(user.ID) + } + if len(out) >= domain.MaxChannelMentionRecipients { + return out, nil + } + } + if identity != nil { + for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) { + user, found, err := identity.ResolveUsername(ctx, currentUserID, username) + if err != nil { + return nil, internalErr() + } + if found { + add(user.ID) + } + if len(out) >= domain.MaxChannelMentionRecipients { + return out, nil + } + } + } + return out, nil +} + +func extractMentionUsernames(message string, limit int) []string { + if limit <= 0 || message == "" { + return nil + } + seen := make(map[string]struct{}) + out := make([]string, 0) + for i := 0; i < len(message); i++ { + if message[i] != '@' { + continue + } + if i > 0 && isUsernameByte(message[i-1]) { + continue + } + j := i + 1 + for j < len(message) && isUsernameByte(message[j]) { + j++ + } + if j == i+1 { + continue + } + username := strings.ToLower(message[i+1 : j]) + if _, ok := seen[username]; ok { + continue + } + seen[username] = struct{}{} + out = append(out, username) + if len(out) == limit { + return out + } + i = j - 1 + } + return out +} + +func isUsernameByte(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' +} + +func (r *Router) affectedHistory(ctx context.Context, authKeyID [8]byte, userID int64, offset int) (*tg.MessagesAffectedHistory, error) { + st := domain.UpdateState{Date: int(r.clock.Now().Unix())} + if r.deps.Updates != nil { + var err error + st, err = r.deps.Updates.GetState(ctx, authKeyID, userID) + if err != nil { + return nil, internalErr() + } + } + return &tg.MessagesAffectedHistory{Pts: st.Pts, PtsCount: 0, Offset: offset}, nil +} + +func (r *Router) chatsForInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) []tg.ChatClass { + p, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil || p.Type != domain.PeerTypeChannel || p.ID == 0 || r.deps.Channels == nil { + return []tg.ChatClass{} + } + view, err := r.deps.Channels.GetChannel(ctx, userID, p.ID) + if err != nil || view.Channel.ID == 0 { + return []tg.ChatClass{} + } + return []tg.ChatClass{tgChannelChat(userID, view.Channel, &view.Self)} +} + +func (r *Router) forumTopicPeerView(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.ChannelView, error) { + p, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return domain.ChannelView{}, err + } + if p.Type != domain.PeerTypeChannel || p.ID == 0 { + return domain.ChannelView{}, peerIDInvalidErr() + } + if r.deps.Channels == nil { + return domain.ChannelView{}, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, p.ID) + if err != nil { + return domain.ChannelView{}, channelInvalidErr(err) + } + return view, nil +} + +func (r *Router) forumTopicsResponse(ctx context.Context, userID int64, view domain.ChannelView, list domain.ChannelForumTopicList, includeGeneral bool) *tg.MessagesForumTopics { + if list.Channel.ID != 0 { + view.Channel = list.Channel + } + if list.Dialog.ChannelID != 0 { + view.Dialog = list.Dialog + } + channels := []domain.Channel{view.Channel} + messages := []tg.MessageClass{} + messageIDs := map[int]struct{}{} + topics := []tg.ForumTopicClass{} + userIDs := make([]int64, 0, len(list.Topics)+len(list.Messages)+1) + count := list.Count + if includeGeneral { + count++ + topics = append(topics, tgForumGeneralTopic(userID, view)) + userIDs = append(userIDs, view.Channel.CreatorUserID) + if view.Channel.TopMessageID > 0 && r.deps.Channels != nil { + if history, err := r.deps.Channels.GetMessages(ctx, userID, view.Channel.ID, []int{view.Channel.TopMessageID}); err == nil { + for _, msg := range history.Messages { + if _, ok := messageIDs[msg.ID]; ok { + continue + } + messageIDs[msg.ID] = struct{}{} + if item := tgChannelMessage(userID, msg); item != nil { + messages = append(messages, item) + } + userIDs = append(userIDs, msg.SenderUserID) + } + channels = append(channels, history.Channels...) + for _, u := range history.Users { + userIDs = append(userIDs, u.ID) + } + } + } + } + for _, topic := range list.Topics { + topics = append(topics, tgForumTopicFromDomain(userID, topic)) + userIDs = append(userIDs, topic.CreatorUserID) + } + for _, msg := range list.Messages { + if _, ok := messageIDs[msg.ID]; ok { + continue + } + messageIDs[msg.ID] = struct{}{} + if item := tgChannelMessage(userID, msg); item != nil { + messages = append(messages, item) + } + userIDs = append(userIDs, msg.SenderUserID) + if msg.SendAs != nil && msg.SendAs.Type == domain.PeerTypeUser { + userIDs = append(userIDs, msg.SendAs.ID) + } + } + for _, u := range list.Users { + userIDs = append(userIDs, u.ID) + } + return &tg.MessagesForumTopics{ + Count: count, + Topics: topics, + Messages: messages, + Chats: tgChannels(userID, channels), + Users: r.tgUsersForIDs(ctx, userID, userIDs), + Pts: view.Channel.Pts, + } +} + +func tgForumGeneralTopic(viewerUserID int64, view domain.ChannelView) *tg.ForumTopic { + return &tg.ForumTopic{ + My: view.Channel.CreatorUserID == viewerUserID && viewerUserID != 0, + ID: forumGeneralTopicID, + Date: view.Channel.Date, + Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}, + Title: "General", + IconColor: forumGeneralIconColor, + TopMessage: view.Channel.TopMessageID, + ReadInboxMaxID: view.Dialog.ReadInboxMaxID, + ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID, + UnreadCount: view.Dialog.UnreadCount, + UnreadMentionsCount: view.Dialog.UnreadMentions, + FromID: &tg.PeerUser{UserID: view.Channel.CreatorUserID}, + NotifySettings: *tdesktop.NotifySettings(), + } +} + +func tgForumTopicFromDomain(viewerUserID int64, topic domain.ChannelForumTopic) *tg.ForumTopic { + iconColor := topic.IconColor + if iconColor == 0 { + iconColor = domain.DefaultForumTopicIconColor + } + return &tg.ForumTopic{ + My: topic.CreatorUserID == viewerUserID && viewerUserID != 0, + Closed: topic.Closed, + Pinned: topic.Pinned, + Hidden: topic.Hidden, + TitleMissing: topic.TitleMissing, + ID: topic.TopicID, + Date: topic.Date, + Peer: &tg.PeerChannel{ChannelID: topic.ChannelID}, + Title: topic.Title, + IconColor: iconColor, + IconEmojiID: topic.IconEmojiID, + TopMessage: topic.TopMessageID, + ReadInboxMaxID: topic.ReadInboxMaxID, + ReadOutboxMaxID: topic.ReadOutboxMaxID, + UnreadCount: topic.UnreadCount, + UnreadMentionsCount: topic.UnreadMentionsCount, + UnreadReactionsCount: topic.UnreadReactionsCount, + UnreadPollVotesCount: topic.UnreadPollVotesCount, + FromID: &tg.PeerUser{UserID: topic.CreatorUserID}, + NotifySettings: *tdesktop.NotifySettings(), + } +} + +func (r *Router) pinnedForumTopicUpdates(viewerUserID int64, channel domain.Channel, topicID int, pinned bool) *tg.Updates { + update := &tg.UpdatePinnedForumTopic{ + Peer: &tg.PeerChannel{ChannelID: channel.ID}, + TopicID: topicID, + } + update.SetPinned(pinned) + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) pinnedForumTopicsOrderUpdates(viewerUserID int64, channel domain.Channel, order []int) *tg.Updates { + update := &tg.UpdatePinnedForumTopics{ + Peer: &tg.PeerChannel{ChannelID: channel.ID}, + Order: append([]int(nil), order...), + } + if order != nil { + update.SetOrder(append([]int(nil), order...)) + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func forumTopicQueryMatchesGeneral(query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + return query == "" || strings.Contains("general", query) +} + +func (r *Router) pushReadHistoryEvent(ctx context.Context, userID int64, event domain.UpdateEvent) { + if r.hasReliableUpdateDispatch() { + return + } + if r.deps.Sessions == nil || userID == 0 { + return + } + var update tg.UpdateClass + switch event.Type { + case domain.UpdateEventReadHistoryInbox: + update = tgReadHistoryInboxUpdate(event) + case domain.UpdateEventReadHistoryOutbox: + update = tgReadHistoryOutboxUpdate(event) + } + if update == nil { + return + } + updates := &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: event.Date, + Seq: 0, + } + r.pushUserMessage(ctx, userID, "push read history", updates) +} + +func tgReadHistoryInbox(event domain.UpdateEvent) *tg.UpdateReadHistoryInbox { + peer := tgPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdateReadHistoryInbox{ + Peer: peer, + MaxID: event.MaxID, + StillUnreadCount: event.StillUnreadCount, + Pts: event.Pts, + PtsCount: event.PtsCount, + } +} + +func tgReadHistoryInboxUpdate(event domain.UpdateEvent) tg.UpdateClass { + if event.Peer.Type == domain.PeerTypeChannel && event.Peer.ID != 0 { + return &tg.UpdateReadChannelInbox{ + ChannelID: event.Peer.ID, + MaxID: event.MaxID, + StillUnreadCount: event.StillUnreadCount, + Pts: event.Pts, + } + } + return tgReadHistoryInbox(event) +} + +func tgReadHistoryOutbox(event domain.UpdateEvent) *tg.UpdateReadHistoryOutbox { + peer := tgPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdateReadHistoryOutbox{ + Peer: peer, + MaxID: event.MaxID, + Pts: event.Pts, + PtsCount: event.PtsCount, + } +} + +func tgReadHistoryOutboxUpdate(event domain.UpdateEvent) tg.UpdateClass { + if event.Peer.Type == domain.PeerTypeChannel && event.Peer.ID != 0 { + return &tg.UpdateReadChannelOutbox{ + ChannelID: event.Peer.ID, + MaxID: event.MaxID, + } + } + return tgReadHistoryOutbox(event) +} + +func messagesNotModifiedOrEmpty(hash int64) tg.MessagesMessagesClass { + if hash != 0 { + return &tg.MessagesMessagesNotModified{Count: 0} + } + return &tg.MessagesMessages{} +} + +func messagesAllStickersEmpty(hash int64) tg.MessagesAllStickersClass { + if hash != 0 { + return &tg.MessagesAllStickersNotModified{} + } + return &tg.MessagesAllStickers{Sets: []tg.StickerSet{}} +} + +func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass { + if hash != 0 { + return &tg.MessagesFeaturedStickersNotModified{Count: 0} + } + return &tg.MessagesFeaturedStickers{ + Count: 0, + Sets: []tg.StickerSetCoveredClass{}, + Unread: []int64{}, + } +} + +func tgEditMessageUpdates(event domain.UpdateEvent, msg domain.Message, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { + update := tgOtherUpdateFromEvent(domain.UpdateEvent{ + Type: domain.UpdateEventEditMessage, + Pts: event.Pts, + PtsCount: event.PtsCount, + Message: msg, + }) + if update == nil { + return &tg.Updates{Date: event.Date} + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: users, + Chats: chats, + Date: event.Date, + Seq: 0, + } +} + +func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, randomID int64, includeMessageID bool, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { + updates := make([]tg.UpdateClass, 0, 2) + if includeMessageID { + updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: randomID}) + } + item := tgMessage(msg) + if item == nil { + item = &tg.MessageEmpty{ID: msg.ID} + } + updates = append(updates, &tg.UpdateNewMessage{ + Message: item, + Pts: event.Pts, + PtsCount: event.PtsCount, + }) + date := event.Date + if date == 0 { + date = msg.Date + } + return &tg.Updates{ + Updates: updates, + Users: users, + Chats: chats, + Date: date, + Seq: 0, // 私聊不维护账号级 seq,恒 0(客户端仅靠 pts 同步) + } +} + +func tgForwardMessagesUpdates(res domain.ForwardPrivateMessagesResult, randomIDs []int64, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { + updates := make([]tg.UpdateClass, 0, len(res.SenderMessages)*2) + date := 0 + for i, msg := range res.SenderMessages { + randomID := int64(0) + if i < len(randomIDs) { + randomID = randomIDs[i] + } + updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: randomID}) + event := domain.UpdateEvent{} + if i < len(res.SenderEvents) { + event = res.SenderEvents[i] + } + item := tgMessage(msg) + if item == nil { + item = &tg.MessageEmpty{ID: msg.ID} + } + pts := event.Pts + if pts == 0 { + pts = msg.Pts + } + ptsCount := event.PtsCount + if ptsCount == 0 { + ptsCount = 1 + } + updates = append(updates, &tg.UpdateNewMessage{ + Message: item, + Pts: pts, + PtsCount: ptsCount, + }) + if date == 0 { + date = event.Date + } + if date == 0 { + date = msg.Date + } + } + return &tg.Updates{ + Updates: updates, + Users: users, + Chats: chats, + Date: date, + Seq: 0, + } +} + +func (r *Router) usersForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.UserClass { + seen := make(map[int64]struct{}, 2) + users := make([]tg.UserClass, 0, 2) + add := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + switch { + case id == domain.OfficialSystemUserID: + users = append(users, r.tgUser(domain.OfficialSystemUser())) + case id == ownerUserID: + if r.deps.Users == nil { + return + } + u, err := r.deps.Users.Self(ctx, ownerUserID) + if err == nil && u.ID != 0 { + users = append(users, r.tgSelfUser(u)) + } + default: + if r.deps.Users == nil { + return + } + u, found, err := r.deps.Users.ByID(ctx, ownerUserID, id) + if err == nil && found { + users = append(users, r.tgUser(u)) + } + } + } + if msg.From.Type == domain.PeerTypeUser { + add(msg.From.ID) + } + if msg.Peer.Type == domain.PeerTypeUser { + add(msg.Peer.ID) + } + if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser { + add(msg.Forward.From.ID) + } + if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser { + add(msg.ReplyTo.Peer.ID) + } + return users +} + +func (r *Router) usersForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.UserClass { + seen := make(map[int64]struct{}, len(messages)*2) + ids := make([]int64, 0, len(messages)*2) + addID := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + ids = append(ids, id) + } + for _, msg := range messages { + if msg.From.Type == domain.PeerTypeUser { + addID(msg.From.ID) + } + if msg.Peer.Type == domain.PeerTypeUser { + addID(msg.Peer.ID) + } + if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser { + addID(msg.Forward.From.ID) + } + if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser { + addID(msg.ReplyTo.Peer.ID) + } + } + if len(ids) == 0 { + return nil + } + loaded := make(map[int64]domain.User, len(ids)) + if r.deps.Users != nil { + if users, err := r.deps.Users.ByIDs(ctx, ownerUserID, ids); err == nil { + for _, user := range users { + loaded[user.ID] = user + } + } + } + users := make([]tg.UserClass, 0, len(ids)) + for _, id := range ids { + switch { + case id == domain.OfficialSystemUserID: + users = append(users, r.tgUser(domain.OfficialSystemUser())) + case id == ownerUserID: + if user, ok := loaded[id]; ok { + users = append(users, r.tgSelfUser(user)) + } + default: + if user, ok := loaded[id]; ok { + users = append(users, r.tgUser(user)) + } + } + } + return users +} + +func (r *Router) chatsForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.ChatClass { + if r.deps.Channels == nil { + return nil + } + seen := make(map[int64]struct{}, 2) + chats := make([]tg.ChatClass, 0, 2) + add := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + view, err := r.deps.Channels.GetChannel(ctx, ownerUserID, id) + if err != nil || view.Channel.ID == 0 { + return + } + chats = append(chats, tgChannelChat(ownerUserID, view.Channel, &view.Self)) + } + if msg.From.Type == domain.PeerTypeChannel { + add(msg.From.ID) + } + if msg.Peer.Type == domain.PeerTypeChannel { + add(msg.Peer.ID) + } + if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeChannel { + add(msg.Forward.From.ID) + } + if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeChannel { + add(msg.ReplyTo.Peer.ID) + } + return chats +} + +func (r *Router) chatsForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.ChatClass { + seen := make(map[int64]struct{}, len(messages)) + chats := make([]tg.ChatClass, 0, len(messages)) + for _, msg := range messages { + for _, chat := range r.chatsForMessageUpdate(ctx, ownerUserID, msg) { + channel, ok := chat.(*tg.Channel) + if !ok || channel.ID == 0 { + continue + } + if _, ok := seen[channel.ID]; ok { + continue + } + seen[channel.ID] = struct{}{} + chats = append(chats, chat) + } + } + return chats +} diff --git a/internal/rpc/metrics.go b/internal/rpc/metrics.go new file mode 100644 index 00000000..ea74f44e --- /dev/null +++ b/internal/rpc/metrics.go @@ -0,0 +1,25 @@ +package rpc + +import "time" + +// Metrics 接收 RPC 业务层指标。默认 NopMetrics,后续可对接 Prometheus。 +type Metrics interface { + MessageSend(d time.Duration, duplicate bool, err error) + MessageRateLimited(retryAfterSeconds int) + OutboxClaimed(count int) + OutboxDelivered(d time.Duration) + OutboxFailed(err error) +} + +// NopMetrics 是 Metrics 的空实现。 +type NopMetrics struct{} + +func (NopMetrics) MessageSend(time.Duration, bool, error) {} + +func (NopMetrics) MessageRateLimited(int) {} + +func (NopMetrics) OutboxClaimed(int) {} + +func (NopMetrics) OutboxDelivered(time.Duration) {} + +func (NopMetrics) OutboxFailed(error) {} diff --git a/internal/rpc/outbox_dispatcher.go b/internal/rpc/outbox_dispatcher.go new file mode 100644 index 00000000..ee7a9b83 --- /dev/null +++ b/internal/rpc/outbox_dispatcher.go @@ -0,0 +1,369 @@ +package rpc + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const ( + defaultOutboxBatch = 100 + defaultOutboxInterval = 200 * time.Millisecond + defaultOutboxWorkers = 2 +) + +var errMissingOutboxEvent = errors.New("missing outbox update event") + +// OutboxDispatcher 把 PG transactional outbox 中的 update 批量推给在线 session。 +// 多 worker 并发 claim:ClaimPending 用 FOR UPDATE SKIP LOCKED,worker 间认领不重叠。 +type OutboxDispatcher struct { + events store.UpdateEventStore + outbox store.DispatchOutboxStore + sessions SessionBinder + log *zap.Logger + metrics Metrics + batch int + interval time.Duration + workers int + pushTimeout time.Duration +} + +// OutboxOption 调整 OutboxDispatcher 的运行参数。 +type OutboxOption func(*OutboxDispatcher) + +// WithOutboxBatch 设置每次 claim 的最大条数;<=0 时保持默认。 +func WithOutboxBatch(n int) OutboxOption { + return func(d *OutboxDispatcher) { + if n > 0 { + d.batch = n + } + } +} + +// WithOutboxInterval 设置两次 claim 之间的轮询间隔;<=0 时保持默认。 +func WithOutboxInterval(interval time.Duration) OutboxOption { + return func(d *OutboxDispatcher) { + if interval > 0 { + d.interval = interval + } + } +} + +// WithOutboxWorkers 设置并发 claim worker 数;<=0 时保持默认。 +func WithOutboxWorkers(n int) OutboxOption { + return func(d *OutboxDispatcher) { + if n > 0 { + d.workers = n + } + } +} + +// WithOutboxPushTimeout 设置 updates fanout 入队等待时间;<=0 时使用同步可靠推送。 +func WithOutboxPushTimeout(timeout time.Duration) OutboxOption { + return func(d *OutboxDispatcher) { + if timeout > 0 { + d.pushTimeout = timeout + } + } +} + +// WithOutboxMetrics 注入指标实现;nil 时保持 NopMetrics。 +func WithOutboxMetrics(m Metrics) OutboxOption { + return func(d *OutboxDispatcher) { + if m != nil { + d.metrics = m + } + } +} + +// NewOutboxDispatcher 创建在线 update 推送 worker。batch/interval 默认值见 +// defaultOutboxBatch/defaultOutboxInterval,可经 WithOutbox* 选项覆盖(生产由 config 注入)。 +func NewOutboxDispatcher(events store.UpdateEventStore, outbox store.DispatchOutboxStore, sessions SessionBinder, log *zap.Logger, opts ...OutboxOption) *OutboxDispatcher { + if log == nil { + log = zap.NewNop() + } + d := &OutboxDispatcher{ + events: events, + outbox: outbox, + sessions: sessions, + log: log, + metrics: NopMetrics{}, + batch: defaultOutboxBatch, + interval: defaultOutboxInterval, + workers: defaultOutboxWorkers, + } + for _, opt := range opts { + if opt != nil { + opt(d) + } + } + return d +} + +// Run 启动 workers 个并发 worker 持续 claim pending outbox;ctx 退出时全部停止并等待退出。 +func (d *OutboxDispatcher) Run(ctx context.Context) { + if d == nil || d.events == nil || d.outbox == nil || d.sessions == nil { + return + } + workers := d.workers + if workers < 1 { + workers = 1 + } + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + d.runWorker(ctx) + }() + } + wg.Wait() +} + +// runWorker 是单个 claim 循环;多 worker 靠 ClaimPending 的 SKIP LOCKED 互不重叠。 +func (d *OutboxDispatcher) runWorker(ctx context.Context) { + ticker := time.NewTicker(d.interval) + defer ticker.Stop() + for { + d.DispatchOnce(ctx) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// batchEventLoader 是 UpdateEventStore 的可选批量能力:一次取多条 (user,pts) 事件。 +type batchEventLoader interface { + BatchByCursor(ctx context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) +} + +// batchOutboxMarker 是 DispatchOutboxStore 的可选批量能力:一次标记多行 delivered。 +type batchOutboxMarker interface { + MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error +} + +// DispatchOnce claim 一批 outbox 并投递,测试可直接调用。 +// store 同时具备批量取事件 + 批量标记能力时走批量路径(每批 ~3 次 PG 往返),否则逐条回退。 +func (d *OutboxDispatcher) DispatchOnce(ctx context.Context) { + items, err := d.outbox.ClaimPending(ctx, d.batch) + if err != nil { + d.log.Warn("claim dispatch outbox", zap.Error(err)) + return + } + if len(items) == 0 { + return + } + d.metrics.OutboxClaimed(len(items)) + if loader, ok := d.events.(batchEventLoader); ok { + if marker, ok := d.outbox.(batchOutboxMarker); ok { + d.dispatchBatch(ctx, items, loader, marker) + return + } + } + for _, item := range items { + d.dispatchItem(ctx, item) + } +} + +type outboxEventKey struct { + userID int64 + pts int +} + +// dispatchBatch 批量加载已 claim 事件、逐条 push、批量标记 delivered;失败项单独退避重试。 +func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.DispatchOutboxItem, loader batchEventLoader, marker batchOutboxMarker) { + cursors := make([]store.EventCursor, len(items)) + for i, item := range items { + cursors[i] = store.EventCursor{UserID: item.TargetUserID, Pts: item.Pts} + } + events, err := loader.BatchByCursor(ctx, cursors) + if err != nil { + // 批量取失败则整批回退逐条路径,让每条各自重试/标失败,不丢进度。 + d.log.Warn("batch load dispatch events", zap.Error(err)) + for _, item := range items { + d.dispatchItem(ctx, item) + } + return + } + byKey := make(map[outboxEventKey]domain.UpdateEvent, len(events)) + for _, event := range events { + byKey[outboxEventKey{event.UserID, event.Pts}] = event + } + start := time.Now() + delivered := make([]store.DispatchOutboxItem, 0, len(items)) + for _, item := range items { + event, ok := byKey[outboxEventKey{item.TargetUserID, item.Pts}] + if !ok { + d.markDispatchFailed(ctx, item, errMissingOutboxEvent) + continue + } + update := tgUpdateForOutboxEvent(event) + if update == nil { + delivered = append(delivered, item) + continue + } + if _, retriable, err := d.pushOutboxUpdate(ctx, item, update); err != nil { + if retriable { + // 出站队列拥塞:留 dispatching 行靠租约过期重投,不计入 attempts 升级。 + // 不加入 delivered,故不会被 MarkDeliveredBatch 删除。 + continue + } + d.markDispatchFailed(ctx, item, err) + continue + } + delivered = append(delivered, item) + } + if len(delivered) == 0 { + return + } + if err := marker.MarkDeliveredBatch(ctx, delivered); err != nil { + // 批量标记失败则逐条标记,避免整批已投递却卡在 dispatching 等租约过期重投。 + d.log.Warn("mark dispatch delivered batch", zap.Error(err)) + for _, item := range delivered { + if markErr := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); markErr != nil { + d.log.Warn("mark dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(markErr)) + } + } + } + per := time.Since(start) / time.Duration(len(delivered)) + for range delivered { + d.metrics.OutboxDelivered(per) + } +} + +func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.DispatchOutboxItem) { + start := time.Now() + events, err := d.events.ListAfter(ctx, item.TargetUserID, item.Pts-1, 1) + if err != nil { + d.markDispatchFailed(ctx, item, err) + return + } + if len(events) == 0 || events[0].Pts != item.Pts { + d.markDispatchFailed(ctx, item, errMissingOutboxEvent) + return + } + update := tgUpdateForOutboxEvent(events[0]) + if update == nil { + if err := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); err != nil { + d.log.Warn("mark noop dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(err)) + return + } + d.metrics.OutboxDelivered(time.Since(start)) + return + } + sent, retriable, err := d.pushOutboxUpdate(ctx, item, update) + if err != nil { + if retriable { + // 出站队列拥塞:保留 dispatching 行,靠租约过期(defaultDispatchLease)重新 claim 重投, + // 不计入 attempts 升级,避免正常满 fan-out 负载把可靠 update 误打成 failed。 + d.log.Debug("dispatch outbox deferred (push queue full)", + zap.Int64("target_user_id", item.TargetUserID), + zap.Int64("outbox_id", item.ID), + zap.Int("pts", item.Pts), + ) + return + } + d.markDispatchFailed(ctx, item, err) + return + } + if err := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); err != nil { + d.log.Warn("mark dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(err)) + return + } + d.metrics.OutboxDelivered(time.Since(start)) + d.log.Debug("dispatch outbox delivered", + zap.Int64("target_user_id", item.TargetUserID), + zap.Int64("outbox_id", item.ID), + zap.Int("pts", item.Pts), + zap.Int("sessions", sent), + ) +} + +// pushOutboxUpdate 投递一条 outbox update,返回 (送达的在线 session 数, 是否可重试, err)。 +// best-effort 路径(pushTimeout>0)的失败只可能是出站队列拥塞(慢消费者入队超时),属暂时性、 +// 可重试:调用方应保留 dispatching 行靠租约过期重投,而非计入 attempts 升级为 failed。 +// 可靠路径的失败是真实投递错误,retriable=false,按原逻辑退避升级。 +func (d *OutboxDispatcher) pushOutboxUpdate(ctx context.Context, item store.DispatchOutboxItem, update *tg.Updates) (sent int, retriable bool, err error) { + var zeroAuthKeyID [8]byte + if d.pushTimeout > 0 { + if scoped, ok := d.sessions.(ScopedBestEffortSessionBinder); ok && item.ExcludeAuthKeyID != zeroAuthKeyID { + sent, err = scoped.PushToUserExceptAuthKeySessionBestEffort(ctx, item.TargetUserID, item.ExcludeAuthKeyID, item.ExcludeSessionID, proto.MessageFromServer, update, d.pushTimeout) + return sent, err != nil, err + } + if bestEffort, ok := d.sessions.(BestEffortSessionBinder); ok { + sent, err = bestEffort.PushToUserExceptSessionBestEffort(ctx, item.TargetUserID, item.ExcludeSessionID, proto.MessageFromServer, update, d.pushTimeout) + return sent, err != nil, err + } + } + if scoped, ok := d.sessions.(ScopedSessionBinder); ok && item.ExcludeAuthKeyID != zeroAuthKeyID { + sent, err = scoped.PushToUserExceptAuthKeySession(ctx, item.TargetUserID, item.ExcludeAuthKeyID, item.ExcludeSessionID, proto.MessageFromServer, update) + return sent, false, err + } + sent, err = d.sessions.PushToUserExceptSession(ctx, item.TargetUserID, item.ExcludeSessionID, proto.MessageFromServer, update) + return sent, false, err +} + +func (d *OutboxDispatcher) markDispatchFailed(ctx context.Context, item store.DispatchOutboxItem, err error) { + if err == nil { + err = errMissingOutboxEvent + } + if markErr := d.outbox.MarkFailed(ctx, item.TargetUserID, item.ID, err.Error()); markErr != nil { + d.log.Warn("mark dispatch failed", + zap.Int64("target_user_id", item.TargetUserID), + zap.Int64("outbox_id", item.ID), + zap.Error(markErr), + ) + return + } + d.metrics.OutboxFailed(err) + d.log.Debug("dispatch outbox failed", + zap.Int64("target_user_id", item.TargetUserID), + zap.Int64("outbox_id", item.ID), + zap.Int("pts", item.Pts), + zap.Error(err), + ) +} + +func tgUpdateForOutboxEvent(event domain.UpdateEvent) *tg.Updates { + switch event.Type { + case domain.UpdateEventNewMessage: + return tgPrivateMessageUpdates(event, event.Message, 0, false, tgUsers(event.Users), tgChannels(event.UserID, event.Channels)) + case domain.UpdateEventReadHistoryInbox, domain.UpdateEventReadHistoryOutbox: + var update tg.UpdateClass + if event.Type == domain.UpdateEventReadHistoryOutbox { + update = tgReadHistoryOutboxUpdate(event) + } else { + update = tgReadHistoryInboxUpdate(event) + } + if update == nil { + return nil + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: event.Date, + Seq: 0, // 私聊不维护账号级 seq,恒 0 + } + case domain.UpdateEventNoop: + return nil + default: + update := tgOtherUpdateFromEvent(event) + if update == nil { + return nil + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: event.Date, + Seq: 0, + } + } +} diff --git a/internal/rpc/outbox_dispatcher_test.go b/internal/rpc/outbox_dispatcher_test.go new file mode 100644 index 00000000..74c0d2d5 --- /dev/null +++ b/internal/rpc/outbox_dispatcher_test.go @@ -0,0 +1,458 @@ +package rpc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestOutboxDispatcherPushesNewMessageAndMarksDelivered(t *testing.T) { + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300, + Body: "hello", + Pts: 7, + } + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 55, + TargetUserID: msg.OwnerUserID, + Pts: msg.Pts, + EventType: domain.UpdateEventNewMessage, + ExcludeSessionID: 99, + }}} + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ + ID: msg.From.ID, + FirstName: "Sender", + }}, + }}} + sessions := &captureSessions{} + metrics := &captureOutboxMetrics{} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) + dispatcher.DispatchOnce(context.Background()) + + if !outbox.delivered || outbox.deliveredUserID != msg.OwnerUserID || outbox.deliveredID != 55 { + t.Fatalf("delivered = %v user=%d id=%d, want outbox delivered", outbox.delivered, outbox.deliveredUserID, outbox.deliveredID) + } + if sessions.userID != msg.OwnerUserID || sessions.sessionID != 99 || sessions.messageType != proto.MessageFromServer { + t.Fatalf("push target = user %d exclude %d type %v, want outbox target/exclude", sessions.userID, sessions.sessionID, sessions.messageType) + } + updates, ok := sessions.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", sessions.message) + } + if len(updates.Updates) != 1 || len(updates.Users) != 1 { + t.Fatalf("updates = %+v, want one update and sender user", updates) + } + update, ok := updates.Updates[0].(*tg.UpdateNewMessage) + if !ok || update.Pts != msg.Pts { + t.Fatalf("update = %#v, want UpdateNewMessage pts=%d", updates.Updates[0], msg.Pts) + } + if metrics.claimed != 1 || metrics.delivered != 1 || metrics.failed != 0 { + t.Fatalf("metrics = claimed %d delivered %d failed %d, want 1/1/0", metrics.claimed, metrics.delivered, metrics.failed) + } +} + +func TestOutboxDispatcherUsesScopedAuthKeyExclusion(t *testing.T) { + var excludeAuthKeyID [8]byte + excludeAuthKeyID[0] = 7 + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 57, + TargetUserID: 1000000002, + Pts: 9, + EventType: domain.UpdateEventPeerSettings, + ExcludeAuthKeyID: excludeAuthKeyID, + ExcludeSessionID: 99, + }}} + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: 1000000002, + Type: domain.UpdateEventPeerSettings, + Pts: 9, + PtsCount: 1, + Date: 1700000302, + Peer: peer, + }}} + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t)) + dispatcher.DispatchOnce(context.Background()) + + if sessions.scopedAuthKeyID != excludeAuthKeyID || sessions.sessionID != 99 || sessions.userID != 1000000002 { + t.Fatalf("scoped push = auth %x session %d user %d, want precise outbox exclusion", sessions.scopedAuthKeyID, sessions.sessionID, sessions.userID) + } +} + +// TestOutboxDispatcherBatchPath 覆盖生产批量路径:store 同时具备 BatchByCursor + MarkDeliveredBatch +// 时,DispatchOnce 一次批量取事件、推送、再批量标记 delivered,而非逐条。 +func TestOutboxDispatcherBatchPath(t *testing.T) { + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300, + Body: "hello", + Pts: 7, + } + events := &batchEventStore{captureUpdateEventStore: &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, + }}}} + outbox := &batchDispatchOutbox{captureDispatchOutbox: &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 55, + TargetUserID: msg.OwnerUserID, + Pts: msg.Pts, + EventType: domain.UpdateEventNewMessage, + ExcludeSessionID: 99, + }}}} + sessions := &captureSessions{} + metrics := &captureOutboxMetrics{} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) + dispatcher.DispatchOnce(context.Background()) + + if len(events.batchCursors) != 1 || events.batchCursors[0] != (store.EventCursor{UserID: msg.OwnerUserID, Pts: msg.Pts}) { + t.Fatalf("batch cursors = %+v, want one cursor for (%d,%d)", events.batchCursors, msg.OwnerUserID, msg.Pts) + } + if sessions.userID != msg.OwnerUserID || sessions.sessionID != 99 { + t.Fatalf("push target = user %d exclude %d, want batch push to outbox target", sessions.userID, sessions.sessionID) + } + if len(outbox.deliveredBatch) != 1 || outbox.deliveredBatch[0].ID != 55 { + t.Fatalf("delivered batch = %+v, want one item id=55", outbox.deliveredBatch) + } + if outbox.delivered { + t.Fatalf("batch path should not call per-item MarkDelivered") + } + if metrics.claimed != 1 || metrics.delivered != 1 || metrics.failed != 0 { + t.Fatalf("metrics = claimed %d delivered %d failed %d, want 1/1/0", metrics.claimed, metrics.delivered, metrics.failed) + } +} + +func TestOutboxDispatcherUsesBestEffortPush(t *testing.T) { + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300, + Body: "hello", + Pts: 7, + } + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, + }}} + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 55, + TargetUserID: msg.OwnerUserID, + Pts: msg.Pts, + EventType: domain.UpdateEventNewMessage, + ExcludeSessionID: 99, + }}} + sessions := &captureBestEffortSessions{captureSessions: &captureSessions{}} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond)) + dispatcher.DispatchOnce(context.Background()) + + if !sessions.bestEffort || sessions.timeout != 50*time.Millisecond { + t.Fatalf("best-effort push = %v timeout %v, want true/50ms", sessions.bestEffort, sessions.timeout) + } + if !outbox.delivered || outbox.failed { + t.Fatalf("outbox delivered=%v failed=%v, want delivered after accepted best-effort push", outbox.delivered, outbox.failed) + } +} + +type captureBestEffortSessions struct { + *captureSessions + bestEffort bool + timeout time.Duration +} + +func (s *captureBestEffortSessions) PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { + s.bestEffort = true + s.timeout = timeout + return s.PushToUserExceptSession(ctx, userID, excludeSessionID, t, msg) +} + +// batchEventStore 给 captureUpdateEventStore 加上 BatchByCursor 批量能力。 +type batchEventStore struct { + *captureUpdateEventStore + batchCursors []store.EventCursor +} + +func (s *batchEventStore) BatchByCursor(_ context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) { + s.batchCursors = cursors + out := make([]domain.UpdateEvent, 0, len(cursors)) + for _, c := range cursors { + for _, event := range s.events { + if event.UserID == c.UserID && event.Pts == c.Pts { + out = append(out, event) + } + } + } + return out, nil +} + +// batchDispatchOutbox 给 captureDispatchOutbox 加上 MarkDeliveredBatch 批量能力。 +type batchDispatchOutbox struct { + *captureDispatchOutbox + deliveredBatch []store.DispatchOutboxItem +} + +func (s *batchDispatchOutbox) MarkDeliveredBatch(_ context.Context, items []store.DispatchOutboxItem) error { + s.deliveredBatch = append(s.deliveredBatch, items...) + return nil +} + +type captureUpdateEventStore struct { + events []domain.UpdateEvent +} + +func (s *captureUpdateEventStore) Append(context.Context, int64, domain.UpdateEvent) error { + return nil +} + +func (s *captureUpdateEventStore) ListAfter(_ context.Context, _ int64, pts, limit int) ([]domain.UpdateEvent, error) { + out := make([]domain.UpdateEvent, 0, len(s.events)) + for _, event := range s.events { + if event.Pts > pts { + out = append(out, event) + if len(out) == limit { + break + } + } + } + return out, nil +} + +func (s *captureUpdateEventStore) Current(context.Context, int64) (int, error) { + maxPts := 0 + for _, event := range s.events { + if event.Pts > maxPts { + maxPts = event.Pts + } + } + return maxPts, nil +} + +func (s *captureUpdateEventStore) MaxContiguousPts(context.Context, int64) (int, error) { + present := make(map[int]struct{}, len(s.events)) + for _, event := range s.events { + present[event.Pts] = struct{}{} + } + contiguous := 0 + for { + if _, ok := present[contiguous+1]; !ok { + break + } + contiguous++ + } + return contiguous, nil +} + +func (s *captureUpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) { + return s.MaxContiguousPts(ctx, userID) +} + +type captureDispatchOutbox struct { + items []store.DispatchOutboxItem + delivered bool + deliveredUserID int64 + deliveredID int64 + failed bool + failedError string +} + +type captureScopedSessions struct { + *captureSessions + scopedAuthKeyID [8]byte +} + +func (s *captureScopedSessions) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) { + s.BindAuthKey(sessionID, authKeyID) + s.scopedAuthKeyID = rawAuthKeyID +} + +func (s *captureScopedSessions) AuthKeyIDForSession([8]byte, int64) ([8]byte, bool) { + return s.AuthKeyID(0) +} + +func (s *captureScopedSessions) BindUserForAuthKey(rawAuthKeyID [8]byte, sessionID, userID int64) { + s.BindUser(sessionID, userID) + s.scopedAuthKeyID = rawAuthKeyID +} + +func (s *captureScopedSessions) UserIDForAuthKey([8]byte, int64) (int64, bool) { + return s.UserID(0) +} + +func (s *captureScopedSessions) UserIDResolvedForAuthKey([8]byte, int64) (int64, bool) { + return s.UserIDResolved(0) +} + +func (s *captureScopedSessions) SetReceivesUpdatesForAuthKey([8]byte, int64, bool) {} + +func (s *captureScopedSessions) PushToSessionForAuthKey(_ context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + s.scopedAuthKeyID = rawAuthKeyID + return s.PushToSession(context.Background(), sessionID, t, msg) +} + +func (s *captureScopedSessions) PushToUserExceptAuthKeySession(_ context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + s.scopedAuthKeyID = excludeAuthKeyID + return s.PushToUserExceptSession(context.Background(), userID, excludeSessionID, t, msg) +} + +func (s *captureDispatchOutbox) ClaimPending(context.Context, int) ([]store.DispatchOutboxItem, error) { + items := s.items + s.items = nil + return items, nil +} + +func (s *captureDispatchOutbox) MarkDelivered(_ context.Context, targetUserID, id int64) error { + s.delivered = true + s.deliveredUserID = targetUserID + s.deliveredID = id + return nil +} + +func (s *captureDispatchOutbox) MarkFailed(_ context.Context, _ int64, _ int64, lastError string) error { + s.failed = true + s.failedError = lastError + return nil +} + +func (s *captureDispatchOutbox) DeleteFailed(context.Context, time.Duration, int) (int, error) { + return 0, nil +} + +func TestOutboxDispatcherUsesNoopAsDelivered(t *testing.T) { + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 56, + TargetUserID: 1000000002, + Pts: 8, + EventType: domain.UpdateEventNoop, + }}} + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: 1000000002, + Type: domain.UpdateEventNoop, + Pts: 8, + Date: 1700000301, + }}} + metrics := &captureOutboxMetrics{} + dispatcher := NewOutboxDispatcher(events, outbox, &captureSessions{}, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) + dispatcher.DispatchOnce(context.Background()) + + if !outbox.delivered || outbox.failed { + t.Fatalf("noop delivered=%v failed=%v, want delivered without push", outbox.delivered, outbox.failed) + } + if metrics.delivered != 1 { + t.Fatalf("noop delivered metrics = %d, want 1", metrics.delivered) + } +} + +type captureOutboxMetrics struct { + claimed int + delivered int + failed int +} + +func (m *captureOutboxMetrics) MessageSend(time.Duration, bool, error) {} + +func (m *captureOutboxMetrics) MessageRateLimited(int) {} + +func (m *captureOutboxMetrics) OutboxClaimed(count int) { + m.claimed += count +} + +func (m *captureOutboxMetrics) OutboxDelivered(time.Duration) { + m.delivered++ +} + +func (m *captureOutboxMetrics) OutboxFailed(error) { + m.failed++ +} + +// queueFullBestEffortSessions 模拟出站队列拥塞:best-effort 推送总是失败(入队超时 / 队列满)。 +type queueFullBestEffortSessions struct { + *captureSessions + attempts int +} + +func (s *queueFullBestEffortSessions) PushToUserExceptSessionBestEffort(_ context.Context, _ int64, _ int64, _ proto.MessageType, _ bin.Encoder, _ time.Duration) (int, error) { + s.attempts++ + return 0, errors.New("mtproto outbound queue full") +} + +// TestOutboxDispatcherDefersOnPushQueueFull 验证 best-effort 推送因出站队列拥塞失败时,dispatcher +// 既不标记 delivered(任务保留,靠 dispatching 租约过期重投,满足至少一次投递语义),也不标记 +// failed(拥塞不计入 attempts 升级,避免正常满 fan-out 负载把可靠 update 误打成 failed)。 +func TestOutboxDispatcherDefersOnPushQueueFull(t *testing.T) { + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300, + Body: "hello", + Pts: 7, + } + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, + }}} + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 55, + TargetUserID: msg.OwnerUserID, + Pts: msg.Pts, + EventType: domain.UpdateEventNewMessage, + ExcludeSessionID: 99, + }}} + sessions := &queueFullBestEffortSessions{captureSessions: &captureSessions{}} + metrics := &captureOutboxMetrics{} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond), WithOutboxMetrics(metrics)) + dispatcher.DispatchOnce(context.Background()) + + if sessions.attempts != 1 { + t.Fatalf("best-effort push attempts = %d, want 1(应走 best-effort 推送路径)", sessions.attempts) + } + if outbox.delivered { + t.Fatalf("outbox delivered=true, want 未投递(拥塞应保留 dispatching 行靠租约重投)") + } + if outbox.failed { + t.Fatalf("outbox failed=true, want 未失败(拥塞不计入 attempts 升级)") + } + if metrics.failed != 0 { + t.Fatalf("metrics.failed=%d, want 0(拥塞不算投递失败)", metrics.failed) + } +} diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go new file mode 100644 index 00000000..39f7aa92 --- /dev/null +++ b/internal/rpc/payments.go @@ -0,0 +1,22 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" +) + +// registerPayments 注册第一阶段 TDesktop 启动所需 payments.* RPC 兼容响应。 +func (r *Router) registerPayments(d *tg.ServerDispatcher) { + d.OnPaymentsGetStarGiftActiveAuctions(func(ctx context.Context, hash int64) (tg.PaymentsStarGiftActiveAuctionsClass, error) { + return tdesktop.StarGiftActiveAuctions(), nil + }) + d.OnPaymentsGetSavedStarGifts(func(ctx context.Context, req *tg.PaymentsGetSavedStarGiftsRequest) (*tg.PaymentsSavedStarGifts, error) { + return tdesktop.SavedStarGifts(), nil + }) + d.OnPaymentsGetSavedStarGift(func(ctx context.Context, stargift []tg.InputSavedStarGiftClass) (*tg.PaymentsSavedStarGifts, error) { + return tdesktop.SavedStarGifts(), nil + }) +} diff --git a/internal/rpc/photos.go b/internal/rpc/photos.go new file mode 100644 index 00000000..04c32ab1 --- /dev/null +++ b/internal/rpc/photos.go @@ -0,0 +1,250 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// registerPhotos 注册 photos.* RPC handler(头像上传 / 切换 / 查询 / 删除)。 +func (r *Router) registerPhotos(d *tg.ServerDispatcher) { + d.OnPhotosUploadProfilePhoto(r.onPhotosUploadProfilePhoto) + d.OnPhotosUpdateProfilePhoto(r.onPhotosUpdateProfilePhoto) + d.OnPhotosGetUserPhotos(r.onPhotosGetUserPhotos) + d.OnPhotosDeletePhotos(r.onPhotosDeletePhotos) +} + +func (r *Router) onPhotosUploadProfilePhoto(ctx context.Context, req *tg.PhotosUploadProfilePhotoRequest) (*tg.PhotosPhoto, error) { + if r.deps.Files == nil { + return nil, notImplementedErr() + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || userID == 0 { + return nil, photoInvalidErr() + } + file, hasFile := req.GetFile() + if !hasFile { + // 仅 fallback / video / emoji markup 等本阶段不支持的头像变体。 + return nil, photoInvalidErr() + } + ref, ok := uploadedFileRef(userID, file) + if !ok { + return nil, fileReferenceInvalidErr() + } + photo, err := r.deps.Files.UploadProfilePhoto(ctx, domain.PeerTypeUser, userID, ref, int(r.clock.Now().Unix())) + if err != nil { + return nil, photoUploadErr(err) + } + return r.photosPhotoForSelf(ctx, userID, photo), nil +} + +func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosUpdateProfilePhotoRequest) (*tg.PhotosPhoto, error) { + if r.deps.Files == nil { + return nil, notImplementedErr() + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || userID == 0 { + return nil, photoInvalidErr() + } + switch in := req.ID.(type) { + case *tg.InputPhoto: + photo, found, err := r.deps.Files.SetCurrentProfilePhoto(ctx, domain.PeerTypeUser, userID, in.ID, int(r.clock.Now().Unix())) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, photoInvalidErr() + } + return r.photosPhotoForSelf(ctx, userID, photo), nil + default: + // InputPhotoEmpty:移除当前头像(停用现有当前照片)。 + if cur, found, err := r.deps.Files.CurrentProfilePhoto(ctx, domain.PeerTypeUser, userID); err == nil && found { + _, _ = r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, []int64{cur.ID}) + } + return r.photosPhotoForSelf(ctx, userID, domain.Photo{}), nil + } +} + +func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUserPhotosRequest) (tg.PhotosPhotosClass, error) { + if r.deps.Files == nil { + return &tg.PhotosPhotos{}, nil + } + currentUserID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || currentUserID == 0 { + return nil, userIDInvalidErr() + } + target, found, err := r.userFromInput(ctx, currentUserID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, userIDInvalidErr() + } + offset := req.Offset + if offset < 0 { + offset = 0 + } + limit := req.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + photos, total, err := r.deps.Files.GetProfilePhotos(ctx, domain.PeerTypeUser, target.ID, offset, limit, req.MaxID) + if err != nil { + return nil, internalErr() + } + tgPhotos := make([]tg.PhotoClass, 0, len(photos)) + for _, p := range photos { + tgPhotos = append(tgPhotos, tgPhoto(p)) + } + users := []tg.UserClass{r.tgUser(target)} + if total > len(photos)+offset { + return &tg.PhotosPhotosSlice{Count: total, Photos: tgPhotos, Users: users}, nil + } + return &tg.PhotosPhotos{Photos: tgPhotos, Users: users}, nil +} + +func (r *Router) onPhotosDeletePhotos(ctx context.Context, id []tg.InputPhotoClass) ([]int64, error) { + if r.deps.Files == nil { + return []int64{}, nil + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || userID == 0 { + return nil, photoInvalidErr() + } + ids := make([]int64, 0, len(id)) + for _, in := range id { + if photo, isPhoto := in.(*tg.InputPhoto); isPhoto && photo.ID != 0 { + ids = append(ids, photo.ID) + } + } + if len(ids) == 0 { + return []int64{}, nil + } + if _, err := r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, ids); err != nil { + return nil, internalErr() + } + return ids, nil +} + +// photosPhotoForSelf 组装 photos.photo 响应(新照片 + 带头像的 self user),并在头像变更后 +// 向该账号其它在线设备推送,使其即时刷新头像。仅由 uploadProfilePhoto / updateProfilePhoto +// 等头像变更路径调用(只读路径不得使用,否则会误触发推送)。 +func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo domain.Photo) *tg.PhotosPhoto { + out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}} + if r.deps.Users == nil { + return out + } + self, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return out + } + out.Users = append(out.Users, r.tgSelfUser(self)) + r.pushSelfPhotoUpdate(ctx, self) + return out +} + +// pushSelfPhotoUpdate 向该账号其它在线设备推送头像变更。updateUserName 不含 photo 无法刷新 +// 头像;updateUser 只是「该 user 变了」的信号(TDesktop 仅当 peer 已 full-loaded 时才 +// forceFull 重拉);最可靠是在 Updates.Users 带上含新 userProfilePhoto 的完整 self user, +// TDesktop 经 processUser→setPhoto→peerUpdated(Photo) 即时刷新。当前设备同时经 RPC 返回更新, +// 重复推送对 TDesktop 幂等(setUserpicChecked 比对 photo_id,相同则 no-op)。 +func (r *Router) pushSelfPhotoUpdate(ctx context.Context, self domain.User) { + if self.ID == 0 { + return + } + r.pushUserUpdates(ctx, self.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: self.ID}}, + Users: []tg.UserClass{r.tgSelfUser(self)}, + Date: int(r.clock.Now().Unix()), + }) +} + +// uploadedFileRef 把 tg.InputFile / InputFileBig 转成 domain.UploadedFileRef。 +func uploadedFileRef(ownerUserID int64, file tg.InputFileClass) (domain.UploadedFileRef, bool) { + switch f := file.(type) { + case *tg.InputFile: + if f.ID == 0 || f.Parts <= 0 { + return domain.UploadedFileRef{}, false + } + return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, MD5: f.MD5Checksum}, true + case *tg.InputFileBig: + if f.ID == 0 || f.Parts <= 0 { + return domain.UploadedFileRef{}, false + } + return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, Big: true}, true + default: + return domain.UploadedFileRef{}, false + } +} + +// resolveInputChatPhoto 把 tg.InputChatPhoto 解析为 *domain.Photo(nil=清除头像)。 +// 支持新上传(InputChatUploadedPhoto)与引用已有照片(InputChatPhoto{InputPhoto})。 +func (r *Router) resolveInputChatPhoto(ctx context.Context, userID int64, input tg.InputChatPhotoClass) (*domain.Photo, error) { + switch in := input.(type) { + case *tg.InputChatPhotoEmpty: + return nil, nil + case *tg.InputChatUploadedPhoto: + file, ok := in.GetFile() + if !ok { + return nil, photoInvalidErr() + } + if r.deps.Files == nil { + return nil, photoInvalidErr() + } + ref, ok := uploadedFileRef(userID, file) + if !ok { + return nil, fileReferenceInvalidErr() + } + // 频道/群头像用 avatar 尺寸('a'/'c'),匹配 InputPeerPhotoFileLocation 下载路径。 + photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref) + if err != nil { + return nil, photoUploadErr(err) + } + return &photo, nil + case *tg.InputChatPhoto: + switch id := in.ID.(type) { + case *tg.InputPhoto: + if r.deps.Files == nil { + return nil, photoInvalidErr() + } + photo, found, err := r.deps.Files.GetPhoto(ctx, id.ID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, photoInvalidErr() + } + return &photo, nil + default: + return nil, nil // InputPhotoEmpty → 清除 + } + default: + return nil, photoInvalidErr() + } +} + +func photoUploadErr(err error) error { + switch { + case errors.Is(err, domain.ErrFilePartsInvalid): + return filePartsInvalidErr() + case errors.Is(err, domain.ErrPhotoInvalid): + return photoInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/photos_push_test.go b/internal/rpc/photos_push_test.go new file mode 100644 index 00000000..5e1db46b --- /dev/null +++ b/internal/rpc/photos_push_test.go @@ -0,0 +1,58 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestUploadProfilePhotoPushesUpdateToOtherDevices 守护审计修复:换头像后必须向该账号其它在线 +// 设备推送(updateUser 信号 + Updates.Users 带新 self user),否则其它设备头像不刷新。 +// 原因:updateUserName 不含 photo 无法刷新头像,唯有带 user 对象最可靠(见 photos.go +// pushSelfPhotoUpdate 注释)。曾完全不推送,本测试防回归。 +func TestUploadProfilePhotoPushesUpdateToOtherDevices(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) + sessions := &captureSessions{} + files := &fakeFiles{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Files: files, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + req := &tg.PhotosUploadProfilePhotoRequest{} + req.SetFile(&tg.InputFile{ID: 42, Parts: 1, Name: "a.jpg"}) // File 是 flags 可选字段,须 SetFile 置位 + if _, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), req); err != nil { + t.Fatalf("uploadProfilePhoto: %v", err) + } + + snap := sessions.snapshot() + if snap.userID != owner.ID { + t.Fatalf("push target user = %d, want %d", snap.userID, owner.ID) + } + updates, ok := snap.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", snap.message) + } + hasUserUpdate := false + for _, u := range updates.Updates { + if uu, ok := u.(*tg.UpdateUser); ok && uu.UserID == owner.ID { + hasUserUpdate = true + } + } + if !hasUserUpdate { + t.Fatalf("updates = %+v, want UpdateUser for self", updates.Updates) + } + if len(updates.Users) == 0 { + t.Fatal("pushed updates missing self user — other devices cannot refresh avatar") + } +} diff --git a/internal/rpc/premium.go b/internal/rpc/premium.go new file mode 100644 index 00000000..b6b83d92 --- /dev/null +++ b/internal/rpc/premium.go @@ -0,0 +1,142 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +const ( + maxPremiumBoostsListLimit = 100 + maxPremiumBoostsOffsetBytes = 128 + maxPremiumApplyBoostSlots = 16 +) + +func (r *Router) registerPremium(d *tg.ServerDispatcher) { + d.OnPremiumGetBoostsStatus(r.onPremiumGetBoostsStatus) + d.OnPremiumGetBoostsList(r.onPremiumGetBoostsList) + d.OnPremiumGetMyBoosts(r.onPremiumGetMyBoosts) + d.OnPremiumApplyBoost(r.onPremiumApplyBoost) + d.OnPremiumGetUserBoosts(r.onPremiumGetUserBoosts) +} + +func (r *Router) onPremiumGetBoostsStatus(ctx context.Context, peer tg.InputPeerClass) (*tg.PremiumBoostsStatus, error) { + if _, _, err := r.premiumBoostChannelView(ctx, peer, false); err != nil { + return nil, err + } + return emptyPremiumBoostsStatus(), nil +} + +func (r *Router) onPremiumGetBoostsList(ctx context.Context, req *tg.PremiumGetBoostsListRequest) (*tg.PremiumBoostsList, error) { + if req.Limit < 0 || req.Limit > maxPremiumBoostsListLimit || len(req.Offset) > maxPremiumBoostsOffsetBytes { + return nil, limitInvalidErr() + } + if _, _, err := r.premiumBoostChannelView(ctx, req.Peer, true); err != nil { + return nil, err + } + return emptyPremiumBoostsList(), nil +} + +func (r *Router) onPremiumGetMyBoosts(ctx context.Context) (*tg.PremiumMyBoosts, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return emptyPremiumMyBoosts(), nil +} + +func (r *Router) onPremiumApplyBoost(ctx context.Context, req *tg.PremiumApplyBoostRequest) (*tg.PremiumMyBoosts, error) { + slots, ok := req.GetSlots() + if !ok { + return nil, tgerr400("BOOSTS_EMPTY") + } + if len(slots) == 0 { + return nil, tgerr400("SLOTS_EMPTY") + } + if len(slots) > maxPremiumApplyBoostSlots { + return nil, limitInvalidErr() + } + for _, slot := range slots { + if slot < 0 { + return nil, tgerr400("SLOTS_INVALID") + } + } + if _, _, err := r.premiumBoostChannelView(ctx, req.Peer, false); err != nil { + return nil, err + } + return emptyPremiumMyBoosts(), nil +} + +func (r *Router) onPremiumGetUserBoosts(ctx context.Context, req *tg.PremiumGetUserBoostsRequest) (*tg.PremiumBoostsList, error) { + userID, view, err := r.premiumBoostChannelView(ctx, req.Peer, true) + if err != nil { + return nil, err + } + if _, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.UserID}); err != nil { + return nil, err + } + _ = view + return emptyPremiumBoostsList(), nil +} + +func (r *Router) premiumBoostChannelView(ctx context.Context, peer tg.InputPeerClass, requireAdmin bool) (int64, domain.ChannelView, error) { + ref, ok := premiumBoostChannelRef(peer) + if !ok { + return 0, domain.ChannelView{}, peerIDInvalidErr() + } + input := &tg.InputChannel{ChannelID: ref.ID} + if ref.CheckAccessHash { + input.AccessHash = ref.AccessHash + } + userID, view, err := r.channelView(ctx, input) + if err != nil { + return 0, domain.ChannelView{}, err + } + if requireAdmin && view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin { + return 0, domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED") + } + return userID, view, nil +} + +func premiumBoostChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) { + switch p := peer.(type) { + case *tg.InputPeerChannel: + return channelInputRef{ + ID: p.ChannelID, + AccessHash: p.AccessHash, + CheckAccessHash: p.AccessHash != 0, + }, p.ChannelID > 0 + case *tg.InputPeerChannelFromMessage: + return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0 + case *tg.InputPeerChat: + return channelInputRef{ID: p.ChatID}, p.ChatID > 0 + default: + return channelInputRef{}, false + } +} + +func emptyPremiumBoostsStatus() *tg.PremiumBoostsStatus { + return &tg.PremiumBoostsStatus{ + Level: 0, + CurrentLevelBoosts: 0, + Boosts: 0, + BoostURL: "", + } +} + +func emptyPremiumBoostsList() *tg.PremiumBoostsList { + return &tg.PremiumBoostsList{ + Count: 0, + Boosts: []tg.Boost{}, + Users: []tg.UserClass{}, + } +} + +func emptyPremiumMyBoosts() *tg.PremiumMyBoosts { + return &tg.PremiumMyBoosts{ + MyBoosts: []tg.MyBoost{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} diff --git a/internal/rpc/presence.go b/internal/rpc/presence.go new file mode 100644 index 00000000..29ab780c --- /dev/null +++ b/internal/rpc/presence.go @@ -0,0 +1,490 @@ +package rpc + +import ( + "context" + "sync" + "time" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const userOnlineTTL = 5 * time.Minute +const presenceDialogFanoutCandidateLimit = 512 + +type presenceSessionKey struct { + rawAuthKeyID [8]byte + sessionID int64 +} + +type presenceSessionState struct { + userID int64 + status domain.UserStatus +} + +type presenceTracker struct { + mu sync.RWMutex + bySession map[presenceSessionKey]presenceSessionState + byUser map[int64]map[presenceSessionKey]domain.UserStatus +} + +func newPresenceTracker() *presenceTracker { + return &presenceTracker{ + bySession: make(map[presenceSessionKey]presenceSessionState), + byUser: make(map[int64]map[presenceSessionKey]domain.UserStatus), + } +} + +func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, status domain.UserStatus) { + if p == nil || userID == 0 { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if old, ok := p.bySession[key]; ok { + p.removeSessionLocked(key, old.userID) + } + p.bySession[key] = presenceSessionState{userID: userID, status: status} + sessions := p.byUser[userID] + if sessions == nil { + sessions = make(map[presenceSessionKey]domain.UserStatus) + p.byUser[userID] = sessions + } + sessions[key] = status +} + +func (p *presenceTracker) clearSession(key presenceSessionKey) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if old, ok := p.bySession[key]; ok { + p.removeSessionLocked(key, old.userID) + } +} + +func (p *presenceTracker) removeSessionLocked(key presenceSessionKey, userID int64) { + delete(p.bySession, key) + sessions := p.byUser[userID] + delete(sessions, key) + if len(sessions) == 0 { + delete(p.byUser, userID) + } +} + +func (p *presenceTracker) statusFor(userID int64, now int) (domain.UserStatus, bool) { + if p == nil || userID == 0 { + return domain.UserStatus{}, false + } + p.mu.RLock() + defer p.mu.RUnlock() + sessions := p.byUser[userID] + if len(sessions) == 0 { + return domain.UserStatus{}, false + } + known := false + var bestOnline domain.UserStatus + var bestOffline domain.UserStatus + for _, status := range sessions { + status = normalizePresenceStatus(status, now) + switch status.Kind { + case domain.UserStatusOnline: + if bestOnline.Expires == 0 || status.Expires > bestOnline.Expires { + bestOnline = status + } + known = true + case domain.UserStatusOffline: + if bestOffline.WasOnline == 0 || status.WasOnline > bestOffline.WasOnline { + bestOffline = status + } + known = true + case domain.UserStatusRecently, domain.UserStatusLastWeek, domain.UserStatusLastMonth, domain.UserStatusEmpty: + if !known { + bestOffline = status + known = true + } + } + } + if bestOnline.Kind == domain.UserStatusOnline { + return bestOnline, true + } + if known { + return bestOffline, true + } + return domain.UserStatus{}, false +} + +func normalizePresenceStatus(status domain.UserStatus, now int) domain.UserStatus { + if status.Kind == domain.UserStatusOnline && status.Expires <= now { + wasOnline := status.WasOnline + if wasOnline == 0 || wasOnline > status.Expires { + wasOnline = status.Expires + } + return domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: wasOnline} + } + return status +} + +func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offline bool) domain.UserStatus { + now := int(r.clock.Now().Unix()) + status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now} + if !offline { + status = domain.UserStatus{ + Kind: domain.UserStatusOnline, + Expires: now + int(userOnlineTTL/time.Second), + WasOnline: now, + } + } + if key, ok := presenceSessionKeyFromContext(ctx); ok { + r.presence.setSessionStatus(key, userID, status) + } + r.persistLastSeen(ctx, userID, now) + return r.userPresenceStatusForUser(domain.User{ID: userID, LastSeenAt: now}) +} + +func (r *Router) announceSessionOnline(ctx context.Context, userID int64) { + if userID == 0 { + return + } + status := r.setPresenceFromContext(ctx, userID, false) + r.pushUserStatus(ctx, userID, status) + r.pushOnlinePeerStatusesToCurrentSession(ctx, userID) +} + +func (r *Router) userPresenceStatus(userID int64) domain.UserStatus { + return r.userPresenceStatusForUser(domain.User{ID: userID}) +} + +func (r *Router) userPresenceStatusForUser(u domain.User) domain.UserStatus { + userID := u.ID + if userID == 0 { + return domain.UserStatus{Kind: domain.UserStatusRecently} + } + now := int(r.clock.Now().Unix()) + if status, ok := r.presence.statusFor(userID, now); ok { + return status + } + if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok && provider.IsUserOnline(userID) { + return domain.UserStatus{ + Kind: domain.UserStatusOnline, + Expires: now + int(userOnlineTTL/time.Second), + WasOnline: now, + } + } + if u.LastSeenAt > 0 { + return domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: u.LastSeenAt} + } + return domain.UserStatus{Kind: domain.UserStatusRecently} +} + +type userLastSeenUpdater interface { + UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error +} + +func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt int) { + if userID == 0 || lastSeenAt <= 0 || r.deps.Users == nil { + return + } + updater, ok := r.deps.Users.(userLastSeenUpdater) + if !ok { + return + } + if err := updater.UpdateLastSeen(ctx, userID, lastSeenAt); err != nil { + r.log.Warn("Update user last seen failed", zap.Int64("user_id", userID), zap.Int("last_seen_at", lastSeenAt), zap.Error(err)) + } +} + +// SessionOffline is called by mtprotoedge when an active connection disappears. +// Business-side effects stay here: mtprotoedge only reports lifecycle facts. +func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) { + if userID == 0 { + return + } + ctx := WithSessionID(WithRawAuthKeyID(context.Background(), rawAuthKeyID), sessionID) + key := presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID} + if !lastForUser { + r.presence.clearSession(key) + return + } + now := int(r.clock.Now().Unix()) + status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now} + r.presence.setSessionStatus(key, userID, status) + r.persistLastSeen(ctx, userID, now) + r.pushUserStatus(ctx, userID, status) +} + +func presenceSessionKeyFromContext(ctx context.Context) (presenceSessionKey, bool) { + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return presenceSessionKey{}, false + } + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + return presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}, true + } + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + return presenceSessionKey{rawAuthKeyID: authKeyID, sessionID: sessionID}, true + } + return presenceSessionKey{sessionID: sessionID}, true +} + +func (r *Router) withUserPresence(u domain.User) domain.User { + if u.ID == 0 { + return u + } + u.Status = r.userPresenceStatusForUser(u) + return u +} + +func (r *Router) withUsersPresence(users []domain.User) []domain.User { + if len(users) == 0 { + return users + } + out := append([]domain.User(nil), users...) + for i := range out { + out[i] = r.withUserPresence(out[i]) + } + return out +} + +func (r *Router) withContactListPresence(list domain.ContactList) domain.ContactList { + if len(list.Contacts) == 0 { + return list + } + out := list + out.Contacts = append([]domain.Contact(nil), list.Contacts...) + for i := range out.Contacts { + out.Contacts[i].User = r.withUserPresence(out.Contacts[i].User) + } + return out +} + +func (r *Router) withMessageListPresence(list domain.MessageList) domain.MessageList { + list.Users = r.withUsersPresence(list.Users) + return list +} + +func (r *Router) withDialogListPresence(list domain.DialogList) domain.DialogList { + list.Users = r.withUsersPresence(list.Users) + return list +} + +func (r *Router) withUserSearchPresence(res domain.UserSearchResult) domain.UserSearchResult { + res.MyResults = r.withUsersPresence(res.MyResults) + res.Results = r.withUsersPresence(res.Results) + return res +} + +func (r *Router) tgUser(u domain.User) *tg.User { + return tgUser(r.withUserPresence(u)) +} + +func (r *Router) tgSelfUser(u domain.User) *tg.User { + return tgSelfUser(r.withUserPresence(u)) +} + +func (r *Router) tgUsers(users []domain.User) []tg.UserClass { + return tgUsers(r.withUsersPresence(users)) +} + +func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain.UserStatus) { + if userID == 0 { + return + } + update := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUserStatus{ + UserID: userID, + Status: tgUserStatus(status), + }}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } + r.pushUserUpdates(ctx, userID, update) + for _, recipientID := range r.onlinePresenceRecipientIDs(ctx, userID) { + if recipientID == userID { + continue + } + r.pushUserMessage(ctx, recipientID, "push user status", update) + } +} + +func (r *Router) onlinePresenceRecipientIDs(ctx context.Context, userID int64) []int64 { + if userID == 0 { + return nil + } + seen := map[int64]struct{}{} + out := make([]int64, 0) + add := func(ids []int64) { + for _, id := range ids { + if id == 0 || id == userID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + add(r.onlineContactIDs(ctx, userID)) + add(r.onlinePrivateDialogPeerIDs(ctx, userID, seen)) + return out +} + +func (r *Router) onlineContactIDs(ctx context.Context, userID int64) []int64 { + if r.deps.Contacts == nil || userID == 0 { + return nil + } + ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0) + if err != nil || notModified || len(ids) == 0 { + return nil + } + candidates := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + contactID := int64(id) + if contactID == 0 || contactID == userID { + continue + } + if _, ok := seen[contactID]; ok { + continue + } + seen[contactID] = struct{}{} + candidates = append(candidates, contactID) + } + if len(candidates) == 0 { + return nil + } + if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok { + return provider.OnlineUserIDsForCandidates(candidates, 0) + } + return candidates +} + +func (r *Router) onlinePrivateDialogPeerIDs(ctx context.Context, userID int64, already map[int64]struct{}) []int64 { + if r.deps.Dialogs == nil || userID == 0 { + return nil + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return nil + } + candidates := provider.OnlineUserIDs(presenceDialogFanoutCandidateLimit) + if len(candidates) == 0 { + return nil + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + out := make([]int64, 0, len(candidates)) + for _, candidateID := range candidates { + if candidateID == 0 || candidateID == userID { + continue + } + if _, ok := already[candidateID]; ok { + continue + } + list, err := r.deps.Dialogs.GetPeerDialogs(ctx, candidateID, []domain.Peer{peer}) + if err != nil || !dialogListHasPeer(list, peer) { + continue + } + out = append(out, candidateID) + } + return out +} + +func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, userID int64) { + peerIDs := r.onlineRelevantPeerIDs(ctx, userID) + if len(peerIDs) == 0 { + return + } + updates := make([]tg.UpdateClass, 0, len(peerIDs)) + for _, peerID := range peerIDs { + status := r.userPresenceStatus(peerID) + if status.Kind != domain.UserStatusOnline { + continue + } + updates = append(updates, &tg.UpdateUserStatus{ + UserID: peerID, + Status: tgUserStatus(status), + }) + } + if len(updates) == 0 { + return + } + r.pushCurrentSessionMessage(ctx, "push online peer statuses", &tg.Updates{ + Updates: updates, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) +} + +func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int64 { + if userID == 0 { + return nil + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return nil + } + seen := map[int64]struct{}{} + candidates := make([]int64, 0) + add := func(id int64) { + if id == 0 || id == userID { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + candidates = append(candidates, id) + } + if r.deps.Contacts != nil { + ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0) + if err == nil && !notModified { + for _, id := range ids { + add(int64(id)) + } + } + } + if r.deps.Dialogs != nil { + list, err := r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{Limit: presenceDialogFanoutCandidateLimit}) + if err == nil { + for _, dialog := range list.Dialogs { + if dialog.Peer.Type == domain.PeerTypeUser { + add(dialog.Peer.ID) + } + } + } + } + if len(candidates) == 0 { + return nil + } + return provider.OnlineUserIDsForCandidates(candidates, 0) +} + +func dialogListHasPeer(list domain.DialogList, peer domain.Peer) bool { + for _, dialog := range list.Dialogs { + if dialog.Peer != peer { + continue + } + if dialog.TopMessage != 0 || + dialog.TopMessageDate != 0 || + dialog.ReadInboxMaxID != 0 || + dialog.ReadOutboxMaxID != 0 || + dialog.UnreadCount != 0 || + dialog.UnreadMentions != 0 || + dialog.UnreadReactions != 0 || + dialog.Pinned || + dialog.UnreadMark || + dialog.Draft != nil { + return true + } + for _, msg := range list.Messages { + if msg.Peer == peer { + return true + } + } + } + return false +} diff --git a/internal/rpc/push.go b/internal/rpc/push.go new file mode 100644 index 00000000..c4920f21 --- /dev/null +++ b/internal/rpc/push.go @@ -0,0 +1,73 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" + "go.uber.org/zap" +) + +func (r *Router) pushUserMessage(ctx context.Context, userID int64, logMessage string, msg bin.Encoder) int { + if r.deps.Sessions == nil || userID == 0 || msg == nil { + return 0 + } + sessionID, _ := SessionIDFrom(ctx) + if timeout := r.cfg.OutboundPushTimeout; timeout > 0 { + authKeyID, _ := AuthKeyIDFrom(ctx) + if scoped, ok := r.deps.Sessions.(ScopedBestEffortSessionBinder); ok { + if sent, err := scoped.PushToUserExceptAuthKeySessionBestEffort(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg, timeout); err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Duration("timeout", timeout), zap.Error(err)) + return sent + } else { + return sent + } + } + if bestEffort, ok := r.deps.Sessions.(BestEffortSessionBinder); ok { + if sent, err := bestEffort.PushToUserExceptSessionBestEffort(ctx, userID, sessionID, proto.MessageFromServer, msg, timeout); err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Duration("timeout", timeout), zap.Error(err)) + return sent + } else { + return sent + } + } + } + if scoped, ok := r.scopedSessions(); ok { + authKeyID, _ := AuthKeyIDFrom(ctx) + if sent, err := scoped.PushToUserExceptAuthKeySession(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg); err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err)) + return sent + } else { + return sent + } + } + if sent, err := r.deps.Sessions.PushToUserExceptSession(ctx, userID, sessionID, proto.MessageFromServer, msg); err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err)) + return sent + } else { + return sent + } +} + +func (r *Router) pushCurrentSessionMessage(ctx context.Context, logMessage string, msg bin.Encoder) { + if r.deps.Sessions == nil || msg == nil { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + if scoped, ok := r.scopedSessions(); ok { + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return + } + if err := scoped.PushToSessionForAuthKey(ctx, rawAuthKeyID, sessionID, proto.MessageFromServer, msg); err != nil { + r.log.Debug(logMessage, zap.Int64("session_id", sessionID), zap.Error(err)) + } + return + } + if err := r.deps.Sessions.PushToSession(ctx, sessionID, proto.MessageFromServer, msg); err != nil { + r.log.Debug(logMessage, zap.Int64("session_id", sessionID), zap.Error(err)) + } +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go new file mode 100644 index 00000000..d8a4977d --- /dev/null +++ b/internal/rpc/router.go @@ -0,0 +1,416 @@ +package rpc + +import ( + "context" + "encoding/hex" + "fmt" + "sync" + "time" + + "go.uber.org/zap" + "golang.org/x/sync/singleflight" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" +) + +// maxWrapperDepth 限制 invokeWithLayer/initConnection 等 wrapper 的嵌套深度,防御恶意构造。 +const maxWrapperDepth = 4 + +var ( + tlTypeNamesOnce sync.Once + tlTypeNames map[uint32]string +) + +// Config 是 Router 所需的服务端信息。 +type Config struct { + DC int + IP string // 对外公布的 DC IP(写入 DCOptions) + Port int // 对外公布的 DC 端口 + OutboundPushTimeout time.Duration +} + +// Router 把解密后的 RPC 请求按 TypeID 路由到 typed handler(tg.ServerDispatcher)。 +// +// handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler +// 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳: +// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates,并兜底未注册 RPC。 +type Router struct { + cfg Config + log *zap.Logger + clock clock.Clock + deps Deps + dispatcher *tg.ServerDispatcher + clientInfoMu sync.RWMutex + clientInfo map[clientInfoSessionKey]ClientInfo + authUserMu sync.RWMutex + authUsers map[[8]byte]authUserCacheEntry + authUserSF singleflight.Group + presence *presenceTracker +} + +type clientInfoSessionKey struct { + rawAuthKeyID [8]byte + sessionID int64 +} + +type authUserCacheEntry struct { + userID int64 + found bool +} + +// New 创建 Router,由各业务域自行注册其 RPC handler(registerHelp/Auth/Users/Updates)。 +func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { + r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, presence: newPresenceTracker()} + d := tg.NewServerDispatcher(r.fallback) + + r.registerHelp(d) + r.registerAuth(d) + r.registerUsers(d) + r.registerUpdates(d) + r.registerAccount(d) + r.registerMessages(d) + r.registerChannels(d) + r.registerUpload(d) + r.registerPhotos(d) + r.registerFolders(d) + r.registerContacts(d) + r.registerLangpack(d) + r.registerStories(d) + r.registerPayments(d) + r.registerStats(d) + r.registerPremium(d) + r.registerAiCompose(d) + + r.dispatcher = d + return r +} + +// Dispatch 路由一条 RPC 请求:先剥离 invokeWithLayer / initConnection / +// invokeWithoutUpdates 等 wrapper(注入 layer / 客户端信息到 ctx), +// 再按 TypeID 路由到 typed handler。满足 mtprotoedge.RPCHandler。 +func (r *Router) Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error) { + ctx = WithRawAuthKeyID(ctx, authKeyID) + effectiveAuthKeyID, err := r.effectiveAuthKeyID(ctx, authKeyID, sessionID) + if err != nil { + return nil, internalErr() + } + ctx = WithAuthKeyID(ctx, effectiveAuthKeyID) + ctx = WithSessionID(ctx, sessionID) + userID, hasUserID, err := r.effectiveUserID(ctx, authKeyID, effectiveAuthKeyID, sessionID) + if err != nil { + return nil, internalErr() + } + if hasUserID { + ctx = WithUserID(ctx, userID) + } + if info, ok := r.clientInfoForSession(ctx); ok { + ctx = WithClientInfo(ctx, info) + } + return r.dispatch(ctx, b, 0) +} + +func (r *Router) effectiveAuthKeyID(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64) ([8]byte, error) { + if r.deps.Sessions != nil { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + if id, ok := scoped.AuthKeyIDForSession(rawAuthKeyID, sessionID); ok { + return id, nil + } + } else if id, ok := r.deps.Sessions.AuthKeyID(sessionID); ok { + return id, nil + } + } + effective := rawAuthKeyID + if r.deps.Auth != nil { + resolved, ok, err := r.deps.Auth.ResolveAuthKey(ctx, rawAuthKeyID) + if err != nil { + return [8]byte{}, err + } + if ok { + effective = resolved + } + } + if r.deps.Sessions != nil { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + scoped.BindAuthKeyForSession(rawAuthKeyID, sessionID, effective) + } else { + r.deps.Sessions.BindAuthKey(sessionID, effective) + } + } + return effective, nil +} + +func (r *Router) effectiveUserID(ctx context.Context, rawAuthKeyID, authKeyID [8]byte, sessionID int64) (int64, bool, error) { + if userID, ok := UserIDFrom(ctx); ok { + if scoped, ok := r.scopedSessions(); ok { + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID) + } else if r.deps.Sessions != nil { + r.deps.Sessions.BindUser(sessionID, userID) + } + return userID, true, nil + } + if r.deps.Sessions != nil { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + if userID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved { + return userID, userID != 0, nil + } + } else if userID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved { + return userID, userID != 0, nil + } + } + if r.deps.Auth == nil { + return 0, false, nil + } + userID, found, err := r.lookupAuthUser(ctx, authKeyID) + if err != nil { + return 0, false, err + } + if r.deps.Sessions != nil { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + if cachedUserID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved { + return cachedUserID, cachedUserID != 0, nil + } + } else if cachedUserID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved { + return cachedUserID, cachedUserID != 0, nil + } + if found { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID) + } else { + r.deps.Sessions.BindUser(sessionID, userID) + } + r.announceSessionOnline(ctx, userID) + } else { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, 0) + } else { + r.deps.Sessions.BindUser(sessionID, 0) + } + } + } + return userID, found, nil +} + +func (r *Router) lookupAuthUser(ctx context.Context, authKeyID [8]byte) (int64, bool, error) { + if userID, found, ok := r.cachedAuthUser(authKeyID); ok { + return userID, found, nil + } + key := string(authKeyID[:]) + v, err, _ := r.authUserSF.Do(key, func() (any, error) { + if userID, found, ok := r.cachedAuthUser(authKeyID); ok { + return authUserCacheEntry{userID: userID, found: found}, nil + } + userID, found, err := r.deps.Auth.UserID(ctx, authKeyID) + if err != nil { + return authUserCacheEntry{}, err + } + r.setAuthUserCache(authKeyID, userID, found) + return authUserCacheEntry{userID: userID, found: found}, nil + }) + if err != nil { + return 0, false, err + } + entry := v.(authUserCacheEntry) + return entry.userID, entry.found, nil +} + +func (r *Router) cachedAuthUser(authKeyID [8]byte) (int64, bool, bool) { + r.authUserMu.RLock() + defer r.authUserMu.RUnlock() + entry, ok := r.authUsers[authKeyID] + if !ok { + return 0, false, false + } + return entry.userID, entry.found, true +} + +func (r *Router) setAuthUserCache(authKeyID [8]byte, userID int64, found bool) { + r.authUserMu.Lock() + defer r.authUserMu.Unlock() + if r.authUsers == nil { + r.authUsers = make(map[[8]byte]authUserCacheEntry) + } + r.authUsers[authKeyID] = authUserCacheEntry{userID: userID, found: found} +} + +func (r *Router) invalidateAuthUserCache(authKeyID [8]byte) { + r.authUserMu.Lock() + delete(r.authUsers, authKeyID) + r.authUserMu.Unlock() + r.authUserSF.Forget(string(authKeyID[:])) +} + +func (r *Router) scopedSessions() (ScopedSessionBinder, bool) { + if r.deps.Sessions == nil { + return nil, false + } + scoped, ok := r.deps.Sessions.(ScopedSessionBinder) + return scoped, ok +} + +func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.Encoder, error) { + if depth > maxWrapperDepth { + return nil, wrapperTooDeepErr() + } + + id, err := b.PeekID() + if err != nil { + return nil, err + } + + switch id { + case tg.InvokeWithLayerRequestTypeID: + if err := b.ConsumeID(id); err != nil { + return nil, err + } + layer, err := b.Int() + if err != nil { + return nil, fmt.Errorf("decode invokeWithLayer layer: %w", err) + } + // query 紧跟 layer,buffer 剩余即内层请求。 + return r.dispatch(WithLayer(ctx, layer), b, depth+1) + + case tg.InvokeWithoutUpdatesRequestTypeID: + if err := b.ConsumeID(id); err != nil { + return nil, err + } + return r.dispatch(ctx, b, depth+1) + + case tg.InitConnectionRequestTypeID: + req := &tg.InitConnectionRequest{Query: &rawObject{}} + if err := req.Decode(b); err != nil { + return nil, fmt.Errorf("decode initConnection: %w", err) + } + info := ClientInfo{ + APIID: req.APIID, + DeviceModel: req.DeviceModel, + SystemVersion: req.SystemVersion, + AppVersion: req.AppVersion, + SystemLangCode: req.SystemLangCode, + LangPack: req.LangPack, + LangCode: req.LangCode, + } + ctx = WithClientInfo(ctx, info) + r.rememberClientInfo(ctx, info) + r.log.Debug("initConnection", + zap.Int("api_id", req.APIID), + zap.String("device", req.DeviceModel), + zap.String("app", req.AppVersion), + zap.Int("layer", LayerFrom(ctx)), + ) + inner, ok := req.Query.(*rawObject) + if !ok { + return nil, fmt.Errorf("initConnection query: unexpected type %T", req.Query) + } + return r.dispatch(ctx, &bin.Buffer{Buf: inner.data}, depth+1) + + default: + start := time.Now() + enc, err := r.dispatcher.Handle(ctx, b) + dur := time.Since(start) + fields := append([]zap.Field{ + zap.String("method", tlTypeName(id)), + zap.String("type_id", fmt.Sprintf("%#x", id)), + zap.Duration("dur", dur), + }, r.contextLogFields(ctx)...) + if err != nil || dur > 100*time.Millisecond { + if err != nil { + fields = append(fields, zap.Error(err)) + } + r.log.Info("RPC inner handled", fields...) + } else { + r.log.Debug("RPC inner handled", fields...) + } + return enc, err + } +} + +func tlTypeName(id uint32) string { + tlTypeNamesOnce.Do(func() { + names := tg.NamesMap() + tlTypeNames = make(map[uint32]string, len(names)) + for name, typeID := range names { + tlTypeNames[typeID] = name + } + }) + if name, ok := tlTypeNames[id]; ok { + return name + } + return fmt.Sprintf("%#x", id) +} + +func (r *Router) rememberClientInfo(ctx context.Context, info ClientInfo) { + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + r.clientInfoMu.Lock() + defer r.clientInfoMu.Unlock() + if r.clientInfo == nil { + r.clientInfo = make(map[clientInfoSessionKey]ClientInfo) + } + r.clientInfo[clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}] = info +} + +func (r *Router) clientInfoForSession(ctx context.Context) (ClientInfo, bool) { + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return ClientInfo{}, false + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return ClientInfo{}, false + } + r.clientInfoMu.RLock() + defer r.clientInfoMu.RUnlock() + info, ok := r.clientInfo[clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}] + return info, ok +} + +// fallback 处理未注册的 RPC:记录到 compatibility trace(落兼容矩阵), +// 返回 NOT_IMPLEMENTED rpc_error 让客户端继续运行而非断连。 +func (r *Router) fallback(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { + id, _ := b.PeekID() + fields := append([]zap.Field{zap.String("type_id", fmt.Sprintf("%#x", id))}, r.contextLogFields(ctx)...) + r.log.Warn("Unhandled RPC (compatibility trace)", fields...) + return nil, notImplementedErr() +} + +func (r *Router) contextLogFields(ctx context.Context) []zap.Field { + fields := []zap.Field{zap.Int("layer", LayerFrom(ctx))} + if sessionID, ok := SessionIDFrom(ctx); ok { + fields = append(fields, zap.Int64("session_id", sessionID)) + } + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + fields = append(fields, zap.String("raw_auth_key_id", hex.EncodeToString(rawAuthKeyID[:]))) + } + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + fields = append(fields, zap.String("auth_key_id", hex.EncodeToString(authKeyID[:]))) + } + if userID, ok := UserIDFrom(ctx); ok { + fields = append(fields, zap.Int64("user_id", userID)) + } + return fields +} + +// rawObject 在解码 wrapper 时按原样捕获内层 query 的 TL 字节,供递归分发。 +// 它实现 bin.Object(Encode/Decode),但只搬运字节、不解释内容。 +type rawObject struct { + data []byte +} + +func (o *rawObject) Decode(b *bin.Buffer) error { + o.data = append(o.data[:0], b.Buf...) + b.Skip(len(b.Buf)) + return nil +} + +func (o *rawObject) Encode(b *bin.Buffer) error { + b.Put(o.data) + return nil +} diff --git a/internal/rpc/router_test.go b/internal/rpc/router_test.go new file mode 100644 index 00000000..8121d6b8 --- /dev/null +++ b/internal/rpc/router_test.go @@ -0,0 +1,10137 @@ +package rpc + +import ( + "context" + "encoding/binary" + "reflect" + "strconv" + "strings" + "sync" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + + appauth "telesrv/internal/app/auth" + appchannels "telesrv/internal/app/channels" + appcontacts "telesrv/internal/app/contacts" + appdialogs "telesrv/internal/app/dialogs" + appmessages "telesrv/internal/app/messages" + appupdates "telesrv/internal/app/updates" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestDispatchUnwrapsWrappers 验证 Router 能剥离 +// invokeWithLayer(initConnection(help.getConfig)) 并路由到 getConfig handler。 +func TestDispatchUnwrapsWrappers(t *testing.T) { + const ( + dc = 2 + ip = "127.0.0.1" + port = 2398 + layer = 225 + ) + r := New(Config{DC: dc, IP: ip, Port: port}, Deps{}, zaptest.NewLogger(t), clock.System) + + req := &tg.InvokeWithLayerRequest{ + Layer: layer, + Query: &tg.InitConnectionRequest{ + APIID: 123, + DeviceModel: "TestDevice", + SystemVersion: "1.0", + AppVersion: "1.0", + SystemLangCode: "en", + LangPack: "tdesktop", + LangCode: "en", + Query: &tg.HelpGetConfigRequest{}, + }, + } + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode wrapped request: %v", err) + } + + enc, err := r.Dispatch(context.Background(), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + cfg, ok := enc.(*tg.Config) + if !ok { + t.Fatalf("result type = %T, want *tg.Config", enc) + } + if cfg.ThisDC != dc { + t.Fatalf("ThisDC = %d, want %d", cfg.ThisDC, dc) + } + if len(cfg.DCOptions) != 1 || cfg.DCOptions[0].Port != port { + t.Fatalf("DCOptions = %+v", cfg.DCOptions) + } +} + +// TestDispatchUnknownReturnsError 验证未注册 RPC 经 fallback 返回 rpc_error。 +func TestDispatchUnknownReturnsError(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + + // help.getCdnConfig 第一阶段未注册,应走 fallback。 + var b bin.Buffer + if err := (&tg.HelpGetCDNConfigRequest{}).Encode(&b); err != nil { + t.Fatalf("encode: %v", err) + } + + _, err := r.Dispatch(context.Background(), [8]byte{}, 0, &b) + if err == nil { + t.Fatal("expected error for unregistered RPC") + } +} + +func TestDispatchResolvesBoundTempAuthKey(t *testing.T) { + var tempAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} + var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} + tempBindings := memory.NewTempAuthKeyBindingStore() + if err := tempBindings.Save(context.Background(), domain.TempAuthKeyBinding{ + TempAuthKeyID: tempAuthKeyID, + PermAuthKeyID: int64(binary.LittleEndian.Uint64(permAuthKeyID[:])), + ExpiresAt: int(time.Now().Add(time.Hour).Unix()), + }); err != nil { + t.Fatalf("save temp binding: %v", err) + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Auth: appauth.NewService(nil, nil, nil, nil, tempBindings, "12345"), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + req := &tg.HelpGetConfigRequest{} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 123, &in); err != nil { + t.Fatalf("dispatch: %v", err) + } + gotSession := sessions.snapshot() + if gotSession.authKeyID != permAuthKeyID || !gotSession.authKeyResolved { + t.Fatalf("session auth key = %x resolved %v, want resolved perm %x", gotSession.authKeyID, gotSession.authKeyResolved, permAuthKeyID) + } +} + +func TestBindTempAuthKeyClearsNegativeUserCache(t *testing.T) { + var tempAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} + var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} + sessions := &captureSessions{} + auth := &captureAuthService{} + r := New(Config{}, Deps{ + Auth: auth, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + req := &tg.AuthBindTempAuthKeyRequest{ + PermAuthKeyID: int64(binary.LittleEndian.Uint64(permAuthKeyID[:])), + Nonce: 2, + ExpiresAt: int(time.Now().Add(time.Hour).Unix()), + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 123, &in); err != nil { + t.Fatalf("dispatch: %v", err) + } + if auth.userIDCount != 1 { + t.Fatalf("user lookup count = %d, want one negative lookup before temp binding", auth.userIDCount) + } + gotSession := sessions.snapshot() + if gotSession.authKeyID != permAuthKeyID || !gotSession.authKeyResolved { + t.Fatalf("session auth key = %x resolved %v, want perm %x", gotSession.authKeyID, gotSession.authKeyResolved, permAuthKeyID) + } + if gotSession.userResolved || gotSession.userID != 0 { + t.Fatalf("negative user cache = user %d resolved %v, want cleared after auth key switch", gotSession.userID, gotSession.userResolved) + } +} + +func TestDispatchCachesUnauthenticatedIdentity(t *testing.T) { + var rawAuthKeyID = [8]byte{0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77} + auth := &captureAuthService{} + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Auth: auth, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + req := &tg.HelpGetConfigRequest{} + + for i := 0; i < 2; i++ { + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(context.Background(), rawAuthKeyID, 777, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + + if auth.resolveCount != 1 || auth.userIDCount != 1 { + t.Fatalf("identity lookups = resolve %d user %d, want one-time negative cache", auth.resolveCount, auth.userIDCount) + } + gotSession := sessions.snapshot() + if !gotSession.userResolved || gotSession.userID != 0 { + t.Fatalf("cached unauth identity = user %d resolved %v, want 0/true", gotSession.userID, gotSession.userResolved) + } +} + +func TestDispatchCachesConnectionIdentity(t *testing.T) { + var rawAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} + var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} + auth := &captureAuthService{ + resolvedAuthKeyID: permAuthKeyID, + hasResolved: true, + userID: 1000000001, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Auth: auth, + Sessions: sessions, + Users: staticUsersService{user: domain.User{ + ID: 1000000001, + FirstName: "Test", + }}, + }, zaptest.NewLogger(t), clock.System) + req := &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}} + + for i := 0; i < 2; i++ { + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(context.Background(), rawAuthKeyID, 777, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + + if auth.resolveCount != 1 || auth.userIDCount != 1 { + t.Fatalf("identity lookups = resolve %d user %d, want one-time cache", auth.resolveCount, auth.userIDCount) + } + gotSession := sessions.snapshot() + if gotSession.authKeyID != permAuthKeyID || gotSession.userID != 1000000001 { + t.Fatalf("cached identity = auth %x user %d, want perm/user", gotSession.authKeyID, gotSession.userID) + } +} + +func TestDispatchSingleflightsAuthUserLookupAcrossStartupRPCs(t *testing.T) { + var authKeyID = [8]byte{0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42} + auth := newBlockingUserAuthService(1000000001) + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Auth: auth, + }, zaptest.NewLogger(t), clock.System) + + const calls = 16 + errs := make(chan error, calls) + var wg sync.WaitGroup + for i := 0; i < calls; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + var in bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { + errs <- err + return + } + _, err := r.Dispatch(context.Background(), authKeyID, int64(100+i), &in) + errs <- err + }(i) + } + + select { + case <-auth.started: + case <-time.After(time.Second): + t.Fatal("auth lookup did not start") + } + close(auth.release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("dispatch: %v", err) + } + } + if got := auth.UserIDCount(); got != 1 { + t.Fatalf("UserID lookups = %d, want singleflighted one lookup", got) + } + + for i := 0; i < 3; i++ { + var in bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { + t.Fatalf("encode: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, int64(200+i), &in); err != nil { + t.Fatalf("cached dispatch %d: %v", i, err) + } + } + if got := auth.UserIDCount(); got != 1 { + t.Fatalf("UserID lookups after cache hits = %d, want still one lookup", got) + } +} + +func TestDispatchAnnouncesPresenceWhenSessionIdentityRestored(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + dialogs := memory.NewDialogStore() + if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }}, + Users: []domain.User{bob}, + }); err != nil { + t.Fatalf("save dialogs: %v", err) + } + auth := &captureAuthService{userID: bob.ID} + sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} + r := New(Config{}, Deps{ + Auth: auth, + Dialogs: appdialogs.NewService(dialogs), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + req := &tg.HelpGetConfigRequest{} + + for i := 0; i < 2; i++ { + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(ctx, [8]byte{0x22}, 333, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + + if auth.userIDCount != 1 { + t.Fatalf("user lookup count = %d, want one restored identity lookup", auth.userIDCount) + } + gotPushes := sessions.pushedUserIDs() + if !reflect.DeepEqual(gotPushes, []int64{bob.ID, alice.ID}) { + t.Fatalf("pushed users = %+v, want self and online private dialog peer once", gotPushes) + } + update := pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != bob.ID { + t.Fatalf("status user = %d, want bob", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("status = %#v, want online with future expires", update.Status) + } +} + +func TestDispatchPushesOnlinePeerStatusesToRestoredSession(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + dialogs := memory.NewDialogStore() + if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }}, + Users: []domain.User{bob}, + }); err != nil { + t.Fatalf("save dialogs: %v", err) + } + auth := &captureAuthService{userID: alice.ID} + sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}} + r := New(Config{}, Deps{ + Auth: auth, + Dialogs: appdialogs.NewService(dialogs), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false); err != nil || !ok { + t.Fatalf("bob account.updateStatus online = %v, %v", ok, err) + } + req := &tg.HelpGetConfigRequest{} + + for i := 0; i < 2; i++ { + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(ctx, [8]byte{0x33}, 444, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + + if auth.userIDCount != 1 { + t.Fatalf("user lookup count = %d, want one restored identity lookup", auth.userIDCount) + } + update := pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != bob.ID { + t.Fatalf("status user = %d, want bob", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("status = %#v, want online peer status for restored session", update.Status) + } +} + +// TestTDesktopStartupRPCsEncode 验证第一阶段 TDesktop 启动 RPC 均能被路由并编码回包。 +func TestTDesktopStartupRPCsEncode(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: staticUsersService{user: domain.User{ + ID: 1000000001, + AccessHash: 42, + FirstName: "Test", + LastName: "User", + Phone: "15550000000", + }}, + }, zaptest.NewLogger(t), clock.System) + + tests := []struct { + name string + req bin.Encoder + }{ + {name: "auth.bindTempAuthKey", req: &tg.AuthBindTempAuthKeyRequest{PermAuthKeyID: 1, Nonce: 2, ExpiresAt: 3, EncryptedMessage: []byte("binding")}}, + {name: "auth.exportLoginToken", req: &tg.AuthExportLoginTokenRequest{APIID: 1, APIHash: "hash"}}, + {name: "help.getAppConfig", req: &tg.HelpGetAppConfigRequest{}}, + {name: "help.getCountriesList", req: &tg.HelpGetCountriesListRequest{LangCode: "en"}}, + {name: "help.getTimezonesList", req: &tg.HelpGetTimezonesListRequest{}}, + {name: "help.getPeerColors", req: &tg.HelpGetPeerColorsRequest{}}, + {name: "help.getPeerProfileColors", req: &tg.HelpGetPeerProfileColorsRequest{}}, + {name: "help.getPromoData", req: &tg.HelpGetPromoDataRequest{}}, + {name: "help.getTermsOfServiceUpdate", req: &tg.HelpGetTermsOfServiceUpdateRequest{}}, + {name: "help.getPremiumPromo", req: &tg.HelpGetPremiumPromoRequest{}}, + {name: "account.getPassword", req: &tg.AccountGetPasswordRequest{}}, + {name: "account.getNotifySettings", req: &tg.AccountGetNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}}}, + {name: "account.getPrivacy", req: &tg.AccountGetPrivacyRequest{Key: &tg.InputPrivacyKeyStatusTimestamp{}}}, + {name: "account.getAuthorizations", req: &tg.AccountGetAuthorizationsRequest{}}, + {name: "account.getDefaultEmojiStatuses", req: &tg.AccountGetDefaultEmojiStatusesRequest{}}, + {name: "account.getCollectibleEmojiStatuses", req: &tg.AccountGetCollectibleEmojiStatusesRequest{}}, + {name: "account.getDefaultGroupPhotoEmojis", req: &tg.AccountGetDefaultGroupPhotoEmojisRequest{}}, + {name: "account.getConnectedBots", req: &tg.AccountGetConnectedBotsRequest{}}, + {name: "account.getReactionsNotifySettings", req: &tg.AccountGetReactionsNotifySettingsRequest{}}, + {name: "account.getContactSignUpNotification", req: &tg.AccountGetContactSignUpNotificationRequest{}}, + {name: "account.getThemes", req: &tg.AccountGetThemesRequest{Format: "tdesktop"}}, + {name: "account.getContentSettings", req: &tg.AccountGetContentSettingsRequest{}}, + {name: "account.getGlobalPrivacySettings", req: &tg.AccountGetGlobalPrivacySettingsRequest{}}, + {name: "account.getPasskeys", req: &tg.AccountGetPasskeysRequest{}}, + {name: "account.getSavedMusicIds", req: &tg.AccountGetSavedMusicIDsRequest{}}, + {name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}}, + {name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}}, + {name: "users.getFullUser", req: &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}}}, + {name: "users.getSavedMusic", req: &tg.UsersGetSavedMusicRequest{ID: &tg.InputUserSelf{}, Limit: 20}}, + {name: "users.getSavedMusicByID", req: &tg.UsersGetSavedMusicByIDRequest{ID: &tg.InputUserSelf{}, Documents: []tg.InputDocumentClass{}}}, + {name: "messages.getDialogFilters", req: &tg.MessagesGetDialogFiltersRequest{}}, + {name: "messages.getDialogs", req: &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}}, + {name: "messages.getPinnedDialogs", req: &tg.MessagesGetPinnedDialogsRequest{}}, + {name: "messages.getPeerDialogs", req: &tg.MessagesGetPeerDialogsRequest{Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}}}, + {name: "messages.getAvailableReactions", req: &tg.MessagesGetAvailableReactionsRequest{}}, + {name: "messages.getAvailableEffects", req: &tg.MessagesGetAvailableEffectsRequest{}}, + {name: "messages.getStickers", req: &tg.MessagesGetStickersRequest{}}, + {name: "messages.getStickerSet", req: &tg.MessagesGetStickerSetRequest{Stickerset: &tg.InputStickerSetEmpty{}}}, + {name: "messages.getEmojiGroups", req: &tg.MessagesGetEmojiGroupsRequest{}}, + {name: "messages.getEmojiStickerGroups", req: &tg.MessagesGetEmojiStickerGroupsRequest{}}, + {name: "messages.getEmojiProfilePhotoGroups", req: &tg.MessagesGetEmojiProfilePhotoGroupsRequest{}}, + {name: "messages.getEmojiKeywordsLanguages", req: &tg.MessagesGetEmojiKeywordsLanguagesRequest{LangCodes: []string{"en"}}}, + {name: "messages.getAttachMenuBots", req: &tg.MessagesGetAttachMenuBotsRequest{}}, + {name: "messages.getQuickReplies", req: &tg.MessagesGetQuickRepliesRequest{}}, + {name: "messages.getSavedHistory", req: &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, + {name: "messages.readSavedHistory", req: &tg.MessagesReadSavedHistoryRequest{ParentPeer: &tg.InputPeerChannel{ChannelID: 1, AccessHash: 1}, Peer: &tg.InputPeerSelf{}}}, + {name: "messages.deleteSavedHistory", req: &tg.MessagesDeleteSavedHistoryRequest{Peer: &tg.InputPeerSelf{}}}, + {name: "messages.getPeerSettings", req: &tg.MessagesGetPeerSettingsRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, + {name: "messages.getHistory", req: &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Limit: 20}}, + {name: "messages.readHistory", req: &tg.MessagesReadHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, + {name: "messages.search", req: &tg.MessagesSearchRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Filter: &tg.InputMessagesFilterEmpty{}, Limit: 20}}, + {name: "messages.searchGlobal", req: &tg.MessagesSearchGlobalRequest{Q: "login", Filter: &tg.InputMessagesFilterEmpty{}, OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}}, + {name: "messages.getWebPage", req: &tg.MessagesGetWebPageRequest{URL: "https://example.invalid"}}, + {name: "messages.getScheduledHistory", req: &tg.MessagesGetScheduledHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, + {name: "contacts.getContacts", req: &tg.ContactsGetContactsRequest{}}, + {name: "contacts.search", req: &tg.ContactsSearchRequest{Q: "Test", Limit: 20}}, + {name: "contacts.getBlocked", req: &tg.ContactsGetBlockedRequest{Limit: 20}}, + {name: "contacts.getTopPeers", req: &tg.ContactsGetTopPeersRequest{Correspondents: true, Limit: 10}}, + {name: "contacts.getSponsoredPeers", req: &tg.ContactsGetSponsoredPeersRequest{Q: "Test"}}, + {name: "stories.getAllStories", req: &tg.StoriesGetAllStoriesRequest{}}, + {name: "stories.getStoriesArchive", req: &tg.StoriesGetStoriesArchiveRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, + {name: "stories.getPinnedStories", req: &tg.StoriesGetPinnedStoriesRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, + {name: "stories.getAlbums", req: &tg.StoriesGetAlbumsRequest{Peer: &tg.InputPeerSelf{}}}, + {name: "payments.getStarGiftActiveAuctions", req: &tg.PaymentsGetStarGiftActiveAuctionsRequest{}}, + {name: "payments.getSavedStarGifts", req: &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, + {name: "payments.getSavedStarGift", req: &tg.PaymentsGetSavedStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{}}}, + {name: "aicompose.getTones", req: &tg.AicomposeGetTonesRequest{}}, + {name: "langpack.getLangPack", req: &tg.LangpackGetLangPackRequest{LangPack: "tdesktop", LangCode: "en"}}, + {name: "langpack.getDifference", req: &tg.LangpackGetDifferenceRequest{LangPack: "tdesktop", LangCode: "en", FromVersion: 1}}, + {name: "langpack.getStrings", req: &tg.LangpackGetStringsRequest{LangPack: "tdesktop", LangCode: "en", Keys: []string{"lng_intro_about"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in bin.Buffer + if err := tt.req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + ctx := WithUserID(context.Background(), 1000000001) + enc, err := r.Dispatch(ctx, [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + var out bin.Buffer + if err := enc.Encode(&out); err != nil { + t.Fatalf("encode response: %v", err) + } + if out.Len() == 0 { + t.Fatal("encoded response is empty") + } + }) + } +} + +func TestContactsSearchFindsUsers(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Searchable", LastName: "Friend", Username: "search_friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(memory.NewContactStore(), users), + }, zaptest.NewLogger(t), clock.System) + + var in bin.Buffer + if err := (&tg.ContactsSearchRequest{Q: "@search", Limit: 20}).Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.ContactsFound) + if !ok { + t.Fatalf("result type = %T, want *tg.ContactsFound", enc) + } + if len(box.Results) != 1 || len(box.Users) != 1 { + t.Fatalf("search result sizes = results %d users %d, want 1/1", len(box.Results), len(box.Users)) + } + peer, ok := box.Results[0].(*tg.PeerUser) + if !ok || peer.UserID != friend.ID { + t.Fatalf("peer = %T %+v, want friend", box.Results[0], box.Results[0]) + } +} + +func TestContactsSearchFindsPublicChannels(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000011", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550000012", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + stranger, err := userStore.Create(ctx, domain.User{AccessHash: 13, Phone: "15550000013", FirstName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "CU Public RPC", + Megagroup: true, + MemberUserIDs: []int64{viewer.ID}, + Date: 1700000012, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + public, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: "cu_public_rpc", + }) + if err != nil { + t.Fatalf("set channel username: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Contacts: appcontacts.NewService(memory.NewContactStore(), userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + var in bin.Buffer + if err := (&tg.ContactsSearchRequest{Q: "CU Public", Limit: 20}).Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.ContactsFound) + if !ok { + t.Fatalf("result type = %T, want *tg.ContactsFound", enc) + } + if len(box.MyResults) != 1 || len(box.Chats) != 1 { + t.Fatalf("search result sizes = my %d chats %d, want 1/1", len(box.MyResults), len(box.Chats)) + } + peer, ok := box.MyResults[0].(*tg.PeerChannel) + if !ok || peer.ChannelID != public.ID { + t.Fatalf("peer = %T %+v, want public channel", box.MyResults[0], box.MyResults[0]) + } + chat, ok := box.Chats[0].(*tg.Channel) + if !ok || chat.ID != public.ID || chat.Username != "cu_public_rpc" { + t.Fatalf("chat = %T %+v, want public channel chat", box.Chats[0], box.Chats[0]) + } + if chat.Left { + t.Fatalf("member search chat left = true, want active member channel") + } + + var strangerIn bin.Buffer + if err := (&tg.ContactsSearchRequest{Q: "CU Public", Limit: 20}).Encode(&strangerIn); err != nil { + t.Fatalf("encode stranger request: %v", err) + } + strangerEnc, err := r.Dispatch(WithUserID(ctx, stranger.ID), [8]byte{}, 0, &strangerIn) + if err != nil { + t.Fatalf("dispatch stranger: %v", err) + } + strangerBox, ok := strangerEnc.(*tg.ContactsFound) + if !ok { + t.Fatalf("stranger result type = %T, want *tg.ContactsFound", strangerEnc) + } + if len(strangerBox.Results) != 1 || len(strangerBox.Chats) != 1 { + t.Fatalf("stranger search sizes = results %d chats %d, want 1/1", len(strangerBox.Results), len(strangerBox.Chats)) + } + strangerPeer, ok := strangerBox.Results[0].(*tg.PeerChannel) + if !ok || strangerPeer.ChannelID != public.ID { + t.Fatalf("stranger peer = %T %+v, want public channel", strangerBox.Results[0], strangerBox.Results[0]) + } + strangerChat, ok := strangerBox.Chats[0].(*tg.Channel) + if !ok || !strangerChat.Left || strangerChat.ID != public.ID { + t.Fatalf("stranger chat = %T %+v, want left public channel", strangerBox.Chats[0], strangerBox.Chats[0]) + } + + resolved, err := r.onContactsResolveUsername(WithUserID(ctx, viewer.ID), &tg.ContactsResolveUsernameRequest{Username: "@CU_PUBLIC_RPC"}) + if err != nil { + t.Fatalf("resolve username: %v", err) + } + resolvedPeer, ok := resolved.Peer.(*tg.PeerChannel) + if !ok || resolvedPeer.ChannelID != public.ID || len(resolved.Chats) != 1 { + t.Fatalf("resolved channel = %+v, want peer + chat", resolved) + } +} + +func TestUsernameRPCLifecycle(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) + reqCtx := WithUserID(ctx, owner.ID) + + ok, err := r.onAccountCheckUsername(reqCtx, "taken_name") + if err != nil || ok { + t.Fatalf("check occupied = ok %v err %v, want false/nil", ok, err) + } + user, err := r.onAccountUpdateUsername(reqCtx, "owner_name") + if err != nil { + t.Fatalf("update username: %v", err) + } + self, ok := user.(*tg.User) + if !ok || self.Username != "owner_name" || len(self.Usernames) != 1 || !self.Usernames[0].Active { + t.Fatalf("updated user = %T %+v, want self with active username", user, user) + } + + resolved, err := r.onContactsResolveUsername(reqCtx, &tg.ContactsResolveUsernameRequest{Username: "@OWNER_NAME"}) + if err != nil { + t.Fatalf("resolve username: %v", err) + } + peer, ok := resolved.Peer.(*tg.PeerUser) + if !ok || peer.UserID != owner.ID || len(resolved.Users) != 1 { + t.Fatalf("resolved username = %+v, want owner peer", resolved) + } + + resolvedPhone, err := r.onContactsResolvePhone(reqCtx, "+15550000002") + if err != nil { + t.Fatalf("resolve phone: %v", err) + } + phonePeer, ok := resolvedPhone.Peer.(*tg.PeerUser) + if !ok || phonePeer.UserID != other.ID { + t.Fatalf("resolved phone peer = %+v, want other", resolvedPhone.Peer) + } +} + +func TestAccountUpdateProfileRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) + req := &tg.AccountUpdateProfileRequest{} + req.SetFirstName("Updated") + req.SetLastName("User") + req.SetAbout("profile bio") + + user, err := r.onAccountUpdateProfile(WithUserID(ctx, owner.ID), req) + if err != nil { + t.Fatalf("update profile: %v", err) + } + self, ok := user.(*tg.User) + if !ok || self.FirstName != "Updated" || self.LastName != "User" { + t.Fatalf("updated user = %T %+v, want updated self user", user, user) + } + full, err := r.onUsersGetFullUser(WithUserID(ctx, owner.ID), &tg.InputUserSelf{}) + if err != nil { + t.Fatalf("get full user: %v", err) + } + if full.FullUser.About != "profile bio" { + t.Fatalf("full about = %q, want profile bio", full.FullUser.About) + } +} + +func TestUsersSavedMusicStubsValidateInput(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550000002", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) + reqCtx := WithUserID(ctx, owner.ID) + + got, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ + ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Offset: 0, + Limit: 20, + }) + if err != nil { + t.Fatalf("users.getSavedMusic: %v", err) + } + music, ok := got.(*tg.UsersSavedMusic) + if !ok || music.Count != 0 || len(music.Documents) != 0 { + t.Fatalf("saved music = %T %+v, want empty users.savedMusic", got, got) + } + if _, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ + ID: &tg.InputUserSelf{}, + Limit: maxSavedMusicLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("large saved music limit err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ + ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash + 1}, + Limit: 20, + }); err == nil || !strings.Contains(err.Error(), "USER_ID_INVALID") { + t.Fatalf("bad saved music user err = %v, want USER_ID_INVALID", err) + } +} + +func TestDialogFilterFromGetDialogsRequestUsesAllParameters(t *testing.T) { + r := New(Config{}, Deps{ + Users: staticUsersService{user: domain.User{ID: 1000000001, AccessHash: 42}}, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + ExcludePinned: true, + OffsetDate: 1700000000, + OffsetID: 22, + OffsetPeer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + Limit: 999, + Hash: 123456, + } + req.SetFolderID(7) + + filter, err := r.dialogFilterFromRequest(context.Background(), 1000000001, req) + if err != nil { + t.Fatalf("dialogFilterFromRequest: %v", err) + } + if !filter.ExcludePinned || !filter.HasFolderID || filter.FolderID != 7 { + t.Fatalf("folder/pinned filter = %+v", filter) + } + if filter.OffsetDate != req.OffsetDate || filter.OffsetID != req.OffsetID || !filter.HasOffsetPeer || filter.OffsetPeer.ID != domain.OfficialSystemUserID { + t.Fatalf("offset filter = %+v", filter) + } + if filter.Limit != 500 || filter.Hash != req.Hash { + t.Fatalf("limit/hash filter = %+v", filter) + } +} + +func TestMessagesGetDialogsReturnsNotModifiedFromFullListHash(t *testing.T) { + dialogs := &captureDialogs{list: domain.DialogList{Count: 3, Hash: 77}} + r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + Hash: 77, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesDialogsBox", enc) + } + got, ok := box.Dialogs.(*tg.MessagesDialogsNotModified) + if !ok { + t.Fatalf("boxed response = %T, want *tg.MessagesDialogsNotModified", box.Dialogs) + } + if got.Count != 3 || dialogs.filter.Hash != 77 { + t.Fatalf("not modified = %+v filter %+v, want count/hash from service", got, dialogs.filter) + } +} + +func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550001002", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "E2E Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) != 1 { + t.Fatalf("updates = %T %+v, want one chat", invited.Updates, invited.Updates) + } + channel, ok := updates.Chats[0].(*tg.Channel) + if !ok || !channel.Megagroup || channel.Broadcast { + t.Fatalf("chat = %#v, want megagroup channel", updates.Chats[0]) + } + if len(updates.Updates) != 2 { + t.Fatalf("updates len = %d, want create + invite service messages", len(updates.Updates)) + } + newMsg, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage) + if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 { + t.Fatalf("create update = %#v, want channel pts=1", updates.Updates[0]) + } + service, ok := newMsg.Message.(*tg.MessageService) + if !ok { + t.Fatalf("create message = %T, want service", newMsg.Message) + } + if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok { + t.Fatalf("service action = %T, want channel create", service.Action) + } + inviteMsg, ok := updates.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok || inviteMsg.Pts != 2 || inviteMsg.PtsCount != 1 { + t.Fatalf("invite update = %#v, want channel pts=2", updates.Updates[1]) + } + inviteService, ok := inviteMsg.Message.(*tg.MessageService) + if !ok { + t.Fatalf("invite message = %T, want service", inviteMsg.Message) + } + addUser, ok := inviteService.Action.(*tg.MessageActionChatAddUser) + if !ok || len(addUser.Users) != 1 || addUser.Users[0] != friend.ID { + t.Fatalf("invite action = %#v, want add friend %d", inviteService.Action, friend.ID) + } + if len(updates.Users) != 2 { + t.Fatalf("updates users len = %d, want owner + friend", len(updates.Users)) + } + + participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get participants: %v", err) + } + participantList, ok := participants.(*tg.ChannelsChannelParticipants) + if !ok || participantList.Count != 2 || len(participantList.Participants) != 2 || len(participantList.Users) != 2 { + t.Fatalf("participants = %T %+v, want owner + friend participants/users", participants, participants) + } + + req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20} + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch get dialogs: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("dialogs response = %T, want box", enc) + } + dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) + if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Chats) != 1 || len(dialogs.Messages) != 1 { + t.Fatalf("dialogs = %T %+v, want channel dialog/chat/message", box.Dialogs, box.Dialogs) + } + dialog := dialogs.Dialogs[0].(*tg.Dialog) + if peer, ok := dialog.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("dialog peer = %#v, want channel %d", dialog.Peer, channel.ID) + } +} + +func TestMessagesCreateChatRejectsEmptyInviteListRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550001021", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(memory.NewChannelStore()), + }, zaptest.NewLogger(t), clock.System) + + if _, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Title: "No Invitees", + }); err == nil || !strings.Contains(err.Error(), "USERS_TOO_FEW") { + t.Fatalf("create chat without users err = %v, want USERS_TOO_FEW", err) + } +} + +func TestMessagesCreateChatTDesktopReturnsLegacyChatAndAcceptsInputPeerChatRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + tdCtx := WithClientInfo(WithUserID(ctx, owner.ID), ClientInfo{ + DeviceModel: "Desktop", + AppVersion: "6.8.4 x64", + LangPack: "tdesktop", + }) + + invited, err := r.onMessagesCreateChat(tdCtx, &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "TDesktop Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) != 2 { + t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) + } + legacy, ok := updates.Chats[0].(*tg.Chat) + if !ok || !legacy.Deactivated { + t.Fatalf("legacy chat = %#v, want migrated chat", updates.Chats[0]) + } + channel, ok := updates.Chats[1].(*tg.Channel) + if !ok || !channel.Megagroup || channel.Broadcast { + t.Fatalf("channel = %#v, want megagroup channel", updates.Chats[1]) + } + if !channel.Creator { + t.Fatalf("channel creator flag = false, want true for creator") + } + if rights, ok := channel.GetAdminRights(); !ok || !rights.ChangeInfo || !rights.InviteUsers { + t.Fatalf("channel admin rights = %+v ok=%v, want creator manage rights", rights, ok) + } + migrated, ok := legacy.GetMigratedTo() + if !ok { + t.Fatalf("legacy chat missing migrated_to") + } + migratedTo, ok := migrated.(*tg.InputChannel) + if !ok || migratedTo.ChannelID != channel.ID || migratedTo.AccessHash != channel.AccessHash { + t.Fatalf("migrated_to = %#v, want channel %d/%d", migrated, channel.ID, channel.AccessHash) + } + + participants, err := r.onChannelsGetParticipants(tdCtx, &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get participants after create chat: %v", err) + } + participantList, ok := participants.(*tg.ChannelsChannelParticipants) + if !ok || len(participantList.Chats) != 0 { + t.Fatalf("participants = %T %+v, want no chat side vector", participants, participants) + } + if participantList.Count != 2 || len(participantList.Participants) != 2 || len(participantList.Users) != 2 { + t.Fatalf("participants count/rows/users = %d/%d/%d, want owner + friend", + participantList.Count, len(participantList.Participants), len(participantList.Users)) + } + legacyHistoryReq := &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + Limit: 20, + } + var legacyHistoryBuf bin.Buffer + if err := legacyHistoryReq.Encode(&legacyHistoryBuf); err != nil { + t.Fatalf("encode legacy history: %v", err) + } + legacyHistory, err := r.Dispatch(tdCtx, [8]byte{}, 0, &legacyHistoryBuf) + if err != nil { + t.Fatalf("legacy history: %v", err) + } + legacyBox, ok := legacyHistory.(*tg.MessagesMessagesBox) + if !ok { + t.Fatalf("legacy history = %T %+v, want messages box", legacyHistory, legacyHistory) + } + legacyMessages, ok := legacyBox.Messages.(*tg.MessagesMessages) + if !ok || len(legacyMessages.Messages) != 0 { + t.Fatalf("legacy history = %T %+v, want empty messages.messages", legacyHistory, legacyHistory) + } + + sent, err := r.onMessagesSendMessage(tdCtx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + RandomID: 99, + Message: "via legacy input peer", + }) + if err != nil { + t.Fatalf("send via inputPeerChat: %v", err) + } + sentUpdates, ok := sent.(*tg.Updates) + if !ok || len(sentUpdates.Updates) < 2 { + t.Fatalf("send updates = %T %+v, want channel message updates", sent, sent) + } + newMsg, ok := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok { + t.Fatalf("send update = %#v, want updateNewChannelMessage", sentUpdates.Updates[1]) + } + msg, ok := newMsg.Message.(*tg.Message) + if !ok || msg.Message != "via legacy input peer" { + t.Fatalf("sent message = %#v, want text channel message", newMsg.Message) + } + if peer, ok := msg.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("sent peer = %#v, want peerChannel %d", msg.PeerID, channel.ID) + } +} + +func TestMessagesCreateChatDispatchRemembersTDesktopClientInfo(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550001041", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550001042", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + sessions := &captureSessions{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + rawAuthKeyID := [8]byte{0x42, 0x01} + sessionID := int64(77) + initReq := &tg.InvokeWithLayerRequest{ + Layer: 225, + Query: &tg.InitConnectionRequest{ + APIID: 111111, + DeviceModel: "Desktop", + AppVersion: "6.8.4 x64", + SystemLangCode: "en", + LangPack: "tdesktop", + LangCode: "en", + Query: &tg.HelpGetConfigRequest{}, + }, + } + var initBuf bin.Buffer + if err := initReq.Encode(&initBuf); err != nil { + t.Fatalf("encode init: %v", err) + } + if _, err := r.Dispatch(WithUserID(ctx, owner.ID), rawAuthKeyID, sessionID, &initBuf); err != nil { + t.Fatalf("dispatch init: %v", err) + } + + createReq := &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Dispatch TDesktop Group", + } + var createBuf bin.Buffer + if err := createReq.Encode(&createBuf); err != nil { + t.Fatalf("encode create chat: %v", err) + } + enc, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &createBuf) + if err != nil { + t.Fatalf("dispatch create chat: %v", err) + } + invited, ok := enc.(*tg.MessagesInvitedUsers) + if !ok { + t.Fatalf("create response = %T, want messages.invitedUsers", enc) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) != 2 { + t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) + } + if legacy, ok := updates.Chats[0].(*tg.Chat); !ok || !legacy.Deactivated { + t.Fatalf("first chat = %#v, want migrated legacy chat", updates.Chats[0]) + } + channel, ok := updates.Chats[1].(*tg.Channel) + if !ok || !channel.Megagroup || !channel.Creator { + t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1]) + } + if len(updates.Updates) != 2 { + t.Fatalf("updates len = %d, want create + invite service messages", len(updates.Updates)) + } + participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get participants: %v", err) + } + list, ok := participants.(*tg.ChannelsChannelParticipants) + if !ok || list.Count != 2 || len(list.Participants) != 2 || len(list.Users) != 2 { + t.Fatalf("participants = %T %+v, want owner + friend", participants, participants) + } + sessions.mu.Lock() + pushUserIDs := append([]int64(nil), sessions.pushUserIDs...) + sessions.mu.Unlock() + if len(pushUserIDs) != 1 || pushUserIDs[0] != friend.ID { + t.Fatalf("push user ids = %v, want only invited friend %d", pushUserIDs, friend.ID) + } +} + +func TestMessagesCreateChatSessionWithoutClientInfoReturnsLegacyChat(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001051", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550001052", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + invited, err := r.onMessagesCreateChat(WithSessionID(WithUserID(ctx, owner.ID), 99), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Session Legacy Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) != 2 { + t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) + } + if legacy, ok := updates.Chats[0].(*tg.Chat); !ok || !legacy.Deactivated { + t.Fatalf("first chat = %#v, want migrated legacy chat", updates.Chats[0]) + } + if channel, ok := updates.Chats[1].(*tg.Channel); !ok || !channel.Megagroup || !channel.Creator { + t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1]) + } +} + +func TestChannelsGetInactiveChannelsReturnsLeastActiveRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550001041", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + createAndSend := func(title string, createDate, msgDate int) int64 { + t.Helper() + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: title, + Megagroup: true, + Date: createDate, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + if _, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: int64(msgDate), + Message: title + " message", + Date: msgDate, + }); err != nil { + t.Fatalf("send %s: %v", title, err) + } + return created.Channel.ID + } + + oldID := createAndSend("Old inactive", 1000, 1100) + midID := createAndSend("Middle inactive", 1000, 1200) + newID := createAndSend("New inactive", 1000, 1300) + leftID := createAndSend("Left inactive", 1000, 900) + if _, err := channelSvc.LeaveChannel(ctx, owner.ID, leftID, 1400); err != nil { + t.Fatalf("leave channel: %v", err) + } + + got, err := r.onChannelsGetInactiveChannels(WithUserID(ctx, owner.ID)) + if err != nil { + t.Fatalf("get inactive channels: %v", err) + } + if len(got.Dates) != 3 || len(got.Chats) != 3 || len(got.Users) != 0 { + t.Fatalf("inactive chats = %+v, want three active channel chats and no users", got) + } + wantIDs := []int64{oldID, midID, newID} + wantDates := []int{1100, 1200, 1300} + for i, chat := range got.Chats { + channel, ok := chat.(*tg.Channel) + if !ok { + t.Fatalf("chat %d = %T, want *tg.Channel", i, chat) + } + if channel.ID != wantIDs[i] || got.Dates[i] != wantDates[i] { + t.Fatalf("inactive item %d = id %d date %d, want id %d date %d", i, channel.ID, got.Dates[i], wantIDs[i], wantDates[i]) + } + } +} + +func TestChannelsGetChannelRecommendationsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001051", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550001052", FirstName: "Other"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + createPublicBroadcast := func(creator domain.User, title, username string, date int) domain.Channel { + t.Helper() + created, err := channelSvc.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + Title: title, + Broadcast: true, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channel, err := channelSvc.UpdateUsername(ctx, creator.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: username, + }) + if err != nil { + t.Fatalf("set username for %s: %v", title, err) + } + return channel + } + + source := createPublicBroadcast(owner, "Source Recommendations", "source_recs", 1000) + for i := 0; i < 12; i++ { + createPublicBroadcast(owner, "Candidate "+strconv.Itoa(i), "rec"+strconv.Itoa(i)+"public", 2000+i) + } + groupCreated, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Public Group", + Megagroup: true, + Date: 3000, + }) + if err != nil { + t.Fatalf("create public group: %v", err) + } + group, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: groupCreated.Channel.ID, + Username: "group_recs", + }) + if err != nil { + t.Fatalf("set group username: %v", err) + } + + recommendationsReq := func(channel domain.Channel) *tg.ChannelsGetChannelRecommendationsRequest { + req := &tg.ChannelsGetChannelRecommendationsRequest{} + req.SetChannel(&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + return req + } + + got, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq(source)) + if err != nil { + t.Fatalf("get recommendations by source: %v", err) + } + slice, ok := got.(*tg.MessagesChatsSlice) + if !ok { + t.Fatalf("recommendations = %T %+v, want messages.chatsSlice", got, got) + } + if slice.Count != 12 || len(slice.Chats) != domain.DefaultChannelRecommendationsLimit { + t.Fatalf("recommendations count=%d len=%d, want count 12 len %d", slice.Count, len(slice.Chats), domain.DefaultChannelRecommendationsLimit) + } + for _, chat := range slice.Chats { + channel, ok := chat.(*tg.Channel) + if !ok { + t.Fatalf("recommendation chat = %T, want channel", chat) + } + if channel.ID == source.ID || !channel.Broadcast || channel.Megagroup || channel.Username == "" { + t.Fatalf("recommendation channel = %+v, want public broadcast excluding source", channel) + } + } + + if _, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq(group)); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { + t.Fatalf("megagroup recommendations err = %v, want CHANNEL_INVALID", err) + } + + globalA := createPublicBroadcast(other, "Global A", "global_recs_a", 5000) + globalB := createPublicBroadcast(other, "Global B", "global_recs_b", 5100) + global, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), &tg.ChannelsGetChannelRecommendationsRequest{}) + if err != nil { + t.Fatalf("get global recommendations: %v", err) + } + box, ok := global.(*tg.MessagesChats) + if !ok { + t.Fatalf("global recommendations = %T %+v, want messages.chats", global, global) + } + if len(box.Chats) != 2 { + t.Fatalf("global recommendations len=%d chats=%+v, want two channels", len(box.Chats), box.Chats) + } + wantIDs := []int64{globalB.ID, globalA.ID} + for i, chat := range box.Chats { + channel, ok := chat.(*tg.Channel) + if !ok { + t.Fatalf("global chat %d = %T, want channel", i, chat) + } + if channel.ID != wantIDs[i] { + t.Fatalf("global chat %d id=%d, want %d", i, channel.ID, wantIDs[i]) + } + } +} + +func TestChannelsDeleteChannelReturnsForbiddenChatAndHidesDialogRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Delete Me", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := invited.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + getDialogs := func(userID int64) *tg.MessagesDialogs { + t.Helper() + req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20} + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch get dialogs: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("dialogs response = %T, want box", enc) + } + dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) + if !ok { + t.Fatalf("dialogs = %T %+v, want messages.dialogs", box.Dialogs, box.Dialogs) + } + return dialogs + } + if got := getDialogs(owner.ID); len(got.Dialogs) != 1 || len(got.Chats) != 1 { + t.Fatalf("dialogs before delete = %+v, want one channel dialog", got) + } + + deleted, err := r.onChannelsDeleteChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ + ChannelID: channel.ID, + AccessHash: channel.AccessHash, + }) + if err != nil { + t.Fatalf("delete channel: %v", err) + } + updates, ok := deleted.(*tg.Updates) + if !ok || len(updates.Updates) != 1 || len(updates.Chats) != 1 { + t.Fatalf("delete response = %T %+v, want updateChannel + channelForbidden", deleted, deleted) + } + if update, ok := updates.Updates[0].(*tg.UpdateChannel); !ok || update.ChannelID != channel.ID { + t.Fatalf("delete update = %#v, want updateChannel %d", updates.Updates[0], channel.ID) + } + forbidden, ok := updates.Chats[0].(*tg.ChannelForbidden) + if !ok || forbidden.ID != channel.ID || forbidden.AccessHash != channel.AccessHash || forbidden.Title != channel.Title || !forbidden.Megagroup { + t.Fatalf("delete chat = %#v, want channelForbidden tombstone", updates.Chats[0]) + } + + pushed := sessions.snapshot() + if pushed.messageType != proto.MessageFromServer || pushed.userID == 0 { + t.Fatalf("push snapshot = %+v, want server update to a channel member", pushed) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Chats) != 1 { + t.Fatalf("pushed update = %T %+v, want channelForbidden chat", pushed.message, pushed.message) + } + if pushedForbidden, ok := pushedUpdates.Chats[0].(*tg.ChannelForbidden); !ok || pushedForbidden.ID != channel.ID { + t.Fatalf("pushed chat = %#v, want channelForbidden %d", pushedUpdates.Chats[0], channel.ID) + } + if got := getDialogs(owner.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 { + t.Fatalf("owner dialogs after delete = %+v, want hidden deleted channel", got) + } + if got := getDialogs(friend.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 { + t.Fatalf("friend dialogs after delete = %+v, want hidden deleted channel", got) + } +} + +func TestChannelRealtimeRecipientsPreferOnlineMembers(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Fanout", + MemberUserIDs: []int64{1002, 1999}, + Date: 10, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sessions := &captureSessions{ + onlineUserIDs: []int64{1999, 3000}, + channelViewers: map[int64][]int64{created.Channel.ID: {1999}}, + channelMembers: map[int64][]int64{created.Channel.ID: {1999, 3000}}, + } + r := New(Config{}, Deps{ + Channels: channelService, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + contains := func(items []int64, want int64) bool { + for _, item := range items { + if item == want { + return true + } + } + return false + } + + got := r.channelFanoutRecipients(ctx, channelFanoutMembers, created.Channel.ID, []int64{1002}) + if !contains(got, 1999) { + t.Fatalf("recipients = %v, want online active member 1999", got) + } + if contains(got, 3000) { + t.Fatalf("recipients = %v, non-member online user leaked", got) + } + if !contains(got, 1002) { + t.Fatalf("recipients = %v, want explicit fallback recipient 1002", got) + } + onlines, err := r.onMessagesGetOnlines(WithUserID(ctx, 1001), &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }) + if err != nil { + t.Fatalf("messages.getOnlines: %v", err) + } + if onlines.Onlines != 2 { + t.Fatalf("messages.getOnlines = %d, want caller plus online active member", onlines.Onlines) + } +} + +func TestChannelInputAccessHashIsValidatedRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550001102", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Hash Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := invited.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + badHash := channel.AccessHash + 1 + if badHash == 0 { + badHash = 1 + } + + if _, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("get full bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + Message: "bad hash", + RandomID: 991, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("send bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesGetPeerSettings(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("peer settings bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), &tg.MessagesToggleDialogPinRequest{ + Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}}, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("toggle pin bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onFoldersEditPeerFolders(WithUserID(ctx, owner.ID), []tg.InputFolderPeer{{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + FolderID: domain.DialogArchiveFolderID, + }}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("edit peer folder bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: 0, + Limit: 10, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("difference bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + chats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{&tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}}) + if err != nil { + t.Fatalf("get channels bad access_hash: %v", err) + } + if got := len(chats.(*tg.MessagesChats).Chats); got != 0 { + t.Fatalf("get channels bad access_hash chats = %d, want 0", got) + } + if _, err := r.dialogFilterFromRequest(WithUserID(ctx, owner.ID), owner.ID, &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + Limit: 20, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("get dialogs offset bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + Message: "draft", + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("save draft bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + filterReq := &tg.MessagesUpdateDialogFilterRequest{ID: domain.DialogCustomFolderMinID} + filterReq.SetFilter(&tg.DialogFilter{ + ID: domain.DialogCustomFolderMinID, + Title: tg.TextWithEntities{Text: "Channels"}, + IncludePeers: []tg.InputPeerClass{ + &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + }, + }) + if ok, err := r.onMessagesUpdateDialogFilter(WithUserID(ctx, owner.ID), filterReq); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") || ok { + t.Fatalf("update dialog filter bad access_hash = %v, %v; want CHANNEL_PRIVATE", ok, err) + } + reply := &tg.InputReplyToMessage{ReplyToMsgID: 1} + reply.SetReplyToPeerID(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}) + sendReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "bad reply hash", + RandomID: 992, + } + sendReq.SetReplyTo(reply) + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), sendReq); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("send reply_to_peer bad access_hash err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } +} + +func TestChannelSendHistoryAndDifferenceRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + + var authKeyID [8]byte + authKeyID[0] = 9 + sendCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), authKeyID), 77) + sent, err := r.onMessagesSendMessage(sendCtx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "hello channel", + RandomID: 99, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + sendUpdates := sent.(*tg.Updates) + if id, ok := sendUpdates.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != 3 || id.RandomID != 99 { + t.Fatalf("message id update = %#v, want id=3 random_id=99", sendUpdates.Updates[0]) + } + newMsg, ok := sendUpdates.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok || newMsg.Pts != 3 || newMsg.PtsCount != 1 { + t.Fatalf("new channel update = %#v, want pts=3", sendUpdates.Updates[1]) + } + msg := newMsg.Message.(*tg.Message) + if msg.PeerID.(*tg.PeerChannel).ChannelID != channel.ID || msg.Message != "hello channel" || !msg.Out { + t.Fatalf("channel message = %#v, want outgoing channel text", msg) + } + pushed := sessions.snapshot() + if pushed.userID != friend.ID || pushed.sessionID != 77 || pushed.messageType != proto.MessageFromServer { + t.Fatalf("pushed channel update = user %d exclude session %d type %v, want friend/exclude/from_server", pushed.userID, pushed.sessionID, pushed.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { + t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Updates) != 1 { + t.Fatalf("pushed channel update = %T %+v, want one updates container without updateMessageID", pushed.message, pushed.message) + } + pushedNew, ok := pushedUpdates.Updates[0].(*tg.UpdateNewChannelMessage) + if !ok { + t.Fatalf("pushed update[0] = %T, want updateNewChannelMessage", pushedUpdates.Updates[0]) + } + pushedMsg := pushedNew.Message.(*tg.Message) + if pushedMsg.Out || pushedMsg.Message != "hello channel" { + t.Fatalf("pushed message = %#v, want incoming channel text for friend", pushedMsg) + } + + history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []tg.InputMessageClass{&tg.InputMessageID{ID: msg.ID}}, + }) + if err != nil { + t.Fatalf("get channel messages: %v", err) + } + messages := history.(*tg.MessagesMessages) + got := messages.Messages[0].(*tg.Message) + if got.Message != "hello channel" || got.Out { + t.Fatalf("history message = %#v, want incoming text for friend", got) + } + + contentAuthKeyID := [8]byte{0x44} + contentCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, friend.ID), contentAuthKeyID), 88) + if ok, err := r.onChannelsReadMessageContents(contentCtx, &tg.ChannelsReadMessageContentsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{msg.ID}, + }); err != nil || !ok { + t.Fatalf("channels.readMessageContents = ok %v err %v, want true", ok, err) + } + contentPush := sessions.snapshot() + if contentPush.userID != friend.ID || contentPush.sessionID != 88 || contentPush.messageType != proto.MessageFromServer { + t.Fatalf("content-read push = user %d exclude session %d type %v, want friend/exclude/from_server", contentPush.userID, contentPush.sessionID, contentPush.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != contentAuthKeyID { + t.Fatalf("content-read exclude auth_key_id = %x, want %x", gotAuthKeyID, contentAuthKeyID) + } + contentUpdates, ok := contentPush.message.(*tg.Updates) + if !ok || len(contentUpdates.Updates) != 1 { + t.Fatalf("content-read pushed message = %T %+v, want one update", contentPush.message, contentPush.message) + } + contentRead, ok := contentUpdates.Updates[0].(*tg.UpdateChannelReadMessagesContents) + if !ok || contentRead.ChannelID != channel.ID || len(contentRead.Messages) != 1 || contentRead.Messages[0] != msg.ID { + t.Fatalf("content-read update = %#v, want channel %d msg %d", contentUpdates.Updates[0], channel.ID, msg.ID) + } + + diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: newMsg.Pts - 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference: %v", err) + } + fullDiff, ok := diff.(*tg.UpdatesChannelDifference) + if !ok || fullDiff.Pts != newMsg.Pts || len(fullDiff.NewMessages) != 1 { + t.Fatalf("diff = %T %+v, want one new message at pts=%d", diff, diff, newMsg.Pts) + } + if _, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: fullDiff.Pts + 1, + Limit: 10, + }); err == nil || !strings.Contains(err.Error(), "PERSISTENT_TIMESTAMP_INVALID") { + t.Fatalf("future channel pts err = %v, want PERSISTENT_TIMESTAMP_INVALID", err) + } + + readOK, err := r.onChannelsReadHistory(WithUserID(ctx, friend.ID), &tg.ChannelsReadHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MaxID: msg.ID, + }) + if err != nil || !readOK { + t.Fatalf("channels.readHistory = %v err %v, want true", readOK, err) + } + readPush := sessions.snapshot() + if readPush.userID != owner.ID || readPush.messageType != proto.MessageFromServer { + t.Fatalf("read outbox push = user %d type %v, want owner/from_server", readPush.userID, readPush.messageType) + } + readPushUpdates, ok := readPush.message.(*tg.Updates) + if !ok || len(readPushUpdates.Updates) != 1 { + t.Fatalf("read outbox pushed message = %T %+v, want one update", readPush.message, readPush.message) + } + readOutbox, ok := readPushUpdates.Updates[0].(*tg.UpdateReadChannelOutbox) + if !ok || readOutbox.ChannelID != channel.ID || readOutbox.MaxID != msg.ID { + t.Fatalf("read outbox update = %#v, want channel %d max %d", readPushUpdates.Updates[0], channel.ID, msg.ID) + } + fullAfterRead, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after read: %v", err) + } + fullChannel := fullAfterRead.FullChat.(*tg.ChannelFull) + if fullChannel.ReadOutboxMaxID != msg.ID { + t.Fatalf("full channel read_outbox = %d, want %d", fullChannel.ReadOutboxMaxID, msg.ID) + } + readers, err := r.onMessagesGetMessageReadParticipants(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageReadParticipantsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: msg.ID, + }) + if err != nil { + t.Fatalf("get message read participants: %v", err) + } + if len(readers) != 1 || readers[0].UserID != friend.ID || readers[0].Date == 0 { + t.Fatalf("read participants = %+v, want friend read date", readers) + } + + editReq := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: msg.ID, + Message: "edited channel", + } + editReq.SetMessage("edited channel") + edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq) + if err != nil { + t.Fatalf("edit channel message: %v", err) + } + editUpdates := edited.(*tg.Updates) + edit, ok := editUpdates.Updates[0].(*tg.UpdateEditChannelMessage) + if !ok || edit.Pts != newMsg.Pts+1 || edit.PtsCount != 1 { + t.Fatalf("edit update = %#v, want updateEditChannelMessage pts=%d", editUpdates.Updates[0], newMsg.Pts+1) + } + if edit.Message.(*tg.Message).Message != "edited channel" { + t.Fatalf("edited message = %#v, want edited text", edit.Message) + } + editData, err := r.onMessagesGetMessageEditData(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageEditDataRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: msg.ID, + }) + if err != nil { + t.Fatalf("get channel edit data: %v", err) + } + if editData.GetCaption() { + t.Fatalf("channel edit data caption = true, want false for text-only message") + } + + forwardReplyTo := &tg.InputReplyToMessage{ReplyToMsgID: msg.ID} + forwardReplyTo.SetQuoteText("channel") + forwardReq := &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{msg.ID}, + RandomID: []int64{100}, + } + forwardReq.SetReplyTo(forwardReplyTo) + forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, friend.ID), forwardReq) + if err != nil { + t.Fatalf("forward channel message: %v", err) + } + forwardUpdates := forwarded.(*tg.Updates) + if id, ok := forwardUpdates.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != msg.ID+1 || id.RandomID != 100 { + t.Fatalf("forward id update = %#v, want id=%d", forwardUpdates.Updates[0], msg.ID+1) + } + forwardNew, ok := forwardUpdates.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok || forwardNew.Pts != edit.Pts+1 || forwardNew.PtsCount != 1 { + t.Fatalf("forward new update = %#v, want pts=%d", forwardUpdates.Updates[1], edit.Pts+1) + } + forwardMsg := forwardNew.Message.(*tg.Message) + if forwardMsg.Message != "edited channel" || forwardMsg.FwdFrom.FromID == nil { + t.Fatalf("forward message = %#v, want fwd header and edited body", forwardMsg) + } + if header, ok := forwardMsg.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != msg.ID { + t.Fatalf("forward reply header = %#v, want reply to channel message %d", forwardMsg.ReplyTo, msg.ID) + } + + deleted, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{msg.ID, forwardMsg.ID}, + }) + if err != nil { + t.Fatalf("delete channel messages: %v", err) + } + if deleted.Pts != forwardNew.Pts+2 || deleted.PtsCount != 2 { + t.Fatalf("delete affected = %+v, want pts=%d count=2", deleted, forwardNew.Pts+2) + } + + diff, err = r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: newMsg.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference after edit/delete: %v", err) + } + fullDiff, ok = diff.(*tg.UpdatesChannelDifference) + if !ok || fullDiff.Pts != deleted.Pts || len(fullDiff.NewMessages) != 1 || len(fullDiff.OtherUpdates) != 2 { + t.Fatalf("diff after edit/delete = %T %+v, want forward message plus edit/delete updates", diff, diff) + } +} + +func TestChannelsReadMessageContentsClearsUnreadReactionAndPushesUpdate(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Reaction Read", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "owner message", + RandomID: 21041, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + msgID := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message).ID + req := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: msgID, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f525"}}, + } + req.SetReaction(req.Reaction) + if _, err := r.onMessagesSendReaction(WithUserID(ctx, friend.ID), req); err != nil { + t.Fatalf("friend send reaction: %v", err) + } + unread, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get unread reactions: %v", err) + } + unreadMessages, _, _ := searchMessagesPayload(t, unread) + if len(unreadMessages) != 1 { + t.Fatalf("unread reactions = %+v, want one message", unread) + } + + contentAuthKeyID := [8]byte{0x66} + contentCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), contentAuthKeyID), 99) + if ok, err := r.onChannelsReadMessageContents(contentCtx, &tg.ChannelsReadMessageContentsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{msgID}, + }); err != nil || !ok { + t.Fatalf("channels.readMessageContents = ok %v err %v, want true", ok, err) + } + pushed := sessions.snapshot() + if pushed.userID != owner.ID || pushed.sessionID != 99 || pushed.messageType != proto.MessageFromServer { + t.Fatalf("reaction read push = user %d session %d type %v, want owner/exclude/from_server", pushed.userID, pushed.sessionID, pushed.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != contentAuthKeyID { + t.Fatalf("reaction read exclude auth_key_id = %x, want %x", gotAuthKeyID, contentAuthKeyID) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Updates) != 1 { + t.Fatalf("reaction read push = %T %+v, want one updateMessageReactions", pushed.message, pushed.message) + } + reactionUpdate, ok := pushedUpdates.Updates[0].(*tg.UpdateMessageReactions) + if !ok || reactionUpdate.MsgID != msgID { + t.Fatalf("reaction read update = %#v, want updateMessageReactions for %d", pushedUpdates.Updates[0], msgID) + } + for _, recent := range reactionUpdate.Reactions.RecentReactions { + if recent.Unread { + t.Fatalf("reaction read update recent = %+v, want unread cleared", reactionUpdate.Reactions.RecentReactions) + } + } + unreadAfter, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get unread reactions after read contents: %v", err) + } + unreadAfterMessages, _, _ := searchMessagesPayload(t, unreadAfter) + if len(unreadAfterMessages) != 0 { + t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter) + } +} + +func TestChannelReadHistoryProducesReadChannelInboxDifference(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002131", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002132", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + updates := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Updates: updates, + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Read Channel Inbox", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "read me", + RandomID: 301, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + readOK, err := r.onChannelsReadHistory(WithUserID(ctx, friend.ID), &tg.ChannelsReadHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MaxID: msg.ID, + }) + if err != nil { + t.Fatalf("channels.readHistory: %v", err) + } + if !readOK { + t.Fatalf("channels.readHistory = false, want true") + } + diff, err := r.onUpdatesGetDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("updates.getDifference: %v", err) + } + full, ok := diff.(*tg.UpdatesDifference) + if !ok || len(full.OtherUpdates) != 1 { + t.Fatalf("difference = %T %+v, want one read channel inbox update", diff, diff) + } + read, ok := full.OtherUpdates[0].(*tg.UpdateReadChannelInbox) + if !ok || read.ChannelID != channel.ID || read.MaxID != msg.ID || read.StillUnreadCount != 0 { + t.Fatalf("difference update = %#v, want updateReadChannelInbox channel %d max %d", full.OtherUpdates[0], channel.ID, msg.ID) + } + if len(full.Chats) != 1 { + t.Fatalf("difference chats = %d, want channel context", len(full.Chats)) + } +} + +func TestChannelGetMessagesReturnsSparseIDs(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002101", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002102", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Sparse RPC Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + var firstID, lastID int + for i := 0; i < 5; i++ { + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "sparse-" + strconv.Itoa(i), + RandomID: int64(100 + i), + }) + if err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + msg := updates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if i == 0 { + firstID = msg.ID + } + lastID = msg.ID + } + + got, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []tg.InputMessageClass{ + &tg.InputMessageID{ID: firstID}, + &tg.InputMessageID{ID: lastID}, + }, + }) + if err != nil { + t.Fatalf("get sparse channel messages: %v", err) + } + messages := got.(*tg.MessagesMessages).Messages + if len(messages) != 2 { + t.Fatalf("messages len = %d, want 2", len(messages)) + } + first := messages[0].(*tg.Message) + last := messages[1].(*tg.Message) + if first.ID != firstID || first.Message != "sparse-0" || last.ID != lastID || last.Message != "sparse-4" { + t.Fatalf("sparse messages = %#v %#v, want first and last exact ids", first, last) + } +} + +func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Updates: updateSvc, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Clear Channel", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "clear me", + RandomID: 401, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MaxID: msg.ID, + }) + if err != nil { + t.Fatalf("delete channel history local: %v", err) + } + updates, ok := cleared.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("clear response = %T %+v, want one update", cleared, cleared) + } + available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages) + if !ok || available.ChannelID != channel.ID || available.AvailableMinID != msg.ID { + t.Fatalf("clear update = %#v, want updateChannelAvailableMessages channel=%d min=%d", updates.Updates[0], channel.ID, msg.ID) + } + pushed := sessions.snapshot() + if pushed.userID != owner.ID { + t.Fatalf("pushed user = %d, want owner %d", pushed.userID, owner.ID) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Updates) != 1 { + t.Fatalf("pushed clear update = %T %+v, want one update", pushed.message, pushed.message) + } + if _, ok := pushedUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages); !ok { + t.Fatalf("pushed update[0] = %T, want updateChannelAvailableMessages", pushedUpdates.Updates[0]) + } + diff, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference: %v", err) + } + full, ok := diff.(*tg.UpdatesDifference) + if !ok || len(full.OtherUpdates) != 1 { + t.Fatalf("difference = %T %+v, want one other update", diff, diff) + } + if diffUpdate, ok := full.OtherUpdates[0].(*tg.UpdateChannelAvailableMessages); !ok || diffUpdate.ChannelID != channel.ID || diffUpdate.AvailableMinID != msg.ID { + t.Fatalf("difference update = %#v, want updateChannelAvailableMessages", full.OtherUpdates[0]) + } + + stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MaxID: msg.ID - 1, + }) + if err != nil { + t.Fatalf("stale delete channel history local: %v", err) + } + staleUpdates, ok := stale.(*tg.Updates) + if !ok || len(staleUpdates.Updates) != 1 { + t.Fatalf("stale clear response = %T %+v, want one update", stale, stale) + } + staleAvailable, ok := staleUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages) + if !ok || staleAvailable.ChannelID != channel.ID || staleAvailable.AvailableMinID != msg.ID { + t.Fatalf("stale clear update = %#v, want monotonic updateChannelAvailableMessages channel=%d min=%d", staleUpdates.Updates[0], channel.ID, msg.ID) + } +} + +func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 43, Phone: "15550002143", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 44, Phone: "15550002144", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Invalid Delete IDs", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + for _, maxID := range []int{-1, domain.MaxMessageBoxID + 1} { + if _, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{Channel: input, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("channels.deleteHistory max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err) + } + if _, err := r.onMessagesDeleteHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteHistoryRequest{Peer: peer, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.deleteHistory channel max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err) + } + } + if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: []int{domain.MaxMessageBoxID + 1}}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("channels.deleteMessages huge id err = %v, want MESSAGE_ID_INVALID", err) + } + tooMany := make([]int, domain.MaxDeleteMessageIDs+1) + for i := range tooMany { + tooMany[i] = i + 1 + } + if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: tooMany}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.deleteMessages too many ids err = %v, want LIMIT_INVALID", err) + } +} + +func TestMessagesDeleteHistoryChannelReturnsOffsetForBoundedPage(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 45, Phone: "15550002145", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 46, Phone: "15550002146", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Delete History Offset", + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1_700_002_145, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + total := domain.MaxDeleteHistoryBatch + 2 + for i := 0; i < total; i++ { + if _, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: created.Channel.ID, + RandomID: int64(210_000 + i), + Message: "bulk delete", + Date: 1_700_002_146 + i, + }); err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + req := &tg.MessagesDeleteHistoryRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + MaxID: 0, + } + req.SetRevoke(true) + affected, err := r.onMessagesDeleteHistory(WithUserID(ctx, owner.ID), req) + if err != nil { + t.Fatalf("messages.deleteHistory channel: %v", err) + } + if affected.Offset != 1 || affected.PtsCount != domain.MaxDeleteHistoryBatch { + t.Fatalf("affected history = %+v, want offset=1 pts_count=%d", affected, domain.MaxDeleteHistoryBatch) + } + pushed := sessions.snapshot() + updates, ok := pushed.message.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed update = %T %+v, want one bounded delete update", pushed.message, pushed.message) + } + deleted, ok := updates.Updates[0].(*tg.UpdateDeleteChannelMessages) + if !ok { + t.Fatalf("pushed update[0] = %T, want updateDeleteChannelMessages", updates.Updates[0]) + } + if len(deleted.Messages) != domain.MaxDeleteHistoryBatch || deleted.PtsCount != domain.MaxDeleteHistoryBatch { + t.Fatalf("delete update len=%d pts_count=%d, want bounded %d", len(deleted.Messages), deleted.PtsCount, domain.MaxDeleteHistoryBatch) + } +} + +func TestChannelDifferenceTooLongCarriesDialogPts(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002161", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002162", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC TooLong Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sourceCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "RPC TooLong Source", + About: "forward source", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create source channel: %v", err) + } + sourceChannel := sourceCreated.(*tg.Updates).Chats[0].(*tg.Channel) + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + }); err != nil { + t.Fatalf("invite friend to source channel: %v", err) + } + sourceSent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, + Message: "forward source", + RandomID: 7000, + }) + if err != nil { + t.Fatalf("send source channel message: %v", err) + } + sourceMsgID := sourceSent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, + ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{sourceMsgID}, + RandomID: []int64{7001}, + }); err != nil { + t.Fatalf("forward source channel to target channel: %v", err) + } + for i := 0; i < 12; i++ { + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "too long page", + RandomID: int64(i + 1), + }); err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + } + diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: 0, + Limit: 3, + }) + if err != nil { + t.Fatalf("channel difference: %v", err) + } + tooLong, ok := diff.(*tg.UpdatesChannelDifferenceTooLong) + if !ok { + t.Fatalf("diff = %T %+v, want channelDifferenceTooLong", diff, diff) + } + dialog, ok := tooLong.Dialog.(*tg.Dialog) + if !ok { + t.Fatalf("tooLong dialog = %T, want dialog", tooLong.Dialog) + } + pts, ok := dialog.GetPts() + if !ok || pts == 0 { + t.Fatalf("tooLong dialog pts = %d ok=%v, want current channel pts", pts, ok) + } + if len(tooLong.Messages) == 0 || len(tooLong.Messages) > domain.MaxChannelDifferenceTooLongMessages { + t.Fatalf("tooLong messages = %d, want bounded latest snapshot", len(tooLong.Messages)) + } + if len(tooLong.Chats) == 0 || tooLong.Chats[0].(*tg.Channel).ID != channel.ID { + t.Fatalf("tooLong chats = %+v, want source channel context", tooLong.Chats) + } + hasSourceChannel := false + for _, chat := range tooLong.Chats { + if ch, ok := chat.(*tg.Channel); ok && ch.ID == sourceChannel.ID { + hasSourceChannel = true + break + } + } + if !hasSourceChannel { + t.Fatalf("tooLong chats = %+v, want forwarded source channel context %d", tooLong.Chats, sourceChannel.ID) + } + hasOwnerUser := false + for _, user := range tooLong.Users { + if u, ok := user.(*tg.User); ok && u.ID == owner.ID { + hasOwnerUser = true + break + } + } + if !hasOwnerUser { + t.Fatalf("tooLong users = %+v, want sender user context", tooLong.Users) + } +} + +func TestBroadcastChannelPostRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002171", FirstName: "Owner"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002172", FirstName: "Member"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "RPC Broadcast", + About: "news", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + if !channel.Broadcast || channel.Megagroup { + t.Fatalf("channel flags = broadcast:%v megagroup:%v, want broadcast only", channel.Broadcast, channel.Megagroup) + } + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}}, + }); err != nil { + t.Fatalf("invite member to broadcast: %v", err) + } + posted, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "broadcast post", + RandomID: 7071, + }) + if err != nil { + t.Fatalf("owner send broadcast post: %v", err) + } + postUpdate := posted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage) + postMsg := postUpdate.Message.(*tg.Message) + if !postMsg.Post || postMsg.FromID != nil || postMsg.Message != "broadcast post" { + t.Fatalf("broadcast post message = %#v, want post without from_id", postMsg) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "member post", + RandomID: 7072, + }); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") { + t.Fatalf("member broadcast send err = %v, want CHAT_WRITE_FORBIDDEN", err) + } + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}, + AdminRights: tg.ChatAdminRights{ChangeInfo: true}, + }); err != nil { + t.Fatalf("promote member without post_messages: %v", err) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "admin without post right", + RandomID: 7073, + }); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") { + t.Fatalf("admin without post right send err = %v, want CHAT_WRITE_FORBIDDEN", err) + } + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}, + AdminRights: tg.ChatAdminRights{PostMessages: true}, + }); err != nil { + t.Fatalf("grant member post_messages: %v", err) + } + adminPosted, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "admin post", + RandomID: 7074, + }) + if err != nil { + t.Fatalf("admin send broadcast post: %v", err) + } + adminPostMsg := adminPosted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if !adminPostMsg.Post || adminPostMsg.FromID != nil || adminPostMsg.Message != "admin post" { + t.Fatalf("admin broadcast post message = %#v, want post without from_id", adminPostMsg) + } + diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, member.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("member channel difference: %v", err) + } + fullDiff := diff.(*tg.UpdatesChannelDifference) + foundPost := false + for _, msg := range fullDiff.NewMessages { + if item, ok := msg.(*tg.Message); ok && item.Message == "broadcast post" { + foundPost = item.Post && item.FromID == nil + } + } + if !foundPost { + t.Fatalf("diff new messages = %+v, want broadcast post without from_id", fullDiff.NewMessages) + } +} + +func TestChannelsCreateChannelUnsupportedOptionsReturnExplicitErrors(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 73, Phone: "15550002173", FirstName: "Owner"}) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(memory.NewChannelStore()), + }, zaptest.NewLogger(t), clock.System) + + tests := []struct { + name string + req *tg.ChannelsCreateChannelRequest + want string + }{ + { + name: "history import", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Import Group", + Megagroup: true, + ForImport: true, + }, + want: "CHAT_INVALID", + }, + { + name: "geogroup", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Geo Group", + Megagroup: true, + GeoPoint: &tg.InputGeoPoint{Lat: 1.2, Long: 3.4}, + Address: "somewhere", + }, + want: "ADDRESS_INVALID", + }, + { + name: "address without geo", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Bad Geo Group", + Megagroup: true, + Address: "somewhere", + }, + want: "ADDRESS_INVALID", + }, + { + name: "negative ttl", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Bad TTL Group", + Megagroup: true, + TTLPeriod: -1, + }, + want: "TTL_PERIOD_INVALID", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("create channel err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestLegacyChannelSettingsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550002181", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550002182", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Legacy Settings Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + themeUpdates, err := r.onMessagesSetChatTheme(WithUserID(ctx, owner.ID), &tg.MessagesSetChatThemeRequest{Peer: peer}) + if err != nil { + t.Fatalf("set chat theme channel peer: %v", err) + } + if len(themeUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("set chat theme updates = %+v, want channel context", themeUpdates) + } + privateTheme, err := r.onMessagesSetChatTheme(WithUserID(ctx, owner.ID), &tg.MessagesSetChatThemeRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + }) + if err != nil { + t.Fatalf("set chat theme private peer: %v", err) + } + if len(privateTheme.(*tg.Updates).Updates) != 0 { + t.Fatalf("private set chat theme updates = %+v, want empty compat ack", privateTheme) + } + + reactionUpdates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}}, + ReactionsLimit: 8, + }) + if err != nil { + t.Fatalf("set available reactions: %v", err) + } + if len(reactionUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("set reactions updates = %+v, want channel state update", reactionUpdates) + } + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after reactions: %v", err) + } + fullChannel := full.FullChat.(*tg.ChannelFull) + reactions, ok := fullChannel.GetAvailableReactions() + if !ok { + t.Fatalf("full channel reactions missing after set") + } + some, ok := reactions.(*tg.ChatReactionsSome) + if !ok || len(some.Reactions) != 1 { + t.Fatalf("full channel reactions = %#v, want one explicit reaction", reactions) + } + if fullChannel.ReactionsLimit != 8 { + t.Fatalf("full channel reactions limit = %d, want 8", fullChannel.ReactionsLimit) + } + if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: make([]tg.ReactionClass, maxChatAvailableReactions+1)}, + }); err == nil { + t.Fatalf("set too many reactions err = nil, want limit error") + } + + noForwards, err := r.onMessagesToggleNoForwards(WithUserID(ctx, owner.ID), &tg.MessagesToggleNoForwardsRequest{ + Peer: peer, + Enabled: true, + }) + if err != nil { + t.Fatalf("toggle noforwards: %v", err) + } + if got := noForwards.(*tg.Updates).Chats[0].(*tg.Channel); !got.Noforwards { + t.Fatalf("noforwards channel = %+v, want enabled", got) + } + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: peer, + Message: "protected content", + RandomID: 8181, + }) + if err != nil { + t.Fatalf("send protected message: %v", err) + } + msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if !msg.Noforwards { + t.Fatalf("protected channel message = %+v, want noforwards inherited", msg) + } +} + +func TestChannelParticipantsSearchQueryIsBounded(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002111", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002112", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Participants RPC Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + + _, err = r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsSearch{Q: strings.Repeat("x", domain.MaxChannelParticipantsQueryLength+1)}, + Limit: 20, + }) + if err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("get participants long query err = %v, want LIMIT_INVALID", err) + } +} + +func TestChannelSendMessageRPCResolvesReplyHeader(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002031", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002032", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Reply Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + rootUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "root", + RandomID: 3001, + }) + if err != nil { + t.Fatalf("send root: %v", err) + } + rootMsg := rootUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + replyTo := &tg.InputReplyToMessage{ReplyToMsgID: rootMsg.ID} + replyTo.SetQuoteText("root") + replyTo.SetQuoteOffset(0) + replyReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "reply", + RandomID: 3002, + } + replyReq.SetReplyTo(replyTo) + replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), replyReq) + if err != nil { + t.Fatalf("send reply: %v", err) + } + replyMsg := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + header, ok := replyMsg.ReplyTo.(*tg.MessageReplyHeader) + if !ok { + t.Fatalf("reply header = %#v, want messageReplyHeader", replyMsg.ReplyTo) + } + topID, topOK := header.GetReplyToTopID() + quoteText, quoteOK := header.GetQuoteText() + if header.ReplyToMsgID != rootMsg.ID || !topOK || topID != rootMsg.ID || !quoteOK || quoteText != "root" { + t.Fatalf("reply header = %#v, want msg/top %d and quote", header, rootMsg.ID) + } + badReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "bad", + RandomID: 3003, + } + badReq.SetReplyTo(&tg.InputReplyToMessage{ReplyToMsgID: 999}) + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), badReq); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("bad reply err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } +} + +func TestMessagesSearchChannelPeerReturnsSingleCopyMessages(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 35, Phone: "15550002035", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 36, Phone: "15550002036", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Search Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + for _, item := range []struct { + userID int64 + text string + random int64 + }{ + {owner.ID, "needle from owner", 5001}, + {friend.ID, "not this one", 5002}, + {friend.ID, "needle from friend", 5003}, + } { + if _, err := r.onMessagesSendMessage(WithUserID(ctx, item.userID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: item.text, + RandomID: item.random, + }); err != nil { + t.Fatalf("send %q: %v", item.text, err) + } + } + + req := &tg.MessagesSearchRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Q: "needle", + Filter: &tg.InputMessagesFilterEmpty{}, + Limit: 10, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode search: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, friend.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch search: %v", err) + } + messages, chats, users := searchMessagesPayload(t, enc) + if len(messages) != 2 || len(chats) != 1 || len(users) < 2 { + t.Fatalf("search payload sizes = messages %d chats %d users %d, want 2/1/2+", len(messages), len(chats), len(users)) + } + for _, msg := range messages { + item := msg.(*tg.Message) + if !strings.Contains(item.Message, "needle") { + t.Fatalf("search result message = %q, want only needle hits", item.Message) + } + } + + fromReq := *req + fromReq.SetFromID(&tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}) + in.Reset() + if err := fromReq.Encode(&in); err != nil { + t.Fatalf("encode from search: %v", err) + } + enc, err = r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch from search: %v", err) + } + messages, _, _ = searchMessagesPayload(t, enc) + if len(messages) != 1 || messages[0].(*tg.Message).Message != "needle from friend" { + t.Fatalf("from search messages = %+v, want friend needle only", messages) + } + + mediaCountReq := &tg.MessagesSearchRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.InputMessagesFilterPhotos{}, + Limit: 0, + } + in.Reset() + if err := mediaCountReq.Encode(&in); err != nil { + t.Fatalf("encode shared media count search: %v", err) + } + enc, err = r.Dispatch(WithUserID(ctx, friend.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch shared media count search: %v", err) + } + if box, ok := enc.(*tg.MessagesMessagesBox); ok { + enc = box.Messages + } + channelMessages, ok := enc.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("shared media count search result = %T, want messages.channelMessages", enc) + } + if channelMessages.Count != 0 || len(channelMessages.Messages) != 0 { + t.Fatalf("shared media count search = count %d messages %d, want empty without media store", channelMessages.Count, len(channelMessages.Messages)) + } +} + +func searchMessagesPayload(t *testing.T, enc bin.Encoder) ([]tg.MessageClass, []tg.ChatClass, []tg.UserClass) { + t.Helper() + switch result := enc.(type) { + case *tg.MessagesMessages: + return result.Messages, result.Chats, result.Users + case *tg.MessagesMessagesSlice: + return result.Messages, result.Chats, result.Users + case *tg.MessagesChannelMessages: + return result.Messages, result.Chats, result.Users + case *tg.MessagesMessagesBox: + return searchMessagesPayload(t, result.Messages) + default: + t.Fatalf("search result type = %T, want messages/messagesSlice", enc) + return nil, nil, nil + } +} + +func TestForwardChannelMessageToUserIncludesSourceChannelChat(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550002033", FirstName: "Owner"}) + recipient, _ := userStore.Create(ctx, domain.User{AccessHash: 34, Phone: "15550002034", FirstName: "Recipient"}) + channelStore := memory.NewChannelStore() + messageStore := memory.NewMessageStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Messages: appmessages.NewService(messageStore, nil), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Source Channel", + About: "forward source", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create source channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "source post", + RandomID: 4001, + }) + if err != nil { + t.Fatalf("send source channel message: %v", err) + } + msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, + ID: []int{msgID}, + RandomID: []int64{4002}, + }) + if err != nil { + t.Fatalf("forward channel to user: %v", err) + } + updates := forwarded.(*tg.Updates) + if len(updates.Chats) != 1 || updates.Chats[0].(*tg.Channel).ID != channel.ID { + t.Fatalf("forward updates chats = %+v, want source channel chat", updates.Chats) + } + newMsg := updates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + if from, ok := newMsg.FwdFrom.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("forward header = %#v, want source channel peer", newMsg.FwdFrom) + } +} + +func TestChannelSendAsCurrentChannelRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002111", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002112", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Send As Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sendAs := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "as channel", + RandomID: 501, + SendAs: sendAs, + }) + if err != nil { + t.Fatalf("send as current channel: %v", err) + } + sentUpdates := sent.(*tg.Updates) + sentMsg := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("send_as message from = %#v, want channel %d", sentMsg.FromID, channel.ID) + } + + history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []tg.InputMessageClass{&tg.InputMessageID{ID: sentMsg.ID}}, + }) + if err != nil { + t.Fatalf("get send_as history: %v", err) + } + historyMsg := history.(*tg.MessagesMessages).Messages[0].(*tg.Message) + if from, ok := historyMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("history send_as from = %#v, want channel %d", historyMsg.FromID, channel.ID) + } + + forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{sentMsg.ID}, + RandomID: []int64{502}, + SendAs: sendAs, + }) + if err != nil { + t.Fatalf("forward as current channel: %v", err) + } + forwardMsg := forwarded.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := forwardMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("forward send_as from = %#v, want channel %d", forwardMsg.FromID, channel.ID) + } + + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: "bad private send_as", + RandomID: 503, + SendAs: sendAs, + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("private send_as err = %v, want SEND_AS_PEER_INVALID", err) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "bad member send_as", + RandomID: 504, + SendAs: sendAs, + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("member send_as err = %v, want SEND_AS_PEER_INVALID", err) + } + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + ID: []int{sentMsg.ID}, + RandomID: []int64{505}, + SendAs: sendAs, + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("private forward send_as err = %v, want SEND_AS_PEER_INVALID", err) + } +} + +func TestTDesktopPassiveChannelStubs(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002101", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002102", FirstName: "Friend"}) + invited, _ := userStore.Create(ctx, domain.User{AccessHash: 43, Phone: "15550002103", FirstName: "Invited"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Passive Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + + sendAs, err := r.onChannelsGetSendAs(WithUserID(ctx, owner.ID), &tg.ChannelsGetSendAsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("get send as: %v", err) + } + if len(sendAs.Peers) != 2 || len(sendAs.Users) != 1 || len(sendAs.Chats) != 1 { + t.Fatalf("send as = %+v, want self + current channel peers with current channel context", sendAs) + } + if peer, ok := sendAs.Peers[0].Peer.(*tg.PeerUser); !ok || peer.UserID != owner.ID { + t.Fatalf("send as peer = %#v, want owner user", sendAs.Peers[0].Peer) + } + if peer, ok := sendAs.Peers[1].Peer.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("send as peer[1] = %#v, want current channel", sendAs.Peers[1].Peer) + } + friendSendAs, err := r.onChannelsGetSendAs(WithUserID(ctx, friend.ID), &tg.ChannelsGetSendAsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("friend get send as: %v", err) + } + if len(friendSendAs.Peers) != 1 { + t.Fatalf("friend send as peers = %+v, want only self", friendSendAs.Peers) + } + if ok, err := r.onMessagesSaveDefaultSendAs(WithUserID(ctx, owner.ID), &tg.MessagesSaveDefaultSendAsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + SendAs: &tg.InputPeerSelf{}, + }); err != nil || !ok { + t.Fatalf("save default send as self = ok %v err %v, want true", ok, err) + } + if ok, err := r.onMessagesSaveDefaultSendAs(WithUserID(ctx, owner.ID), &tg.MessagesSaveDefaultSendAsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + SendAs: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }); err != nil || !ok { + t.Fatalf("save default send as channel = ok %v err %v, want true", ok, err) + } + fullWithDefault, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel with default send as: %v", err) + } + channelFull := fullWithDefault.FullChat.(*tg.ChannelFull) + defaultSendAs, ok := channelFull.GetDefaultSendAs() + if !ok { + t.Fatalf("full channel default_send_as missing") + } + if peer, ok := defaultSendAs.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("full channel default_send_as = %#v, want current channel %d", defaultSendAs, channel.ID) + } + sentWithDefault, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "default send as fallback", + RandomID: 80102, + }) + if err != nil { + t.Fatalf("send message with default send as: %v", err) + } + defaultMsg := sentWithDefault.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := defaultMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("default send_as message from = %#v, want channel %d", defaultMsg.FromID, channel.ID) + } + forwardedWithDefault, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{defaultMsg.ID}, + RandomID: []int64{80103}, + }) + if err != nil { + t.Fatalf("forward with default send as: %v", err) + } + defaultForward := forwardedWithDefault.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := defaultForward.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("default forward send_as from = %#v, want channel %d", defaultForward.FromID, channel.ID) + } + if ok, err := r.onMessagesSaveDefaultSendAs(WithUserID(ctx, owner.ID), &tg.MessagesSaveDefaultSendAsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + SendAs: &tg.InputPeerSelf{}, + }); err != nil || !ok { + t.Fatalf("clear default send as self = ok %v err %v, want true", ok, err) + } + fullAfterClear, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after clearing default send as: %v", err) + } + if _, ok := fullAfterClear.FullChat.(*tg.ChannelFull).GetDefaultSendAs(); ok { + t.Fatalf("full channel default_send_as still set after saving self") + } + if _, err := r.onMessagesSaveDefaultSendAs(WithUserID(ctx, owner.ID), &tg.MessagesSaveDefaultSendAsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + SendAs: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("save default send as friend err = %v, want SEND_AS_PEER_INVALID", err) + } + + sponsored, err := r.onMessagesGetSponsoredMessages(WithUserID(ctx, owner.ID), &tg.MessagesGetSponsoredMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("get sponsored messages: %v", err) + } + if _, ok := sponsored.(*tg.MessagesSponsoredMessagesEmpty); !ok { + t.Fatalf("sponsored = %T, want empty", sponsored) + } + historyTTL, err := r.onMessagesGetDefaultHistoryTTL(WithUserID(ctx, owner.ID)) + if err != nil { + t.Fatalf("get default history ttl: %v", err) + } + if historyTTL.Period != 0 { + t.Fatalf("default history ttl = %+v, want disabled", historyTTL) + } + accountTTL, err := r.onAccountGetAccountTTL(WithUserID(ctx, owner.ID)) + if err != nil { + t.Fatalf("get account ttl: %v", err) + } + if accountTTL.Days <= 0 { + t.Fatalf("account ttl = %+v, want positive days", accountTTL) + } + preview, err := r.onMessagesGetWebPagePreview(WithUserID(ctx, owner.ID), &tg.MessagesGetWebPagePreviewRequest{ + Message: "https://example.com", + }) + if err != nil { + t.Fatalf("messages.getWebPagePreview: %v", err) + } + if _, ok := preview.Media.(*tg.MessageMediaEmpty); !ok || len(preview.Chats) != 0 || len(preview.Users) != 0 { + t.Fatalf("messages.getWebPagePreview = %+v, want empty media preview", preview) + } + if _, err := r.onMessagesGetWebPagePreview(WithUserID(ctx, owner.ID), &tg.MessagesGetWebPagePreviewRequest{}); err == nil || !strings.Contains(err.Error(), "MESSAGE_EMPTY") { + t.Fatalf("messages.getWebPagePreview empty err = %v, want MESSAGE_EMPTY", err) + } + if media, err := r.onMessagesUploadMedia(WithUserID(ctx, owner.ID), &tg.MessagesUploadMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Media: &tg.InputMediaEmpty{}, + }); err != nil { + t.Fatalf("messages.uploadMedia empty: %v", err) + } else if _, ok := media.(*tg.MessageMediaEmpty); !ok { + t.Fatalf("messages.uploadMedia empty = %#v, want messageMediaEmpty", media) + } + if _, err := r.onMessagesUploadMedia(WithUserID(ctx, owner.ID), &tg.MessagesUploadMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Media: &tg.InputMediaUploadedPhoto{}, + }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("messages.uploadMedia unsupported err = %v, want MEDIA_INVALID", err) + } + if updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Media: &tg.InputMediaWebPage{URL: "https://example.com"}, + Message: "https://example.com", + RandomID: 91001, + }); err != nil { + t.Fatalf("messages.sendMedia webpage-as-text: %v", err) + } else if len(updates.(*tg.Updates).Updates) == 0 { + t.Fatalf("messages.sendMedia webpage-as-text = %+v, want channel message updates", updates) + } + if _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Media: &tg.InputMediaUploadedPhoto{}, + Message: "photo", + RandomID: 91002, + }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("messages.sendMedia unsupported err = %v, want MEDIA_INVALID", err) + } + if _, err := r.onMessagesSendMultiMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMultiMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MultiMedia: []tg.InputSingleMedia{{ + Media: &tg.InputMediaUploadedPhoto{}, + RandomID: 91003, + Message: "photo", + }}, + }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("messages.sendMultiMedia unsupported err = %v, want MEDIA_INVALID", err) + } + savedHistoryReq := &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 20, + } + savedHistoryReq.SetParentPeer(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + savedHistory, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), savedHistoryReq) + if err != nil { + t.Fatalf("messages.getSavedHistory: %v", err) + } + if len(savedHistory.(*tg.MessagesMessages).Messages) != 0 || len(savedHistory.(*tg.MessagesMessages).Chats) != 1 { + t.Fatalf("messages.getSavedHistory = %+v, want empty with parent channel context", savedHistory) + } + badSavedParent := &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 20, + } + badSavedParent.SetParentPeer(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash + 1}) + if _, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), badSavedParent); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { + t.Fatalf("messages.getSavedHistory bad parent err = %v, want PARENT_PEER_INVALID", err) + } + if _, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash + 1}, + Limit: 20, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("messages.getSavedHistory bad peer err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: maxSearchResultsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getSavedHistory limit err = %v, want LIMIT_INVALID", err) + } + if ok, err := r.onMessagesReadSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesReadSavedHistoryRequest{ + ParentPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Peer: &tg.InputPeerSelf{}, + MaxID: 0, + }); err != nil || !ok { + t.Fatalf("messages.readSavedHistory = ok %v err %v, want true", ok, err) + } + if _, err := r.onMessagesReadSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesReadSavedHistoryRequest{ + ParentPeer: &tg.InputPeerSelf{}, + Peer: &tg.InputPeerSelf{}, + }); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { + t.Fatalf("messages.readSavedHistory bad parent err = %v, want PARENT_PEER_INVALID", err) + } + if _, err := r.onMessagesReadSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesReadSavedHistoryRequest{ + ParentPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Peer: &tg.InputPeerSelf{}, + MaxID: domain.MaxMessageBoxID + 1, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.readSavedHistory max_id err = %v, want MESSAGE_ID_INVALID", err) + } + deleteSavedReq := &tg.MessagesDeleteSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + } + deleteSavedReq.SetParentPeer(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + deletedSaved, err := r.onMessagesDeleteSavedHistory(WithUserID(ctx, owner.ID), deleteSavedReq) + if err != nil { + t.Fatalf("messages.deleteSavedHistory: %v", err) + } + if deletedSaved.Offset != 0 || deletedSaved.PtsCount != 0 { + t.Fatalf("messages.deleteSavedHistory = %+v, want empty affected history", deletedSaved) + } + badDeleteDate := &tg.MessagesDeleteSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + } + badDeleteDate.SetMinDate(-1) + if _, err := r.onMessagesDeleteSavedHistory(WithUserID(ctx, owner.ID), badDeleteDate); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.deleteSavedHistory min_date err = %v, want LIMIT_INVALID", err) + } + scheduledHistory, err := r.onMessagesGetScheduledHistory(WithUserID(ctx, owner.ID), &tg.MessagesGetScheduledHistoryRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("messages.getScheduledHistory: %v", err) + } + if len(scheduledHistory.(*tg.MessagesMessages).Messages) != 0 || len(scheduledHistory.(*tg.MessagesMessages).Chats) != 1 { + t.Fatalf("messages.getScheduledHistory = %+v, want empty with channel context", scheduledHistory) + } + if _, err := r.onMessagesGetScheduledHistory(WithUserID(ctx, owner.ID), &tg.MessagesGetScheduledHistoryRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash + 1}, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("messages.getScheduledHistory bad hash err = %v, want CHANNEL_PRIVATE", err) + } + scheduled, err := r.onMessagesGetScheduledMessages(WithUserID(ctx, owner.ID), &tg.MessagesGetScheduledMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{1}, + }) + if err != nil { + t.Fatalf("messages.getScheduledMessages: %v", err) + } + if len(scheduled.(*tg.MessagesMessages).Messages) != 0 || len(scheduled.(*tg.MessagesMessages).Chats) != 1 { + t.Fatalf("messages.getScheduledMessages = %+v, want empty with channel context", scheduled) + } + if _, err := r.onMessagesGetScheduledMessages(WithUserID(ctx, owner.ID), &tg.MessagesGetScheduledMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: make([]int, maxGetMessagesIDs+1), + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getScheduledMessages id cap err = %v, want LIMIT_INVALID", err) + } + deletedScheduled, err := r.onMessagesDeleteScheduledMessages(WithUserID(ctx, owner.ID), &tg.MessagesDeleteScheduledMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{7, 8}, + }) + if err != nil { + t.Fatalf("messages.deleteScheduledMessages: %v", err) + } + if got := deletedScheduled.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.deleteScheduledMessages updates = %+v, want one update", got) + } else if update, ok := got[0].(*tg.UpdateDeleteScheduledMessages); !ok || len(update.Messages) != 2 || update.Messages[0] != 7 { + t.Fatalf("messages.deleteScheduledMessages update = %#v, want requested ids", got[0]) + } + if _, err := r.onMessagesSendScheduledMessages(WithUserID(ctx, owner.ID), &tg.MessagesSendScheduledMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{7}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.sendScheduledMessages err = %v, want MESSAGE_ID_INVALID", err) + } + if _, err := r.onMessagesCreateForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesCreateForumTopicRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Title: "Topic", + RandomID: 91004, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.createForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesCreateForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesCreateForumTopicRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + RandomID: 91005, + }); err == nil || !strings.Contains(err.Error(), "TOPIC_TITLE_EMPTY") { + t.Fatalf("messages.createForumTopic empty title err = %v, want TOPIC_TITLE_EMPTY", err) + } + if _, err := r.onMessagesEditForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesEditForumTopicRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + TopicID: 1, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.editForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesUpdatePinnedForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesUpdatePinnedForumTopicRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + TopicID: 1, + Pinned: true, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.updatePinnedForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesReorderPinnedForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedForumTopicsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Order: []int{1, 2}, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.reorderPinnedForumTopics err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesDeleteTopicHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteTopicHistoryRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + TopMsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.deleteTopicHistory err = %v, want CHANNEL_FORUM_MISSING", err) + } + chats, err := r.onMessagesGetChats(WithUserID(ctx, owner.ID), []int64{channel.ID}) + if err != nil { + t.Fatalf("messages.getChats legacy wrapper: %v", err) + } + if len(chats.(*tg.MessagesChats).Chats) != 1 { + t.Fatalf("messages.getChats = %+v, want current megagroup channel", chats) + } + migrated, err := r.onMessagesMigrateChat(WithUserID(ctx, owner.ID), channel.ID) + if err != nil { + t.Fatalf("messages.migrateChat legacy mapping: %v", err) + } + migratedUpdates := migrated.(*tg.Updates) + if len(migratedUpdates.Chats) != 1 || len(migratedUpdates.Updates) != 1 { + t.Fatalf("messages.migrateChat = %+v, want updateChannel with current megagroup", migratedUpdates) + } + if _, err := r.onMessagesMigrateChat(WithUserID(ctx, friend.ID), channel.ID); err == nil { + t.Fatalf("messages.migrateChat by non-admin = nil err, want CHAT_ADMIN_REQUIRED") + } + full, err := r.onMessagesGetFullChat(WithUserID(ctx, owner.ID), channel.ID) + if err != nil { + t.Fatalf("messages.getFullChat legacy wrapper: %v", err) + } + if got := full.FullChat.(*tg.ChannelFull).ID; got != channel.ID { + t.Fatalf("messages.getFullChat id = %d, want %d", got, channel.ID) + } + viewedID := defaultMsg.ID + views, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{viewedID}, + }) + if err != nil { + t.Fatalf("messages.getMessagesViews: %v", err) + } + if len(views.Views) != 1 || len(views.Chats) != 1 { + t.Fatalf("messages.getMessagesViews = %+v, want one view with channel context", views) + } + if got, ok := views.Views[0].GetViews(); !ok || got != 0 { + t.Fatalf("messages.getMessagesViews views = %d ok %v, want explicit zero before increment", got, ok) + } + incremented, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{viewedID}, + Increment: true, + }) + if err != nil { + t.Fatalf("messages.getMessagesViews increment: %v", err) + } + if got, ok := incrementalViewCount(incremented); !ok || got != 1 { + t.Fatalf("messages.getMessagesViews increment count = %d ok %v, want 1", got, ok) + } + repeated, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{viewedID}, + Increment: true, + }) + if err != nil { + t.Fatalf("messages.getMessagesViews repeat increment: %v", err) + } + if got, ok := incrementalViewCount(repeated); !ok || got != 1 { + t.Fatalf("messages.getMessagesViews repeated count = %d ok %v, want still 1", got, ok) + } + friendIncremented, err := r.onMessagesGetMessagesViews(WithUserID(ctx, friend.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{viewedID}, + Increment: true, + }) + if err != nil { + t.Fatalf("messages.getMessagesViews friend increment: %v", err) + } + if got, ok := incrementalViewCount(friendIncremented); !ok || got != 2 { + t.Fatalf("messages.getMessagesViews friend count = %d ok %v, want 2", got, ok) + } + if _, err := r.onMessagesReadMessageContents(WithUserID(ctx, owner.ID), []int{1}); err != nil { + t.Fatalf("messages.readMessageContents: %v", err) + } + mentions, err := r.onMessagesGetUnreadMentions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadMentionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadMentions: %v", err) + } + _, mentionChats, _ := searchMessagesPayload(t, mentions) + if len(mentionChats) != 1 { + t.Fatalf("messages.getUnreadMentions = %+v, want channel context", mentions) + } + if _, err := r.onMessagesReadMentions(WithUserID(ctx, owner.ID), &tg.MessagesReadMentionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }); err != nil { + t.Fatalf("messages.readMentions: %v", err) + } + if ok, err := r.onMessagesReportSpam(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}); err != nil || !ok { + t.Fatalf("messages.reportSpam = ok %v err %v, want true nil", ok, err) + } + reportOptions, err := r.onMessagesReport(WithUserID(ctx, owner.ID), &tg.MessagesReportRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{1}, + }) + if err != nil { + t.Fatalf("messages.report options: %v", err) + } + if choices, ok := reportOptions.(*tg.ReportResultChooseOption); !ok || len(choices.Options) == 0 { + t.Fatalf("messages.report options = %#v, want chooseOption", reportOptions) + } + reportComment, err := r.onMessagesReport(WithUserID(ctx, owner.ID), &tg.MessagesReportRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{1}, + Option: []byte("other"), + }) + if err != nil { + t.Fatalf("messages.report other: %v", err) + } + if _, ok := reportComment.(*tg.ReportResultAddComment); !ok { + t.Fatalf("messages.report other = %#v, want addComment", reportComment) + } + reported, err := r.onMessagesReport(WithUserID(ctx, owner.ID), &tg.MessagesReportRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{1}, + Option: []byte("spam"), + }) + if err != nil { + t.Fatalf("messages.report spam: %v", err) + } + if _, ok := reported.(*tg.ReportResultReported); !ok { + t.Fatalf("messages.report spam = %#v, want reported", reported) + } + if _, err := r.onMessagesReport(WithUserID(ctx, owner.ID), &tg.MessagesReportRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{1}, + Option: []byte("bogus"), + }); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") { + t.Fatalf("messages.report invalid option err = %v, want OPTION_INVALID", err) + } + if ok, err := r.onMessagesReportReaction(WithUserID(ctx, owner.ID), &tg.MessagesReportReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: 1, + ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + }); err != nil || !ok { + t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err) + } + if ok, err := r.onMessagesReportMessagesDelivery(WithUserID(ctx, owner.ID), &tg.MessagesReportMessagesDeliveryRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{1}, + }); err != nil || !ok { + t.Fatalf("messages.reportMessagesDelivery = ok %v err %v, want true nil", ok, err) + } + if ok, err := r.onMessagesReportReadMetrics(WithUserID(ctx, owner.ID), &tg.MessagesReportReadMetricsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Metrics: []tg.InputMessageReadMetric{{ + MsgID: 1, + ViewID: 99, + TimeInViewMs: 10, + ActiveTimeInViewMs: 10, + HeightToViewportRatioPermille: 1000, + SeenRangeRatioPermille: 1000, + }}, + }); err != nil || !ok { + t.Fatalf("messages.reportReadMetrics = ok %v err %v, want true nil", ok, err) + } + if ok, err := r.onMessagesReportMusicListen(WithUserID(ctx, owner.ID), &tg.MessagesReportMusicListenRequest{ + ID: &tg.InputDocument{ID: 1, AccessHash: 2}, + ListenedDuration: 1, + }); err != nil || !ok { + t.Fatalf("messages.reportMusicListen = ok %v err %v, want true nil", ok, err) + } + sponsoredReport, err := r.onMessagesReportSponsoredMessage(WithUserID(ctx, owner.ID), &tg.MessagesReportSponsoredMessageRequest{ + RandomID: []byte("ad"), + Option: []byte("spam"), + }) + if err != nil { + t.Fatalf("messages.reportSponsoredMessage: %v", err) + } + if _, ok := sponsoredReport.(*tg.ChannelsSponsoredMessageReportResultReported); !ok { + t.Fatalf("messages.reportSponsoredMessage = %#v, want reported", sponsoredReport) + } + sendReactionReq := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: viewedID, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + } + sendReactionReq.SetReaction(sendReactionReq.Reaction) + sendReactionReq.SetAddToRecent(true) + if updates, err := r.onMessagesSendReaction(WithUserID(ctx, owner.ID), sendReactionReq); err != nil { + t.Fatalf("messages.sendReaction: %v", err) + } else if got := updates.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.sendReaction updates = %+v, want one reaction update", got) + } else if update, ok := got[0].(*tg.UpdateMessageReactions); !ok || update.MsgID != viewedID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 || update.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("messages.sendReaction update = %#v, want one chosen reaction", got[0]) + } + tooManyReactionsReq := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: viewedID, + Reaction: make([]tg.ReactionClass, maxReactionVector+1), + } + tooManyReactionsReq.SetReaction(tooManyReactionsReq.Reaction) + if _, err := r.onMessagesSendReaction(WithUserID(ctx, owner.ID), tooManyReactionsReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.sendReaction huge reaction vector err = %v, want LIMIT_INVALID", err) + } + reactionUpdates, err := r.onMessagesGetMessagesReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{viewedID}, + }) + if err != nil { + t.Fatalf("messages.getMessagesReactions: %v", err) + } + if got := reactionUpdates.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.getMessagesReactions updates = %+v, want one reaction update", got) + } else if update, ok := got[0].(*tg.UpdateMessageReactions); !ok || update.MsgID != viewedID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 { + t.Fatalf("messages.getMessagesReactions update = %#v, want one reaction", got[0]) + } + reactionList, err := r.onMessagesGetMessageReactionsList(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageReactionsListRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: viewedID, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getMessageReactionsList: %v", err) + } + if reactionList.Count != 1 || len(reactionList.Reactions) != 1 || len(reactionList.Chats) != 1 || len(reactionList.Users) != 1 { + t.Fatalf("messages.getMessageReactionsList = %+v, want one reaction with channel context", reactionList) + } + if peer, ok := reactionList.Reactions[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != owner.ID || !reactionList.Reactions[0].My { + t.Fatalf("messages.getMessageReactionsList reaction = %+v, want current user reaction", reactionList.Reactions[0]) + } + friendReactionReq := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: viewedID, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + } + friendReactionReq.SetReaction(friendReactionReq.Reaction) + if _, err := r.onMessagesSendReaction(WithUserID(ctx, friend.ID), friendReactionReq); err != nil { + t.Fatalf("messages.sendReaction by friend: %v", err) + } + unreadReactions, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadReactions: %v", err) + } + unreadMessages, unreadChats, unreadUsers := searchMessagesPayload(t, unreadReactions) + if len(unreadMessages) != 1 || len(unreadChats) != 1 || len(unreadUsers) == 0 { + t.Fatalf("messages.getUnreadReactions = %+v, want one unread reaction with channel context", unreadReactions) + } + unreadMessage, ok := unreadMessages[0].(*tg.Message) + if !ok || unreadMessage.ID != viewedID { + t.Fatalf("messages.getUnreadReactions message = %#v, want message %d", unreadMessages[0], viewedID) + } + reactions, ok := unreadMessage.GetReactions() + hasUnreadRecent := false + for _, recent := range reactions.RecentReactions { + if recent.Unread { + hasUnreadRecent = true + break + } + } + if !ok || !hasUnreadRecent { + t.Fatalf("messages.getUnreadReactions reactions = %+v ok %v, want unread recent reaction", reactions, ok) + } + if _, err := r.onMessagesReadReactions(WithUserID(ctx, owner.ID), &tg.MessagesReadReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }); err != nil { + t.Fatalf("messages.readReactions: %v", err) + } + unreadAfterRead, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadReactions after read: %v", err) + } + unreadAfterReadMessages, _, _ := searchMessagesPayload(t, unreadAfterRead) + if len(unreadAfterReadMessages) != 0 { + t.Fatalf("messages.getUnreadReactions after read = %+v, want empty messages", unreadAfterRead) + } + common, err := r.onMessagesGetCommonChats(WithUserID(ctx, owner.ID), &tg.MessagesGetCommonChatsRequest{ + UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Limit: 40, + }) + if err != nil { + t.Fatalf("messages.getCommonChats: %v", err) + } + commonChats := common.(*tg.MessagesChats).Chats + if len(commonChats) != 1 || commonChats[0].(*tg.Channel).ID != channel.ID { + t.Fatalf("messages.getCommonChats = %+v, want current shared megagroup", common) + } + commonNext, err := r.onMessagesGetCommonChats(WithUserID(ctx, owner.ID), &tg.MessagesGetCommonChatsRequest{ + UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + MaxID: channel.ID, + Limit: 40, + }) + if err != nil { + t.Fatalf("messages.getCommonChats next page: %v", err) + } + if len(commonNext.(*tg.MessagesChats).Chats) != 0 { + t.Fatalf("messages.getCommonChats next page = %+v, want empty after max id", commonNext) + } + fullFriend, err := r.onUsersGetFullUser(WithUserID(ctx, owner.ID), &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}) + if err != nil { + t.Fatalf("users.getFullUser friend: %v", err) + } + if fullFriend.FullUser.CommonChatsCount != 1 { + t.Fatalf("users.getFullUser commonChatsCount = %d, want 1", fullFriend.FullUser.CommonChatsCount) + } + if _, err := r.onMessagesGetCommonChats(WithUserID(ctx, owner.ID), &tg.MessagesGetCommonChatsRequest{ + UserID: &tg.InputUserSelf{}, + Limit: 1, + }); err == nil || !strings.Contains(err.Error(), "USER_ID_INVALID") { + t.Fatalf("messages.getCommonChats self err = %v, want USER_ID_INVALID", err) + } + if _, err := r.onMessagesGetAttachedStickers(WithUserID(ctx, owner.ID), nil); err == nil || !strings.Contains(err.Error(), "MEDIA_EMPTY") { + t.Fatalf("messages.getAttachedStickers nil err = %v, want MEDIA_EMPTY", err) + } + attached, err := r.onMessagesGetAttachedStickers(WithUserID(ctx, owner.ID), &tg.InputStickeredMediaDocument{ + ID: &tg.InputDocument{ID: 1, AccessHash: 2}, + }) + if err != nil || len(attached) != 0 { + t.Fatalf("messages.getAttachedStickers = %+v err=%v, want empty", attached, err) + } + customEmojiDocs, err := r.onMessagesGetCustomEmojiDocuments(WithUserID(ctx, owner.ID), []int64{1, 2}) + if err != nil || len(customEmojiDocs) != 0 { + t.Fatalf("messages.getCustomEmojiDocuments = %+v err=%v, want empty", customEmojiDocs, err) + } + if _, err := r.onMessagesGetCustomEmojiDocuments(WithUserID(ctx, owner.ID), make([]int64, maxEmojiDocuments+1)); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getCustomEmojiDocuments too many err = %v, want LIMIT_INVALID", err) + } + stickerSets, err := r.onMessagesSearchStickerSets(WithUserID(ctx, owner.ID), &tg.MessagesSearchStickerSetsRequest{Q: "cat"}) + if err != nil { + t.Fatalf("messages.searchStickerSets: %v", err) + } + if found, ok := stickerSets.(*tg.MessagesFoundStickerSets); !ok || len(found.Sets) != 0 { + t.Fatalf("messages.searchStickerSets = %#v, want empty found sets", stickerSets) + } + stickers, err := r.onMessagesSearchStickers(WithUserID(ctx, owner.ID), &tg.MessagesSearchStickersRequest{ + Q: "cat", + LangCode: []string{"en"}, + Limit: 50, + }) + if err != nil { + t.Fatalf("messages.searchStickers: %v", err) + } + if found, ok := stickers.(*tg.MessagesFoundStickers); !ok || len(found.Stickers) != 0 { + t.Fatalf("messages.searchStickers = %#v, want empty found stickers", stickers) + } + if _, err := r.onMessagesSearchStickers(WithUserID(ctx, owner.ID), &tg.MessagesSearchStickersRequest{ + Limit: maxSearchResultsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.searchStickers huge limit err = %v, want LIMIT_INVALID", err) + } + keywords, err := r.onMessagesGetEmojiKeywords(WithUserID(ctx, owner.ID), "en") + if err != nil || keywords.LangCode != "en" || len(keywords.Keywords) != 0 { + t.Fatalf("messages.getEmojiKeywords = %+v err=%v, want empty en", keywords, err) + } + keywordsDiff, err := r.onMessagesGetEmojiKeywordsDifference(WithUserID(ctx, owner.ID), &tg.MessagesGetEmojiKeywordsDifferenceRequest{ + LangCode: "en", + FromVersion: 7, + }) + if err != nil || keywordsDiff.Version != 7 || keywordsDiff.FromVersion != 7 { + t.Fatalf("messages.getEmojiKeywordsDifference = %+v err=%v, want version echo", keywordsDiff, err) + } + extended, err := r.onMessagesGetExtendedMedia(WithUserID(ctx, owner.ID), &tg.MessagesGetExtendedMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{1}, + }) + if err != nil { + t.Fatalf("messages.getExtendedMedia: %v", err) + } + if len(extended.(*tg.Updates).Updates) != 0 { + t.Fatalf("messages.getExtendedMedia = %+v, want empty updates", extended) + } + topReactions, err := r.onMessagesGetTopReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetTopReactionsRequest{Limit: 3}) + if err != nil { + t.Fatalf("messages.getTopReactions: %v", err) + } + topPage, ok := topReactions.(*tg.MessagesReactions) + if !ok || topPage.Hash == 0 || len(topPage.Reactions) != 3 { + t.Fatalf("messages.getTopReactions = %#v, want three hashable reactions", topReactions) + } + if emoji, ok := topPage.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { + t.Fatalf("messages.getTopReactions first reaction = %#v, want account top thumb", topPage.Reactions[0]) + } + if emoji, ok := topPage.Reactions[1].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\u2764\ufe0f" { + t.Fatalf("messages.getTopReactions fallback reaction = %#v, want catalog heart", topPage.Reactions[1]) + } + topNotModified, err := r.onMessagesGetTopReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetTopReactionsRequest{Limit: 3, Hash: topPage.Hash}) + if err != nil { + t.Fatalf("messages.getTopReactions hash: %v", err) + } + if _, ok := topNotModified.(*tg.MessagesReactionsNotModified); !ok { + t.Fatalf("messages.getTopReactions hash = %#v, want notModified", topNotModified) + } + recentReactions, err := r.onMessagesGetRecentReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40}) + if err != nil { + t.Fatalf("messages.getRecentReactions: %v", err) + } + recentPage, ok := recentReactions.(*tg.MessagesReactions) + if !ok || recentPage.Hash == 0 || len(recentPage.Reactions) != 1 { + t.Fatalf("messages.getRecentReactions = %#v, want one reaction with non-zero hash", recentReactions) + } + if emoji, ok := recentPage.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { + t.Fatalf("messages.getRecentReactions reaction = %#v, want thumb emoji", recentPage.Reactions[0]) + } + recentNotModified, err := r.onMessagesGetRecentReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40, Hash: recentPage.Hash}) + if err != nil { + t.Fatalf("messages.getRecentReactions hash: %v", err) + } + if _, ok := recentNotModified.(*tg.MessagesReactionsNotModified); !ok { + t.Fatalf("messages.getRecentReactions hash = %#v, want notModified", recentNotModified) + } + if ok, err := r.onMessagesClearRecentReactions(WithUserID(ctx, owner.ID)); err != nil || !ok { + t.Fatalf("messages.clearRecentReactions = ok %v err %v, want true nil", ok, err) + } + recentAfterClear, err := r.onMessagesGetRecentReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40, Hash: recentPage.Hash}) + if err != nil { + t.Fatalf("messages.getRecentReactions after clear: %v", err) + } + clearedPage, ok := recentAfterClear.(*tg.MessagesReactions) + if !ok || clearedPage.Hash != 0 || len(clearedPage.Reactions) != 0 { + t.Fatalf("messages.getRecentReactions after clear = %#v, want empty page hash 0", recentAfterClear) + } + savedTagsReq := &tg.MessagesGetSavedReactionTagsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + } + savedTagsReq.SetPeer(savedTagsReq.Peer) + savedTags, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), savedTagsReq) + if err != nil { + t.Fatalf("messages.getSavedReactionTags: %v", err) + } + if got := savedTags.(*tg.MessagesSavedReactionTags).Tags; len(got) != 0 { + t.Fatalf("messages.getSavedReactionTags = %+v, want empty", got) + } + staleEmptyTags, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedReactionTagsRequest{Hash: 123}) + if err != nil { + t.Fatalf("messages.getSavedReactionTags stale empty hash: %v", err) + } + staleEmptyPage, ok := staleEmptyTags.(*tg.MessagesSavedReactionTags) + if !ok || staleEmptyPage.Hash != 0 || len(staleEmptyPage.Tags) != 0 { + t.Fatalf("messages.getSavedReactionTags stale empty hash = %#v, want empty page hash 0", staleEmptyTags) + } + updateTagReq := &tg.MessagesUpdateSavedReactionTagRequest{Reaction: &tg.ReactionEmoji{Emoticon: "ok"}} + updateTagReq.SetTitle("Work") + if ok, err := r.onMessagesUpdateSavedReactionTag(WithUserID(ctx, owner.ID), updateTagReq); err != nil || !ok { + t.Fatalf("messages.updateSavedReactionTag = ok %v err %v, want true nil", ok, err) + } + globalTags, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedReactionTagsRequest{}) + if err != nil { + t.Fatalf("messages.getSavedReactionTags global: %v", err) + } + globalPage, ok := globalTags.(*tg.MessagesSavedReactionTags) + if !ok || globalPage.Hash == 0 || len(globalPage.Tags) != 1 { + t.Fatalf("messages.getSavedReactionTags global = %#v, want one hashable tag", globalTags) + } + if emoji, ok := globalPage.Tags[0].Reaction.(*tg.ReactionEmoji); !ok || emoji.Emoticon != "ok" || globalPage.Tags[0].Title != "Work" || globalPage.Tags[0].Count != 0 { + t.Fatalf("messages.getSavedReactionTags tag = %+v, want ok/Work/count0", globalPage.Tags[0]) + } + globalNotModified, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedReactionTagsRequest{Hash: globalPage.Hash}) + if err != nil { + t.Fatalf("messages.getSavedReactionTags hash: %v", err) + } + if _, ok := globalNotModified.(*tg.MessagesSavedReactionTagsNotModified); !ok { + t.Fatalf("messages.getSavedReactionTags hash = %#v, want notModified", globalNotModified) + } + peerTagsAfterUpdate, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), savedTagsReq) + if err != nil { + t.Fatalf("messages.getSavedReactionTags peer after update: %v", err) + } + if got := peerTagsAfterUpdate.(*tg.MessagesSavedReactionTags).Tags; len(got) != 0 { + t.Fatalf("messages.getSavedReactionTags peer after update = %+v, want empty until saved-message tag store exists", got) + } + longTagReq := &tg.MessagesUpdateSavedReactionTagRequest{Reaction: &tg.ReactionEmoji{Emoticon: "ok"}} + longTagReq.SetTitle("abcdefghijklmnop") + if _, err := r.onMessagesUpdateSavedReactionTag(WithUserID(ctx, owner.ID), longTagReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.updateSavedReactionTag long title err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onMessagesUpdateSavedReactionTag(WithUserID(ctx, owner.ID), &tg.MessagesUpdateSavedReactionTagRequest{ + Reaction: &tg.ReactionCustomEmoji{DocumentID: 1}, + }); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") { + t.Fatalf("messages.updateSavedReactionTag custom emoji err = %v, want REACTION_INVALID", err) + } + tagReactions, err := r.onMessagesGetDefaultTagReactions(WithUserID(ctx, owner.ID), 0) + if err != nil { + t.Fatalf("messages.getDefaultTagReactions: %v", err) + } + if got := tagReactions.(*tg.MessagesReactions).Reactions; len(got) != 0 { + t.Fatalf("messages.getDefaultTagReactions = %+v, want empty", got) + } + if _, err := r.onMessagesSendVote(WithUserID(ctx, owner.ID), &tg.MessagesSendVoteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + Options: [][]byte{[]byte("a")}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.sendVote err = %v, want MESSAGE_ID_INVALID without poll store", err) + } + if _, err := r.onMessagesSendVote(WithUserID(ctx, owner.ID), &tg.MessagesSendVoteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + Options: make([][]byte, maxPollVoteOptions+1), + }); err == nil || !strings.Contains(err.Error(), "OPTIONS_TOO_MUCH") { + t.Fatalf("messages.sendVote too many options err = %v, want OPTIONS_TOO_MUCH", err) + } + pollResults, err := r.onMessagesGetPollResults(WithUserID(ctx, owner.ID), &tg.MessagesGetPollResultsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + }) + if err != nil { + t.Fatalf("messages.getPollResults: %v", err) + } + if len(pollResults.(*tg.Updates).Updates) != 0 { + t.Fatalf("messages.getPollResults = %+v, want empty updates", pollResults) + } + pollVotesReq := &tg.MessagesGetPollVotesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: 1, + Option: []byte("a"), + Limit: 10, + } + pollVotesReq.SetOption(pollVotesReq.Option) + pollVotes, err := r.onMessagesGetPollVotes(WithUserID(ctx, owner.ID), pollVotesReq) + if err != nil { + t.Fatalf("messages.getPollVotes: %v", err) + } + if pollVotes.Count != 0 || len(pollVotes.Votes) != 0 || len(pollVotes.Chats) != 1 { + t.Fatalf("messages.getPollVotes = %+v, want empty votes with channel context", pollVotes) + } + if _, err := r.onMessagesGetPollVotes(WithUserID(ctx, owner.ID), &tg.MessagesGetPollVotesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: 1, + Limit: maxSearchResultsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getPollVotes huge limit err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onMessagesAddPollAnswer(WithUserID(ctx, owner.ID), &tg.MessagesAddPollAnswerRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + Answer: &tg.InputPollAnswer{Text: tg.TextWithEntities{Text: "extra"}}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.addPollAnswer err = %v, want MESSAGE_ID_INVALID without poll store", err) + } + if _, err := r.onMessagesDeletePollAnswer(WithUserID(ctx, owner.ID), &tg.MessagesDeletePollAnswerRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + Option: []byte("a"), + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.deletePollAnswer err = %v, want MESSAGE_ID_INVALID without poll store", err) + } + unreadPollVotes, err := r.onMessagesGetUnreadPollVotes(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadPollVotesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadPollVotes: %v", err) + } + if len(unreadPollVotes.(*tg.MessagesMessages).Messages) != 0 { + t.Fatalf("messages.getUnreadPollVotes = %+v, want empty messages", unreadPollVotes) + } + if _, err := r.onMessagesReadPollVotes(WithUserID(ctx, owner.ID), &tg.MessagesReadPollVotesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }); err != nil { + t.Fatalf("messages.readPollVotes: %v", err) + } + if _, err := r.onMessagesAppendTodoList(WithUserID(ctx, owner.ID), &tg.MessagesAppendTodoListRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "TODO_NOT_MODIFIED") { + t.Fatalf("messages.appendTodoList empty err = %v, want TODO_NOT_MODIFIED", err) + } + if _, err := r.onMessagesAppendTodoList(WithUserID(ctx, owner.ID), &tg.MessagesAppendTodoListRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + List: []tg.TodoItem{{Title: tg.TextWithEntities{Text: "item"}}}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.appendTodoList err = %v, want MESSAGE_ID_INVALID without todo store", err) + } + if _, err := r.onMessagesToggleTodoCompleted(WithUserID(ctx, owner.ID), &tg.MessagesToggleTodoCompletedRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "TODO_NOT_MODIFIED") { + t.Fatalf("messages.toggleTodoCompleted empty err = %v, want TODO_NOT_MODIFIED", err) + } + if _, err := r.onMessagesToggleTodoCompleted(WithUserID(ctx, owner.ID), &tg.MessagesToggleTodoCompletedRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + Completed: []int{1}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.toggleTodoCompleted err = %v, want MESSAGE_ID_INVALID without todo store", err) + } + counters, err := r.onMessagesGetSearchCounters(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchCountersRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filters: []tg.MessagesFilterClass{&tg.InputMessagesFilterEmpty{}}, + }) + if err != nil { + t.Fatalf("messages.getSearchCounters: %v", err) + } + if len(counters) != 1 || counters[0].Count != 0 { + t.Fatalf("messages.getSearchCounters = %+v, want one zero counter", counters) + } + calendar, err := r.onMessagesGetSearchResultsCalendar(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchResultsCalendarRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.InputMessagesFilterPhotos{}, + OffsetID: 7, + OffsetDate: 1700000000, + }) + if err != nil { + t.Fatalf("messages.getSearchResultsCalendar: %v", err) + } + if calendar.Count != 0 || calendar.MinDate != 1700000000 || calendar.MinMsgID != 7 || len(calendar.Messages) != 0 { + t.Fatalf("messages.getSearchResultsCalendar = %+v, want empty stable-offset result", calendar) + } + positions, err := r.onMessagesGetSearchResultsPositions(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchResultsPositionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.InputMessagesFilterPhotos{}, + OffsetID: 7, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getSearchResultsPositions: %v", err) + } + if positions.Count != 0 || len(positions.Positions) != 0 { + t.Fatalf("messages.getSearchResultsPositions = %+v, want empty positions", positions) + } + if _, err := r.onMessagesGetSearchResultsPositions(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchResultsPositionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.InputMessagesFilterPhotos{}, + OffsetID: 7, + Limit: maxSearchResultsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getSearchResultsPositions huge limit err = %v, want LIMIT_INVALID", err) + } + replies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getReplies: %v", err) + } + _, replyChats, _ := searchMessagesPayload(t, replies) + if len(replyChats) != 1 { + t.Fatalf("messages.getReplies = %+v, want channel context", replies) + } + discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + }) + if err != nil { + t.Fatalf("messages.getDiscussionMessage: %v", err) + } + if len(discussion.Messages) != 1 || len(discussion.Chats) != 1 || discussion.UnreadCount != 0 { + t.Fatalf("messages.getDiscussionMessage = %+v, want root message with channel context", discussion) + } + if _, ok := discussion.Messages[0].(*tg.MessageService); !ok { + t.Fatalf("messages.getDiscussionMessage message = %T, want service root", discussion.Messages[0]) + } + if _, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + ReadMaxID: 1, + }); err != nil { + t.Fatalf("messages.readDiscussion err = %v, want nil", err) + } + if _, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash + 1}, + MsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("messages.getDiscussionMessage bad hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + ReadMaxID: domain.MaxMessageBoxID + 1, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.readDiscussion huge read_max_id err = %v, want MESSAGE_ID_INVALID", err) + } + if _, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.getForumTopics non-forum err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Topics: []int{1}, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.getForumTopicsByID non-forum err = %v, want CHANNEL_FORUM_MISSING", err) + } + onlines, err := r.onMessagesGetOnlines(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("messages.getOnlines: %v", err) + } + if onlines.Onlines != 1 { + t.Fatalf("messages.getOnlines = %+v, want count 1", onlines) + } + stats, err := r.onStatsGetMegagroupStats(WithUserID(ctx, owner.ID), &tg.StatsGetMegagroupStatsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("stats.getMegagroupStats: %v", err) + } + if stats.GrowthGraph == nil || stats.MembersGraph == nil { + t.Fatalf("megagroup stats = %+v, want non-nil graph stubs", stats) + } + if _, err := r.onStatsGetBroadcastStats(WithUserID(ctx, owner.ID), &tg.StatsGetBroadcastStatsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "BROADCAST_REQUIRED") { + t.Fatalf("stats.getBroadcastStats on megagroup err = %v, want BROADCAST_REQUIRED", err) + } + messageStats, err := r.onStatsGetMessageStats(WithUserID(ctx, owner.ID), &tg.StatsGetMessageStatsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + }) + if err != nil { + t.Fatalf("stats.getMessageStats: %v", err) + } + if messageStats.ViewsGraph == nil || messageStats.ReactionsByEmotionGraph == nil { + t.Fatalf("message stats = %+v, want non-nil graph stubs", messageStats) + } + forwards, err := r.onStatsGetMessagePublicForwards(WithUserID(ctx, owner.ID), &tg.StatsGetMessagePublicForwardsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("stats.getMessagePublicForwards: %v", err) + } + if forwards.Count != 0 || len(forwards.Forwards) != 0 { + t.Fatalf("message public forwards = %+v, want empty", forwards) + } + if _, err := r.onStatsGetMessagePublicForwards(WithUserID(ctx, owner.ID), &tg.StatsGetMessagePublicForwardsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: 1, + Limit: maxStatsPublicForwardsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("stats.getMessagePublicForwards huge limit err = %v, want LIMIT_INVALID", err) + } + graph, err := r.onStatsLoadAsyncGraph(WithUserID(ctx, owner.ID), &tg.StatsLoadAsyncGraphRequest{Token: "stale"}) + if err != nil { + t.Fatalf("stats.loadAsyncGraph: %v", err) + } + if _, ok := graph.(*tg.StatsGraphError); !ok { + t.Fatalf("stats.loadAsyncGraph = %T, want statsGraphError", graph) + } + boostStatus, err := r.onPremiumGetBoostsStatus(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ + ChannelID: channel.ID, + AccessHash: channel.AccessHash, + }) + if err != nil { + t.Fatalf("premium.getBoostsStatus: %v", err) + } + if boostStatus.Level != 0 || boostStatus.Boosts != 0 { + t.Fatalf("premium.getBoostsStatus = %+v, want zero status", boostStatus) + } + boosts, err := r.onPremiumGetBoostsList(WithUserID(ctx, owner.ID), &tg.PremiumGetBoostsListRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("premium.getBoostsList: %v", err) + } + if boosts.Count != 0 || len(boosts.Boosts) != 0 || len(boosts.Users) != 0 { + t.Fatalf("premium.getBoostsList = %+v, want empty", boosts) + } + if _, err := r.onPremiumGetBoostsList(WithUserID(ctx, owner.ID), &tg.PremiumGetBoostsListRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: maxPremiumBoostsListLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("premium.getBoostsList huge limit err = %v, want LIMIT_INVALID", err) + } + myBoosts, err := r.onPremiumGetMyBoosts(WithUserID(ctx, owner.ID)) + if err != nil { + t.Fatalf("premium.getMyBoosts: %v", err) + } + if len(myBoosts.MyBoosts) != 0 || len(myBoosts.Chats) != 0 || len(myBoosts.Users) != 0 { + t.Fatalf("premium.getMyBoosts = %+v, want empty", myBoosts) + } + applyReq := &tg.PremiumApplyBoostRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + } + applyReq.SetSlots([]int{1}) + applied, err := r.onPremiumApplyBoost(WithUserID(ctx, owner.ID), applyReq) + if err != nil { + t.Fatalf("premium.applyBoost: %v", err) + } + if len(applied.MyBoosts) != 0 { + t.Fatalf("premium.applyBoost = %+v, want empty noop", applied) + } + if _, err := r.onPremiumApplyBoost(WithUserID(ctx, owner.ID), &tg.PremiumApplyBoostRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "BOOSTS_EMPTY") { + t.Fatalf("premium.applyBoost missing slots err = %v, want BOOSTS_EMPTY", err) + } + userBoosts, err := r.onPremiumGetUserBoosts(WithUserID(ctx, owner.ID), &tg.PremiumGetUserBoostsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }) + if err != nil { + t.Fatalf("premium.getUserBoosts: %v", err) + } + if userBoosts.Count != 0 || len(userBoosts.Boosts) != 0 { + t.Fatalf("premium.getUserBoosts = %+v, want empty", userBoosts) + } + if _, err := r.onMessagesAddChatUser(WithUserID(ctx, owner.ID), &tg.MessagesAddChatUserRequest{ + ChatID: channel.ID, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }); err != nil { + t.Fatalf("messages.addChatUser legacy wrapper: %v", err) + } + if _, err := r.onMessagesEditChatPhoto(WithUserID(ctx, owner.ID), &tg.MessagesEditChatPhotoRequest{ + ChatID: channel.ID, + Photo: &tg.InputChatPhotoEmpty{}, + }); err != nil { + t.Fatalf("messages.editChatPhoto legacy wrapper: %v", err) + } + if _, err := r.onChannelsEditPhoto(WithUserID(ctx, owner.ID), &tg.ChannelsEditPhotoRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Photo: &tg.InputChatUploadedPhoto{}, + }); err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { + t.Fatalf("channels.editPhoto uploaded photo err = %v, want PHOTO_INVALID", err) + } + if _, err := r.onChannelsEditPhoto(WithUserID(ctx, owner.ID), &tg.ChannelsEditPhotoRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Photo: &tg.InputChatPhoto{ID: &tg.InputPhoto{ID: 1, AccessHash: 2, FileReference: []byte{3}}}, + }); err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { + t.Fatalf("channels.editPhoto existing photo err = %v, want PHOTO_INVALID", err) + } + if ok, err := r.onMessagesEditChatAdmin(WithUserID(ctx, owner.ID), &tg.MessagesEditChatAdminRequest{ + ChatID: channel.ID, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + IsAdmin: true, + }); err != nil || !ok { + t.Fatalf("messages.editChatAdmin legacy wrapper = %v, %v", ok, err) + } + if _, err := r.onMessagesEditChatParticipantRank(WithUserID(ctx, owner.ID), &tg.MessagesEditChatParticipantRankRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + Participant: &tg.InputPeerUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + Rank: "ops", + }); err != nil { + t.Fatalf("messages.editChatParticipantRank legacy wrapper: %v", err) + } + if ok, err := r.onMessagesEditChatAbout(WithUserID(ctx, owner.ID), &tg.MessagesEditChatAboutRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + About: "legacy about", + }); err != nil || !ok { + t.Fatalf("messages.editChatAbout legacy wrapper = %v, %v", ok, err) + } + fullAfterAbout, err := r.onMessagesGetFullChat(WithUserID(ctx, owner.ID), channel.ID) + if err != nil { + t.Fatalf("messages.getFullChat after editChatAbout: %v", err) + } + if got := fullAfterAbout.FullChat.(*tg.ChannelFull).About; got != "legacy about" { + t.Fatalf("full chat about = %q, want legacy about", got) + } + if _, err := r.onMessagesEditChatCreator(WithUserID(ctx, owner.ID), &tg.MessagesEditChatCreatorRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }); err == nil { + t.Fatalf("messages.editChatCreator = nil error, want explicit password/2FA error") + } + permUpdates, err := r.onMessagesEditChatDefaultBannedRights(WithUserID(ctx, owner.ID), &tg.MessagesEditChatDefaultBannedRightsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + BannedRights: tg.ChatBannedRights{ + SendMessages: true, + }, + }) + if err != nil { + t.Fatalf("messages.editChatDefaultBannedRights: %v", err) + } + if len(permUpdates.(*tg.Updates).Updates) == 0 { + t.Fatalf("messages.editChatDefaultBannedRights updates = %+v, want updateChannel", permUpdates) + } + if _, err := r.onMessagesDeleteChatUser(WithUserID(ctx, owner.ID), &tg.MessagesDeleteChatUserRequest{ + ChatID: channel.ID, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }); err != nil { + t.Fatalf("messages.deleteChatUser legacy wrapper: %v", err) + } +} + +func incrementalViewCount(result *tg.MessagesMessageViews) (int, bool) { + if result == nil || len(result.Views) == 0 { + return 0, false + } + return result.Views[0].GetViews() +} + +func TestChannelAdminPinInviteRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002201", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002202", FirstName: "Friend"}) + joiner, _ := userStore.Create(ctx, domain.User{AccessHash: 53, Phone: "15550002203", FirstName: "Joiner"}) + invited, _ := userStore.Create(ctx, domain.User{AccessHash: 54, Phone: "15550002204", FirstName: "Invited"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Admin Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + + selfParticipant, err := r.onChannelsGetParticipant(WithUserID(ctx, friend.ID), &tg.ChannelsGetParticipantRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Participant: &tg.InputPeerSelf{}, + }) + if err != nil { + t.Fatalf("get self regular participant: %v", err) + } + if _, ok := selfParticipant.Participant.(*tg.ChannelParticipantSelf); !ok { + t.Fatalf("self regular participant = %T, want channelParticipantSelf", selfParticipant.Participant) + } + recentForFriend, err := r.onChannelsGetParticipants(WithUserID(ctx, friend.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get recent participants for regular self: %v", err) + } + foundSelf := false + for _, participant := range recentForFriend.(*tg.ChannelsChannelParticipants).Participants { + if _, ok := participant.(*tg.ChannelParticipantSelf); ok { + foundSelf = true + break + } + } + if !foundSelf { + t.Fatalf("recent participants = %+v, want current regular member as channelParticipantSelf", recentForFriend.(*tg.ChannelsChannelParticipants).Participants) + } + + adminUpdates, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + AdminRights: tg.ChatAdminRights{ + ChangeInfo: true, + InviteUsers: true, + PinMessages: true, + }, + Rank: "ops", + }) + if err != nil { + t.Fatalf("edit admin: %v", err) + } + if updates := adminUpdates.(*tg.Updates); len(updates.Updates) < 3 { + t.Fatalf("admin updates empty, want participant update") + } else if _, ok := updates.Updates[0].(*tg.UpdateChannelParticipant); !ok { + t.Fatalf("admin update[0] = %T, want updateChannelParticipant", updates.Updates[0]) + } else if tooLong, ok := updates.Updates[2].(*tg.UpdateChannelTooLong); !ok { + t.Fatalf("admin update[2] = %T, want updateChannelTooLong", updates.Updates[2]) + } else if pts, ok := tooLong.GetPts(); !ok || pts == 0 { + t.Fatalf("admin updateChannelTooLong pts = %d ok=%v, want set pts", pts, ok) + } + adminDiff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference after admin: %v", err) + } + adminFullDiff, ok := adminDiff.(*tg.UpdatesChannelDifference) + if !ok || len(adminFullDiff.OtherUpdates) == 0 { + t.Fatalf("admin diff = %T %+v, want participant other update", adminDiff, adminDiff) + } else if _, ok := adminFullDiff.OtherUpdates[0].(*tg.UpdateChannelParticipant); !ok { + t.Fatalf("admin diff update[0] = %T, want updateChannelParticipant", adminFullDiff.OtherUpdates[0]) + } + admins, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsAdmins{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get admin participants: %v", err) + } + if list := admins.(*tg.ChannelsChannelParticipants); len(list.Participants) != 2 { + t.Fatalf("admin participants = %+v, want creator and promoted admin", list.Participants) + } + + titleUpdates, err := r.onChannelsEditTitle(WithUserID(ctx, friend.ID), &tg.ChannelsEditTitleRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Title: "RPC Admin Group 2", + }) + if err != nil { + t.Fatalf("edit title: %v", err) + } + titleContainer := titleUpdates.(*tg.Updates) + if len(titleContainer.Updates) < 2 { + t.Fatalf("title updates = %+v, want channel + service message", titleContainer.Updates) + } + titleMsg, ok := titleContainer.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok { + t.Fatalf("title update[1] = %T, want updateNewChannelMessage", titleContainer.Updates[1]) + } + if action := titleMsg.Message.(*tg.MessageService).Action; action.(*tg.MessageActionChatEditTitle).Title != "RPC Admin Group 2" { + t.Fatalf("title action = %#v, want new title", action) + } + + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "pin me", + RandomID: 123, + }) + if err != nil { + t.Fatalf("send for pin: %v", err) + } + msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + pinUpdates, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, friend.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: msgID, + }) + if err != nil { + t.Fatalf("pin message: %v", err) + } + pinned, ok := pinUpdates.(*tg.Updates).Updates[0].(*tg.UpdatePinnedChannelMessages) + if !ok || !pinned.Pinned || pinned.Messages[0] != msgID { + t.Fatalf("pin update = %#v, want pinned channel message id=%d", pinUpdates.(*tg.Updates).Updates[0], msgID) + } + + invitedUsers, err := r.onChannelsInviteToChannel(WithUserID(ctx, friend.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}}, + }) + if err != nil { + t.Fatalf("invite to channel: %v", err) + } + if invitedUsers.Updates == nil || len(invitedUsers.MissingInvitees) != 0 { + t.Fatalf("invited users = %+v, want updates and no missing users", invitedUsers) + } + + invite, err := r.onMessagesExportChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesExportChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Title: "join", + }) + if err != nil { + t.Fatalf("export invite: %v", err) + } + exported := invite.(*tg.ChatInviteExported) + hash := strings.TrimPrefix(exported.Link, "https://t.me/+") + checked, err := r.onMessagesCheckChatInvite(WithUserID(ctx, joiner.ID), hash) + if err != nil { + t.Fatalf("check invite: %v", err) + } + if preview, ok := checked.(*tg.ChatInvite); !ok || !preview.Megagroup || preview.Title != "RPC Admin Group 2" { + t.Fatalf("invite preview = %#v, want megagroup title", checked) + } + imported, err := r.onMessagesImportChatInvite(WithUserID(ctx, joiner.ID), hash) + if err != nil { + t.Fatalf("import invite: %v", err) + } + if updates := imported.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) != 1 { + t.Fatalf("import updates = %+v, want chat and join service update", updates) + } + inviteList, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + AdminID: &tg.InputUserSelf{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get exported invites: %v", err) + } + if inviteList.Count != 1 || len(inviteList.Invites) != 1 || len(inviteList.Users) == 0 { + t.Fatalf("exported invite list = %+v, want persisted invite plus admin user context", inviteList) + } + listedInvite, ok := inviteList.Invites[0].(*tg.ChatInviteExported) + listedUsage, listedUsageOK := listedInvite.GetUsage() + listedTitle, listedTitleOK := listedInvite.GetTitle() + if !ok || listedInvite.Link != exported.Link || !listedUsageOK || listedUsage != 1 || !listedTitleOK || listedTitle != "join" { + t.Fatalf("listed invite = %#v, want exported link with one import", inviteList.Invites[0]) + } + if _, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + AdminID: &tg.InputUserSelf{}, + Limit: 101, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("get exported invites high limit err = %v, want LIMIT_INVALID", err) + } + inviteDetails, err := r.onMessagesGetExportedChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Link: exported.Link, + }) + if err != nil { + t.Fatalf("get exported invite: %v", err) + } + if details := inviteDetails.(*tg.MessagesExportedChatInvite); details.Invite == nil || len(details.Users) == 0 { + t.Fatalf("exported invite details = %+v, want invite plus user context", inviteDetails) + } + editInviteReq := &tg.MessagesEditExportedChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Link: exported.Link, + } + editInviteReq.SetTitle("ops link") + editedInvite, err := r.onMessagesEditExportedChatInvite(WithUserID(ctx, friend.ID), editInviteReq) + if err != nil { + t.Fatalf("edit exported invite: %v", err) + } + if edited := editedInvite.(*tg.MessagesExportedChatInvite); edited.Invite == nil || len(edited.Users) == 0 { + t.Fatalf("edited invite = %+v, want invite plus user context", editedInvite) + } else if got, ok := edited.Invite.(*tg.ChatInviteExported).GetTitle(); !ok || got != "ops link" { + t.Fatalf("edited invite title = %q, want ops link", got) + } + adminsWithInvites, err := r.onMessagesGetAdminsWithInvites(WithUserID(ctx, friend.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get admins with invites: %v", err) + } + if len(adminsWithInvites.Admins) != 1 || adminsWithInvites.Admins[0].AdminID != friend.ID || adminsWithInvites.Admins[0].InvitesCount != 1 || len(adminsWithInvites.Users) == 0 { + t.Fatalf("admins with invites = %+v, want self admin context with one invite", adminsWithInvites) + } + importers, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, friend.ID), &tg.MessagesGetChatInviteImportersRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get invite importers: %v", err) + } + if importers.Count != 1 || len(importers.Importers) != 1 || importers.Importers[0].UserID != joiner.ID || len(importers.Users) == 0 { + t.Fatalf("invite importers = %+v, want joined importer", importers) + } + importersSearchReq := &tg.MessagesGetChatInviteImportersRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + } + importersSearchReq.SetLink(exported.Link) + importersSearchReq.SetQ("bob") + if _, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, friend.ID), importersSearchReq); err == nil || !strings.Contains(err.Error(), "SEARCH_WITH_LINK_NOT_SUPPORTED") { + t.Fatalf("get invite importers q+link err = %v, want SEARCH_WITH_LINK_NOT_SUPPORTED", err) + } + if _, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, friend.ID), &tg.MessagesHideChatJoinRequestRequest{ + Approved: true, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "HIDE_REQUESTER_MISSING") { + t.Fatalf("hide chat join request without pending err = %v, want HIDE_REQUESTER_MISSING", err) + } + if updates, err := r.onMessagesHideAllChatJoinRequests(WithUserID(ctx, friend.ID), &tg.MessagesHideAllChatJoinRequestsRequest{ + Approved: false, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }); err != nil { + t.Fatalf("hide all chat join requests: %v", err) + } else if _, ok := updates.(*tg.Updates); !ok { + t.Fatalf("hide all chat join requests updates = %T, want *tg.Updates", updates) + } + if ok, err := r.onMessagesDeleteExportedChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesDeleteExportedChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Link: exported.Link, + }); err != nil || !ok { + t.Fatalf("delete exported invite ok=%v err=%v, want true nil", ok, err) + } + if ok, err := r.onMessagesDeleteRevokedExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesDeleteRevokedExportedChatInvitesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + AdminID: &tg.InputUserSelf{}, + }); err != nil || !ok { + t.Fatalf("delete revoked invites ok=%v err=%v, want true nil", ok, err) + } + adminLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 20, + }) + if err != nil { + t.Fatalf("get admin log: %v", err) + } + if len(adminLog.Events) < 5 || len(adminLog.Chats) != 1 || len(adminLog.Users) < 3 { + t.Fatalf("admin log = %+v, want events plus chat/users", adminLog) + } + tooManyAdmins := make([]tg.InputUserClass, domain.MaxChannelAdminLogAdmins+1) + for i := range tooManyAdmins { + tooManyAdmins[i] = &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash} + } + tooManyAdminsReq := &tg.ChannelsGetAdminLogRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 1, + } + tooManyAdminsReq.SetAdmins(tooManyAdmins) + if _, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), tooManyAdminsReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("get admin log too many admins err = %v, want LIMIT_INVALID", err) + } + pinnedFilter := tg.ChannelAdminLogEventsFilter{} + pinnedFilter.SetPinned(true) + pinnedReq := &tg.ChannelsGetAdminLogRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + } + pinnedReq.SetEventsFilter(pinnedFilter) + pinnedLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), pinnedReq) + if err != nil { + t.Fatalf("get pinned admin log: %v", err) + } + if len(pinnedLog.Events) != 1 { + t.Fatalf("pinned admin log events = %+v, want one", pinnedLog.Events) + } + if _, ok := pinnedLog.Events[0].Action.(*tg.ChannelAdminLogEventActionUpdatePinned); !ok { + t.Fatalf("pinned admin log action = %T, want updatePinned", pinnedLog.Events[0].Action) + } + unpinnedAll, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), &tg.MessagesUnpinAllMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("unpin all messages: %v", err) + } + if unpinnedAll.Pts == 0 || unpinnedAll.PtsCount != 1 || unpinnedAll.Offset != 0 { + t.Fatalf("unpin all affected history = %+v, want one channel pts event", unpinnedAll) + } + afterUnpin, err := r.deps.Channels.GetChannel(ctx, friend.ID, channel.ID) + if err != nil { + t.Fatalf("get channel after unpin: %v", err) + } + if afterUnpin.Channel.PinnedMessageID != 0 { + t.Fatalf("pinned message after unpin all = %d, want 0", afterUnpin.Channel.PinnedMessageID) + } + unpinnedAgain, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), &tg.MessagesUnpinAllMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("unpin all messages again: %v", err) + } + if unpinnedAgain.Pts != afterUnpin.Channel.Pts || unpinnedAgain.PtsCount != 0 || unpinnedAgain.Offset != 0 { + t.Fatalf("unpin all no-op affected history = %+v, want current pts with zero pts_count", unpinnedAgain) + } + invalidTopicUnpin := &tg.MessagesUnpinAllMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + } + invalidTopicUnpin.SetTopMsgID(domain.MaxMessageBoxID + 1) + if _, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), invalidTopicUnpin); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("unpin all invalid top msg err = %v, want MESSAGE_ID_INVALID", err) + } +} + +func TestChannelInviteKickedMemberRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 55, Phone: "15550002255", FirstName: "Owner"}) + helper, _ := userStore.Create(ctx, domain.User{AccessHash: 56, Phone: "15550002256", FirstName: "Helper"}) + kicked, _ := userStore.Create(ctx, domain.User{AccessHash: 57, Phone: "15550002257", FirstName: "Kicked"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{ + &tg.InputUser{UserID: helper.ID, AccessHash: helper.AccessHash}, + &tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}, + }, + Title: "RPC Invite Kicked", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{ + Channel: input, + Participant: &tg.InputPeerUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}, + BannedRights: tg.ChatBannedRights{ + ViewMessages: true, + }, + }); err != nil { + t.Fatalf("kick member: %v", err) + } + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, helper.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: input, + Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, + }); err == nil || !strings.Contains(err.Error(), "USER_KICKED") { + t.Fatalf("helper invite kicked err = %v, want USER_KICKED", err) + } + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: input, + Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, + }); err != nil { + t.Fatalf("owner restore kicked invite: %v", err) + } + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: input, + Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, + }); err == nil || !strings.Contains(err.Error(), "USER_ALREADY_PARTICIPANT") { + t.Fatalf("duplicate invite err = %v, want USER_ALREADY_PARTICIPANT", err) + } +} + +func TestImportChatInviteErrorsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 58, Phone: "15550002258", FirstName: "Owner"}) + first, _ := userStore.Create(ctx, domain.User{AccessHash: 59, Phone: "15550002259", FirstName: "First"}) + second, _ := userStore.Create(ctx, domain.User{AccessHash: 60, Phone: "15550002260", FirstName: "Second"}) + channelStore := memory.NewChannelStore() + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "RPC Import Errors", + Megagroup: true, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + inputChannel := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + requestInvite, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{ + Peer: input, + Title: "approval", + RequestNeeded: true, + }) + if err != nil { + t.Fatalf("export request-needed invite: %v", err) + } + requestHash := strings.TrimPrefix(requestInvite.(*tg.ChatInviteExported).Link, "https://t.me/+") + if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, first.ID), requestHash); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { + t.Fatalf("import request-needed err = %v, want INVITE_REQUEST_SENT", err) + } + pushedPending := sessions.snapshot() + if pushedPending.userID != owner.ID || pushedPending.messageType != proto.MessageFromServer { + t.Fatalf("pending request push = %+v, want owner server update", pushedPending) + } + pushedPendingUpdates, ok := pushedPending.message.(*tg.Updates) + if !ok || len(pushedPendingUpdates.Updates) != 1 { + t.Fatalf("pending request push message = %T %+v, want one update", pushedPending.message, pushedPending.message) + } + pushedPendingUpdate, ok := pushedPendingUpdates.Updates[0].(*tg.UpdatePendingJoinRequests) + if !ok || pushedPendingUpdate.RequestsPending != 1 || len(pushedPendingUpdate.RecentRequesters) != 1 || pushedPendingUpdate.RecentRequesters[0] != first.ID { + t.Fatalf("pending request update = %+v, want first requester", pushedPendingUpdates.Updates[0]) + } + fullAfterPending, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), inputChannel) + if err != nil { + t.Fatalf("get full channel after pending request: %v", err) + } + fullPending := fullAfterPending.FullChat.(*tg.ChannelFull) + requestsPending, ok := fullPending.GetRequestsPending() + recentRequesters, recentOK := fullPending.GetRecentRequesters() + if !ok || requestsPending != 1 || !recentOK || len(recentRequesters) != 1 || recentRequesters[0] != first.ID { + t.Fatalf("full pending = count %d ok %v recent %+v ok %v, want first requester", requestsPending, ok, recentRequesters, recentOK) + } + pendingReq := &tg.MessagesGetChatInviteImportersRequest{ + Requested: true, + Peer: input, + Limit: 10, + } + pendingReq.SetLink(requestInvite.(*tg.ChatInviteExported).Link) + pending, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) + if err != nil { + t.Fatalf("get pending invite importers: %v", err) + } + if pending.Count != 1 || len(pending.Importers) != 1 || pending.Importers[0].UserID != first.ID || !pending.Importers[0].Requested { + t.Fatalf("pending importers = %+v, want first pending request", pending) + } + limitedInvite, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{ + Peer: input, + Title: "one", + UsageLimit: 1, + }) + if err != nil { + t.Fatalf("export limited invite: %v", err) + } + limitedHash := strings.TrimPrefix(limitedInvite.(*tg.ChatInviteExported).Link, "https://t.me/+") + if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, first.ID), limitedHash); err != nil { + t.Fatalf("first import limited invite: %v", err) + } + pendingAfterJoin, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) + if err != nil { + t.Fatalf("get pending invite importers after join: %v", err) + } + if pendingAfterJoin.Count != 0 || len(pendingAfterJoin.Importers) != 0 { + t.Fatalf("pending importers after alternate invite join = %+v, want cleared", pendingAfterJoin) + } + if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, second.ID), limitedHash); err == nil || !strings.Contains(err.Error(), "USERS_TOO_MUCH") { + t.Fatalf("second import limited err = %v, want USERS_TOO_MUCH", err) + } + if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, second.ID), requestHash); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { + t.Fatalf("second import request-needed err = %v, want INVITE_REQUEST_SENT", err) + } + pendingSecond, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) + if err != nil { + t.Fatalf("get second pending invite importers: %v", err) + } + if pendingSecond.Count != 1 || len(pendingSecond.Importers) != 1 || pendingSecond.Importers[0].UserID != second.ID || !pendingSecond.Importers[0].Requested { + t.Fatalf("second pending importers = %+v, want second pending request", pendingSecond) + } + approved, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ + Approved: true, + Peer: input, + UserID: &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash}, + }) + if err != nil { + t.Fatalf("approve chat join request: %v", err) + } + if updates := approved.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) == 0 { + t.Fatalf("approve join request updates = %+v, want channel updates", updates) + } + approvedUpdates := approved.(*tg.Updates) + var pendingCleared *tg.UpdatePendingJoinRequests + for _, update := range approvedUpdates.Updates { + if pending, ok := update.(*tg.UpdatePendingJoinRequests); ok { + pendingCleared = pending + break + } + } + if pendingCleared == nil || pendingCleared.RequestsPending != 0 || len(pendingCleared.RecentRequesters) != 0 { + t.Fatalf("approve pending update = %+v, want cleared pending requests", pendingCleared) + } + if _, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ + Approved: true, + Peer: input, + UserID: &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "HIDE_REQUESTER_MISSING") { + t.Fatalf("approve missing join request err = %v, want HIDE_REQUESTER_MISSING", err) + } +} + +func TestChannelUsernameAndManagementRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"}) + requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Public Team", + About: "public", + Megagroup: true, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "channel management seed", + RandomID: 991, + }) + if err != nil { + t.Fatalf("send seed message: %v", err) + } + msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + + okUsername, err := r.onChannelsCheckUsername(WithUserID(ctx, owner.ID), &tg.ChannelsCheckUsernameRequest{ + Channel: input, + Username: "public_team", + }) + if err != nil || !okUsername { + t.Fatalf("check username = ok %v err %v, want true", okUsername, err) + } + updated, err := r.onChannelsUpdateUsername(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateUsernameRequest{ + Channel: input, + Username: "public_team", + }) + if err != nil || !updated { + t.Fatalf("update username = %v err %v, want true", updated, err) + } + admined, err := r.onChannelsGetAdminedPublicChannels(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminedPublicChannelsRequest{}) + if err != nil { + t.Fatalf("get admined public channels: %v", err) + } + adminedChats := admined.(*tg.MessagesChats).Chats + if len(adminedChats) != 1 || adminedChats[0].(*tg.Channel).Username != "public_team" { + t.Fatalf("admined chats = %+v, want public channel with username", adminedChats) + } + + signatures, err := r.onChannelsToggleSignatures(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSignaturesRequest{ + Channel: input, + SignaturesEnabled: true, + }) + if err != nil { + t.Fatalf("toggle signatures: %v", err) + } + if signed := signatures.(*tg.Updates).Chats[0].(*tg.Channel); !signed.Signatures { + t.Fatalf("signed channel = %+v, want signatures enabled", signed) + } + if _, err := r.onChannelsToggleSlowMode(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSlowModeRequest{Channel: input, Seconds: 7}); err == nil { + t.Fatalf("toggle slow mode invalid seconds err = nil, want SECONDS_INVALID") + } + slowUpdates, err := r.onChannelsToggleSlowMode(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSlowModeRequest{Channel: input, Seconds: 30}) + if err != nil { + t.Fatalf("toggle slow mode valid: %v", err) + } + if slowChannel := slowUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !slowChannel.SlowmodeEnabled { + t.Fatalf("slow mode channel = %+v, want slowmode enabled", slowChannel) + } + if ok, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{Channel: input, Stickerset: &tg.InputStickerSetEmpty{}}); err != nil || !ok { + t.Fatalf("set stickers = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{ + Channel: input, + Stickerset: &tg.InputStickerSetID{ID: 1, AccessHash: 2}, + }); err == nil || !strings.Contains(err.Error(), "STICKERSET_INVALID") { + t.Fatalf("set stickers non-empty err = %v, want STICKERSET_INVALID", err) + } + if ok, err := r.onChannelsReorderUsernames(WithUserID(ctx, owner.ID), &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"public_team"}}); err != nil || !ok { + t.Fatalf("reorder usernames = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsTogglePreHistoryHidden(WithUserID(ctx, owner.ID), &tg.ChannelsTogglePreHistoryHiddenRequest{Channel: input, Enabled: true}); err != nil { + t.Fatalf("toggle prehistory hidden: %v", err) + } + fullAfterSettings, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("get full channel after settings: %v", err) + } + fullSettings := fullAfterSettings.FullChat.(*tg.ChannelFull) + if !fullSettings.HiddenPrehistory || fullSettings.SlowmodeSeconds != 30 { + t.Fatalf("full settings = %+v, want hidden prehistory and slowmode=30", fullSettings) + } + joinToSendUpdates, err := r.onChannelsToggleJoinToSend(WithUserID(ctx, owner.ID), &tg.ChannelsToggleJoinToSendRequest{Channel: input, Enabled: true}) + if err != nil { + t.Fatalf("toggle join-to-send: %v", err) + } + if joinToSendChannel := joinToSendUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !joinToSendChannel.GetJoinToSend() { + t.Fatalf("join-to-send channel = %+v, want join_to_send flag", joinToSendChannel) + } + joinRequestUpdates, err := r.onChannelsToggleJoinRequest(WithUserID(ctx, owner.ID), &tg.ChannelsToggleJoinRequestRequest{Channel: input, Enabled: true}) + if err != nil { + t.Fatalf("toggle join request: %v", err) + } + if joinRequestChannel := joinRequestUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !joinRequestChannel.GetJoinRequest() { + t.Fatalf("join-request channel = %+v, want join_request flag", joinRequestChannel) + } + chatsWithJoinFlags, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channels after join settings: %v", err) + } + listedJoinChannel := chatsWithJoinFlags.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if !listedJoinChannel.GetJoinToSend() || !listedJoinChannel.GetJoinRequest() { + t.Fatalf("listed channel join flags = %+v, want both flags", listedJoinChannel) + } + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, requester.ID), input); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { + t.Fatalf("public join request err = %v, want INVITE_REQUEST_SENT", err) + } + pendingPublic, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), &tg.MessagesGetChatInviteImportersRequest{ + Requested: true, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get public pending join request: %v", err) + } + if pendingPublic.Count != 1 || len(pendingPublic.Importers) != 1 || pendingPublic.Importers[0].UserID != requester.ID || !pendingPublic.Importers[0].Requested { + t.Fatalf("public pending importers = %+v, want requester pending", pendingPublic) + } + fullWithPublicPending, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("get full channel with public pending request: %v", err) + } + fullPublicPending := fullWithPublicPending.FullChat.(*tg.ChannelFull) + publicPendingCount, ok := fullPublicPending.GetRequestsPending() + publicRecent, recentOK := fullPublicPending.GetRecentRequesters() + if !ok || publicPendingCount != 1 || !recentOK || len(publicRecent) != 1 || publicRecent[0] != requester.ID { + t.Fatalf("public full pending = count %d ok %v recent %+v ok %v, want requester", publicPendingCount, ok, publicRecent, recentOK) + } + approvedPublic, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ + Approved: true, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: requester.ID, AccessHash: requester.AccessHash}, + }) + if err != nil { + t.Fatalf("approve public join request: %v", err) + } + var publicPendingCleared *tg.UpdatePendingJoinRequests + for _, update := range approvedPublic.(*tg.Updates).Updates { + if pending, ok := update.(*tg.UpdatePendingJoinRequests); ok { + publicPendingCleared = pending + break + } + } + if publicPendingCleared == nil || publicPendingCleared.RequestsPending != 0 || len(publicPendingCleared.RecentRequesters) != 0 { + t.Fatalf("public approve pending update = %+v, want cleared pending requests", publicPendingCleared) + } + colorReq := &tg.ChannelsUpdateColorRequest{Channel: input} + colorReq.SetForProfile(true) + colorReq.SetColor(1) + colorReq.SetBackgroundEmojiID(9001) + colorUpdates, err := r.onChannelsUpdateColor(WithUserID(ctx, owner.ID), colorReq) + if err != nil { + t.Fatalf("update color: %v", err) + } + colorChannel := colorUpdates.(*tg.Updates).Chats[0].(*tg.Channel) + profileColor, ok := colorChannel.GetProfileColor() + if !ok { + t.Fatalf("update color channel = %+v, want profile color", colorChannel) + } + if peerColor := profileColor.(*tg.PeerColor); peerColor.Color != 1 || peerColor.BackgroundEmojiID != 9001 { + t.Fatalf("profile color = %+v, want color/background", peerColor) + } + status := &tg.EmojiStatus{DocumentID: 9101} + status.SetUntil(1700000100) + statusUpdates, err := r.onChannelsUpdateEmojiStatus(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateEmojiStatusRequest{Channel: input, EmojiStatus: status}) + if err != nil { + t.Fatalf("update emoji status: %v", err) + } + statusChannel := statusUpdates.(*tg.Updates).Chats[0].(*tg.Channel) + emojiStatus, ok := statusChannel.GetEmojiStatus() + if !ok { + t.Fatalf("update emoji status channel = %+v, want emoji status", statusChannel) + } + if got := emojiStatus.(*tg.EmojiStatus); got.DocumentID != 9101 || got.Until != 1700000100 { + t.Fatalf("emoji status = %+v, want document/until", got) + } + chatsWithAppearance, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channels after appearance settings: %v", err) + } + persistedAppearance := chatsWithAppearance.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if _, ok := persistedAppearance.GetProfileColor(); !ok { + t.Fatalf("persisted channel appearance = %+v, want profile color", persistedAppearance) + } + if _, ok := persistedAppearance.GetEmojiStatus(); !ok { + t.Fatalf("persisted channel appearance = %+v, want emoji status", persistedAppearance) + } + link, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Channel: input, ID: msgID}) + if err != nil { + t.Fatalf("export message link: %v", err) + } + if !strings.Contains(link.Link, "public_team") || !strings.Contains(link.Link, strconv.Itoa(msgID)) { + t.Fatalf("exported link = %+v, want username/message id", link) + } + replyToSeed := &tg.InputReplyToMessage{ReplyToMsgID: msgID} + replyReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "thread reply", + RandomID: 992, + } + replyReq.SetReplyTo(replyToSeed) + replySent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), replyReq) + if err != nil { + t.Fatalf("send reply for export link: %v", err) + } + replyMsgID := replySent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + threadLink, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Thread: true, Channel: input, ID: replyMsgID}) + if err != nil { + t.Fatalf("export thread message link: %v", err) + } + if !strings.Contains(threadLink.Link, strconv.Itoa(replyMsgID)) || !strings.Contains(threadLink.Link, "?thread="+strconv.Itoa(msgID)) { + t.Fatalf("thread exported link = %+v, want reply id and thread root %d", threadLink, msgID) + } + if _, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Channel: input, ID: replyMsgID + 10000}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("export missing message link err = %v, want MESSAGE_ID_INVALID", err) + } + if ok, err := r.onChannelsReadMessageContents(WithUserID(ctx, owner.ID), &tg.ChannelsReadMessageContentsRequest{Channel: input, ID: []int{msgID}}); err != nil || !ok { + t.Fatalf("read message contents = ok %v err %v, want true", ok, err) + } + author, err := r.onChannelsGetMessageAuthor(WithUserID(ctx, owner.ID), &tg.ChannelsGetMessageAuthorRequest{Channel: input, ID: msgID}) + if err != nil { + t.Fatalf("get message author: %v", err) + } + if author.(*tg.User).ID != owner.ID { + t.Fatalf("message author = %+v, want owner", author) + } + if _, err := r.onChannelsGetMessageAuthor(WithUserID(ctx, owner.ID), &tg.ChannelsGetMessageAuthorRequest{Channel: input, ID: msgID + 10000}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("get missing message author err = %v, want MESSAGE_ID_INVALID", err) + } + if ok, err := r.onChannelsReportSpam(WithUserID(ctx, owner.ID), &tg.ChannelsReportSpamRequest{ + Channel: input, + Participant: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + ID: []int{msgID}, + }); err != nil || !ok { + t.Fatalf("report spam = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsGetLeftChannels(WithUserID(ctx, owner.ID), 0); err != nil { + t.Fatalf("get left channels: %v", err) + } + if _, err := r.onChannelsGetInactiveChannels(WithUserID(ctx, owner.ID)); err != nil { + t.Fatalf("get inactive channels: %v", err) + } + if _, err := r.onChannelsGetGroupsForDiscussion(WithUserID(ctx, owner.ID)); err != nil { + t.Fatalf("get groups for discussion: %v", err) + } + if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{Broadcast: input, Group: &tg.InputChannelEmpty{}}); err == nil || !strings.Contains(err.Error(), "BROADCAST_ID_INVALID") { + t.Fatalf("set discussion group on megagroup err = %v, want BROADCAST_ID_INVALID", err) + } + if ok, err := r.onChannelsEditLocation(WithUserID(ctx, owner.ID), &tg.ChannelsEditLocationRequest{Channel: input}); err != nil || !ok { + t.Fatalf("edit location = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsConvertToGigagroup(WithUserID(ctx, owner.ID), input); err != nil { + t.Fatalf("convert to gigagroup: %v", err) + } + if affected, err := r.onChannelsDeleteParticipantHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteParticipantHistoryRequest{Channel: input, Participant: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}}); err != nil || affected.PtsCount != 3 || affected.Pts == 0 || affected.Offset != 0 { + t.Fatalf("delete participant history = %+v err %v, want one bounded delete update for owner service, text and reply messages", affected, err) + } + if updates, err := r.onChannelsToggleParticipantsHidden(WithUserID(ctx, owner.ID), &tg.ChannelsToggleParticipantsHiddenRequest{Channel: input, Enabled: true}); err != nil { + t.Fatalf("toggle participants hidden: %v", err) + } else if len(updates.(*tg.Updates).Chats) != 1 { + t.Fatalf("toggle participants hidden updates = %+v, want channel chat", updates) + } + fullHidden, err := r.onChannelsGetFullChannel(WithUserID(ctx, requester.ID), input) + if err != nil { + t.Fatalf("requester get full after participants hidden: %v", err) + } + fullChannel := fullHidden.FullChat.(*tg.ChannelFull) + if hidden := fullChannel.GetParticipantsHidden(); !hidden || fullChannel.CanViewParticipants { + t.Fatalf("requester full channel = %+v, want participants_hidden and can_view_participants=false", fullChannel) + } + hiddenMembers, err := r.onChannelsGetParticipants(WithUserID(ctx, requester.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: input, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("requester get participants hidden: %v", err) + } + if page := hiddenMembers.(*tg.ChannelsChannelParticipants); len(page.Participants) != 0 || page.Count == 0 { + t.Fatalf("hidden participants page = %+v, want empty page with aggregate count", page) + } + viewAsMessagesUpdates, err := r.onChannelsToggleViewForumAsMessages(WithUserID(ctx, owner.ID), &tg.ChannelsToggleViewForumAsMessagesRequest{Channel: input, Enabled: true}) + if err != nil { + t.Fatalf("toggle forum as messages: %v", err) + } + viewAsMessagesUpdate, ok := viewAsMessagesUpdates.(*tg.Updates).Updates[0].(*tg.UpdateChannelViewForumAsMessages) + if !ok || viewAsMessagesUpdate.ChannelID != channel.ID || !viewAsMessagesUpdate.Enabled { + t.Fatalf("toggle forum as messages updates = %+v, want updateChannelViewForumAsMessages", viewAsMessagesUpdates) + } + fullWithViewAsMessages, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("full channel after view as messages: %v", err) + } + if full := fullWithViewAsMessages.FullChat.(*tg.ChannelFull); !full.ViewForumAsMessages { + t.Fatalf("full channel view_forum_as_messages = false, want true") + } + forumUpdates, err := r.onChannelsToggleForum(WithUserID(ctx, owner.ID), &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}) + if err != nil { + t.Fatalf("toggle forum: %v", err) + } + if len(forumUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("toggle forum updates = %+v, want channel chat", forumUpdates) + } + forumChat, ok := forumUpdates.(*tg.Updates).Chats[0].(*tg.Channel) + if !ok || !forumChat.Forum || !forumChat.ForumTabs { + t.Fatalf("toggle forum chat = %+v, want forum+forum_tabs", forumUpdates.(*tg.Updates).Chats[0]) + } + forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + forumTopics, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics after toggle: %v", err) + } + if forumTopics.Count != 1 || len(forumTopics.Topics) != 1 || len(forumTopics.Chats) != 1 || forumTopics.Pts == 0 { + t.Fatalf("messages.getForumTopics after toggle = %+v, want general topic with channel context", forumTopics) + } + generalTopic, ok := forumTopics.Topics[0].(*tg.ForumTopic) + if !ok || generalTopic.ID != forumGeneralTopicID || generalTopic.Title != "General" { + t.Fatalf("forum topic = %+v, want General topic", forumTopics.Topics[0]) + } + forumTopicsByID, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ + Peer: forumPeer, + Topics: []int{forumGeneralTopicID}, + }) + if err != nil { + t.Fatalf("messages.getForumTopicsByID after toggle: %v", err) + } + if forumTopicsByID.Count != 1 || len(forumTopicsByID.Topics) != 1 { + t.Fatalf("messages.getForumTopicsByID after toggle = %+v, want General topic", forumTopicsByID) + } + createdForumTopic, err := r.onMessagesCreateForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesCreateForumTopicRequest{ + Peer: forumPeer, + Title: "Ops", + IconColor: domain.DefaultForumTopicIconColor, + RandomID: 17803001, + }) + if err != nil { + t.Fatalf("messages.createForumTopic after toggle: %v", err) + } + var topicID int + for _, update := range createdForumTopic.(*tg.Updates).Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + msg, ok := newMsg.Message.(*tg.MessageService) + if !ok { + continue + } + action, ok := msg.Action.(*tg.MessageActionTopicCreate) + if ok && action.Title == "Ops" { + topicID = msg.ID + break + } + } + if topicID == 0 { + t.Fatalf("messages.createForumTopic updates = %+v, want topic root service message", createdForumTopic) + } + forumTopicsWithCreated, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics with created topic: %v", err) + } + if forumTopicsWithCreated.Count != 2 || len(forumTopicsWithCreated.Topics) != 2 || len(forumTopicsWithCreated.Messages) == 0 { + t.Fatalf("messages.getForumTopics with created topic = %+v, want General + created topic + root message", forumTopicsWithCreated) + } + var foundCreated bool + for _, item := range forumTopicsWithCreated.Topics { + topic, ok := item.(*tg.ForumTopic) + if ok && topic.ID == topicID && topic.Title == "Ops" && topic.TopMessage == topicID { + foundCreated = true + break + } + } + if !foundCreated { + t.Fatalf("messages.getForumTopics topics = %+v, want created topic id %d", forumTopicsWithCreated.Topics, topicID) + } + forumTopicsByID, err = r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ + Peer: forumPeer, + Topics: []int{forumGeneralTopicID, topicID}, + }) + if err != nil { + t.Fatalf("messages.getForumTopicsByID with created topic: %v", err) + } + if forumTopicsByID.Count != 2 || len(forumTopicsByID.Topics) != 2 || len(forumTopicsByID.Messages) == 0 { + t.Fatalf("messages.getForumTopicsByID with created topic = %+v, want General + created topic", forumTopicsByID) + } + topicReply := &tg.InputReplyToMessage{ReplyToMsgID: 0} + topicReply.SetTopMsgID(topicID) + topicSendReq := &tg.MessagesSendMessageRequest{ + Peer: forumPeer, + Message: "topic body", + RandomID: 17803002, + } + topicSendReq.SetReplyTo(topicReply) + topicSent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), topicSendReq) + if err != nil { + t.Fatalf("messages.sendMessage topic-only reply: %v", err) + } + var topicMsg *tg.Message + for _, update := range topicSent.(*tg.Updates).Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + if msg, ok := newMsg.Message.(*tg.Message); ok && msg.Message == "topic body" { + topicMsg = msg + break + } + } + if topicMsg == nil { + t.Fatalf("messages.sendMessage topic updates = %+v, want topic message", topicSent) + } + header, ok := topicMsg.ReplyTo.(*tg.MessageReplyHeader) + if !ok || !header.ForumTopic || header.ReplyToMsgID != 0 { + t.Fatalf("topic reply header = %#v, want forum topic without reply_to_msg_id", topicMsg.ReplyTo) + } + if topID, ok := header.GetReplyToTopID(); !ok || topID != topicID { + t.Fatalf("topic reply top id = %d ok %v, want %d", topID, ok, topicID) + } + topicReplies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ + Peer: forumPeer, + MsgID: topicID, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getReplies forum topic: %v", err) + } + topicReplyPage, ok := topicReplies.(*tg.MessagesChannelMessages) + if !ok || topicReplyPage.Pts == 0 || len(topicReplyPage.Messages) != 1 || len(topicReplyPage.Topics) != 1 { + t.Fatalf("messages.getReplies forum topic = %T %+v, want channelMessages with one topic", topicReplies, topicReplies) + } + if got := topicReplyPage.Messages[0].(*tg.Message); got.ID != topicMsg.ID || got.Message != "topic body" { + t.Fatalf("topic reply message = %#v, want id %d", got, topicMsg.ID) + } + if topic, ok := topicReplyPage.Topics[0].(*tg.ForumTopic); !ok || !topic.Short || topic.ID != topicID || topic.TopMessage != topicMsg.ID { + t.Fatalf("topic reply page topic = %#v, want short topic with updated top message %d", topicReplyPage.Topics[0], topicMsg.ID) + } + forumTopicsAfterMessage, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics after topic message: %v", err) + } + var foundTopicTop bool + for _, item := range forumTopicsAfterMessage.Topics { + topic, ok := item.(*tg.ForumTopic) + if ok && topic.ID == topicID && topic.TopMessage == topicMsg.ID { + foundTopicTop = true + break + } + } + if !foundTopicTop { + t.Fatalf("messages.getForumTopics after topic message topics = %+v, want top message %d", forumTopicsAfterMessage.Topics, topicMsg.ID) + } + forwardToTopicReq := &tg.MessagesForwardMessagesRequest{ + FromPeer: forumPeer, + ToPeer: forumPeer, + ID: []int{topicMsg.ID}, + RandomID: []int64{17803003}, + } + forwardToTopicReq.SetTopMsgID(topicID) + forwardToTopic, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), forwardToTopicReq) + if err != nil { + t.Fatalf("messages.forwardMessages to forum topic: %v", err) + } + var forwardedTopicMsg *tg.Message + for _, update := range forwardToTopic.(*tg.Updates).Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + if msg, ok := newMsg.Message.(*tg.Message); ok && msg.Message == "topic body" && msg.ID != topicMsg.ID { + forwardedTopicMsg = msg + break + } + } + if forwardedTopicMsg == nil { + t.Fatalf("messages.forwardMessages to topic updates = %+v, want forwarded topic message", forwardToTopic) + } + if _, ok := forwardedTopicMsg.GetFwdFrom(); !ok { + t.Fatalf("forwarded topic message = %#v, want fwd header", forwardedTopicMsg) + } + forwardReply, ok := forwardedTopicMsg.ReplyTo.(*tg.MessageReplyHeader) + if !ok || !forwardReply.ForumTopic || forwardReply.ReplyToMsgID != 0 { + t.Fatalf("forward topic reply header = %#v, want forum topic without reply_to_msg_id", forwardedTopicMsg.ReplyTo) + } + if topID, ok := forwardReply.GetReplyToTopID(); !ok || topID != topicID { + t.Fatalf("forward topic reply top id = %d ok %v, want %d", topID, ok, topicID) + } + forumTopicsAfterForward, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics after forward: %v", err) + } + var foundForwardTop bool + for _, item := range forumTopicsAfterForward.Topics { + topic, ok := item.(*tg.ForumTopic) + if ok && topic.ID == topicID && topic.TopMessage == forwardedTopicMsg.ID { + foundForwardTop = true + break + } + } + if !foundForwardTop { + t.Fatalf("messages.getForumTopics after forward topics = %+v, want top message %d", forumTopicsAfterForward.Topics, forwardedTopicMsg.ID) + } + invalidForwardTop := &tg.MessagesForwardMessagesRequest{ + FromPeer: forumPeer, + ToPeer: forumPeer, + ID: []int{topicMsg.ID}, + RandomID: []int64{17803004}, + } + invalidForwardTop.SetTopMsgID(domain.MaxMessageBoxID + 1) + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), invalidForwardTop); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("messages.forwardMessages huge top_msg_id err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } + editTopicReq := &tg.MessagesEditForumTopicRequest{Peer: forumPeer, TopicID: topicID} + editTopicReq.SetTitle("Ops 2") + editTopicReq.SetClosed(true) + editedForumTopic, err := r.onMessagesEditForumTopic(WithUserID(ctx, owner.ID), editTopicReq) + if err != nil { + t.Fatalf("messages.editForumTopic: %v", err) + } + var editedRootID int + for _, update := range editedForumTopic.(*tg.Updates).Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + msg, ok := newMsg.Message.(*tg.MessageService) + if !ok { + continue + } + action, ok := msg.Action.(*tg.MessageActionTopicEdit) + if ok && action.Title == "Ops 2" { + if closed, closedOK := action.GetClosed(); closedOK && closed { + editedRootID = msg.ID + break + } + } + } + if editedRootID == 0 { + t.Fatalf("messages.editForumTopic updates = %+v, want topic edit service message", editedForumTopic) + } + pinnedTopic, err := r.onMessagesUpdatePinnedForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesUpdatePinnedForumTopicRequest{ + Peer: forumPeer, + TopicID: topicID, + Pinned: true, + }) + if err != nil { + t.Fatalf("messages.updatePinnedForumTopic: %v", err) + } + if got := pinnedTopic.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.updatePinnedForumTopic updates = %+v, want one update", got) + } else if update, ok := got[0].(*tg.UpdatePinnedForumTopic); !ok || update.TopicID != topicID || !update.GetPinned() { + t.Fatalf("messages.updatePinnedForumTopic update = %+v, want pinned topic", got[0]) + } + reorderedTopics, err := r.onMessagesReorderPinnedForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedForumTopicsRequest{ + Peer: forumPeer, + Order: []int{topicID}, + Force: true, + }) + if err != nil { + t.Fatalf("messages.reorderPinnedForumTopics: %v", err) + } + if got := reorderedTopics.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.reorderPinnedForumTopics updates = %+v, want one update", got) + } else if update, ok := got[0].(*tg.UpdatePinnedForumTopics); !ok || len(update.Order) != 1 || update.Order[0] != topicID { + t.Fatalf("messages.reorderPinnedForumTopics update = %+v, want order", got[0]) + } + deletedTopic, err := r.onMessagesDeleteTopicHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteTopicHistoryRequest{ + Peer: forumPeer, + TopMsgID: topicID, + }) + if err != nil { + t.Fatalf("messages.deleteTopicHistory: %v", err) + } + if deletedTopic.Pts == 0 || deletedTopic.PtsCount == 0 || deletedTopic.Offset != 0 { + t.Fatalf("messages.deleteTopicHistory = %+v, want affected history with final offset", deletedTopic) + } + forumTopicsAfterDelete, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics after delete topic: %v", err) + } + if forumTopicsAfterDelete.Count != 1 || len(forumTopicsAfterDelete.Topics) != 1 { + t.Fatalf("messages.getForumTopics after delete topic = %+v, want General only", forumTopicsAfterDelete) + } + antiSpamUpdates, err := r.onChannelsToggleAntiSpam(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAntiSpamRequest{Channel: input, Enabled: true}) + if err != nil { + t.Fatalf("toggle antispam: %v", err) + } + if len(antiSpamUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("toggle antispam updates = %+v, want channel chat", antiSpamUpdates) + } + fullWithAntiSpam, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("full channel after antispam: %v", err) + } + if full := fullWithAntiSpam.FullChat.(*tg.ChannelFull); !full.GetAntispam() { + t.Fatalf("full channel antispam = false, want true") + } + settingsFilter := tg.ChannelAdminLogEventsFilter{} + settingsFilter.SetSettings(true) + antiSpamLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ + Channel: input, + EventsFilter: settingsFilter, + Limit: 10, + }) + if err != nil { + t.Fatalf("admin log after antispam: %v", err) + } + foundAntiSpamLog := false + foundForumLog := false + for _, event := range antiSpamLog.Events { + if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleAntiSpam); ok && action.NewValue { + foundAntiSpamLog = true + } + if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleForum); ok && action.NewValue { + foundForumLog = true + } + } + if !foundAntiSpamLog || !foundForumLog { + t.Fatalf("admin log events = %+v, want toggle antispam and toggle forum actions", antiSpamLog.Events) + } + channelUpdateStubs := []struct { + name string + call func() (tg.UpdatesClass, error) + }{ + {"set boosts", func() (tg.UpdatesClass, error) { + return r.onChannelsSetBoostsToUnblockRestrictions(WithUserID(ctx, owner.ID), &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{Channel: input, Boosts: 1}) + }}, + {"restrict sponsored", func() (tg.UpdatesClass, error) { + return r.onChannelsRestrictSponsoredMessages(WithUserID(ctx, owner.ID), &tg.ChannelsRestrictSponsoredMessagesRequest{Channel: input, Restricted: true}) + }}, + {"update paid messages", func() (tg.UpdatesClass, error) { + return r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: 7}) + }}, + {"toggle autotranslation", func() (tg.UpdatesClass, error) { + return r.onChannelsToggleAutotranslation(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAutotranslationRequest{Channel: input, Enabled: true}) + }}, + } + for _, item := range channelUpdateStubs { + updates, err := item.call() + if err != nil { + t.Fatalf("%s: %v", item.name, err) + } + if len(updates.(*tg.Updates).Chats) != 1 { + t.Fatalf("%s updates = %+v, want channel chat", item.name, updates) + } + } + fullAfterPaidSettings, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("full channel after paid/settings toggles: %v", err) + } + fullPaidSettings := fullAfterPaidSettings.FullChat.(*tg.ChannelFull) + if !fullPaidSettings.GetRestrictedSponsored() { + t.Fatalf("full channel restricted_sponsored = false, want true") + } + if stars, ok := fullPaidSettings.GetSendPaidMessagesStars(); !fullPaidSettings.GetPaidMessagesAvailable() || !ok || stars != 7 { + t.Fatalf("full channel paid messages = available %v stars %d ok %v, want available true stars 7", fullPaidSettings.GetPaidMessagesAvailable(), stars, ok) + } + chatsAfterAutotranslation, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channels after autotranslation: %v", err) + } + channelAfterAutotranslation := chatsAfterAutotranslation.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if !channelAfterAutotranslation.GetAutotranslation() { + t.Fatalf("channel autotranslation = false, want true") + } + autoLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ + Channel: input, + EventsFilter: settingsFilter, + Limit: 10, + }) + if err != nil { + t.Fatalf("admin log after autotranslation: %v", err) + } + foundAutoLog := false + for _, event := range autoLog.Events { + if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleAutotranslation); ok && action.NewValue { + foundAutoLog = true + break + } + } + if !foundAutoLog { + t.Fatalf("admin log events = %+v, want toggle autotranslation action", autoLog.Events) + } + if _, err := r.onChannelsSetBoostsToUnblockRestrictions(WithUserID(ctx, owner.ID), &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{Channel: input, Boosts: maxChannelBoostsToUnblockRestrictions + 1}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("set boosts over max err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: -1}); err == nil || !strings.Contains(err.Error(), "STARS_AMOUNT_INVALID") { + t.Fatalf("update paid messages negative supergroup err = %v, want STARS_AMOUNT_INVALID", err) + } + if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: maxChannelPaidMessageStars + 1}); err == nil || !strings.Contains(err.Error(), "STARS_AMOUNT_INVALID") { + t.Fatalf("update paid messages over max err = %v, want STARS_AMOUNT_INVALID", err) + } + broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Broadcast Paid", + About: "broadcast direct messages", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel) + broadcastInput := &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash} + if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: broadcastInput, SendPaidMessagesStars: -1}); err != nil { + t.Fatalf("update paid messages broadcast disable: %v", err) + } + if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{ + BroadcastMessagesAllowed: true, + Channel: broadcastInput, + SendPaidMessagesStars: maxChannelPaidMessageStars, + }); err != nil { + t.Fatalf("update paid messages broadcast max: %v", err) + } + broadcastChats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{broadcastInput}) + if err != nil { + t.Fatalf("get broadcast after paid messages: %v", err) + } + broadcastAfterPaid := broadcastChats.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if !broadcastAfterPaid.GetBroadcastMessagesAllowed() { + t.Fatalf("broadcast_messages_allowed = false, want true") + } + if stars, ok := broadcastAfterPaid.GetSendPaidMessagesStars(); !ok || stars != maxChannelPaidMessageStars { + t.Fatalf("broadcast send_paid_messages_stars = %d ok %v, want %d true", stars, ok, maxChannelPaidMessageStars) + } + if _, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{Channel: broadcastInput, Stickerset: &tg.InputStickerSetEmpty{}}); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { + t.Fatalf("set stickers broadcast err = %v, want CHANNEL_INVALID", err) + } + if ok, err := r.onChannelsReportAntiSpamFalsePositive(WithUserID(ctx, owner.ID), &tg.ChannelsReportAntiSpamFalsePositiveRequest{Channel: input, MsgID: msgID}); err != nil || !ok { + t.Fatalf("report antispam false positive = ok %v err %v, want true", ok, err) + } + recommendationsReq := &tg.ChannelsGetChannelRecommendationsRequest{} + recommendationsReq.SetChannel(input) + if _, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { + t.Fatalf("megagroup channel recommendations err = %v, want CHANNEL_INVALID", err) + } + if ok, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{Channel: input, Stickerset: &tg.InputStickerSetEmpty{}}); err != nil || !ok { + t.Fatalf("set emoji stickers = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{ + Channel: input, + Stickerset: &tg.InputStickerSetShortName{ShortName: "custom_emoji"}, + }); err == nil || !strings.Contains(err.Error(), "STICKERSET_INVALID") { + t.Fatalf("set emoji stickers non-empty err = %v, want STICKERSET_INVALID", err) + } + if _, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{Channel: broadcastInput, Stickerset: &tg.InputStickerSetEmpty{}}); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { + t.Fatalf("set emoji stickers broadcast err = %v, want CHANNEL_INVALID", err) + } + searchPostsReq := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 10} + searchPostsReq.SetHashtag("ops") + if posts, err := r.onChannelsSearchPosts(WithUserID(ctx, owner.ID), searchPostsReq); err != nil || len(posts.(*tg.MessagesMessages).Messages) != 0 { + t.Fatalf("search posts = %+v err %v, want empty", posts, err) + } + floodReq := &tg.ChannelsCheckSearchPostsFloodRequest{} + floodReq.SetQuery("ops") + if flood, err := r.onChannelsCheckSearchPostsFlood(WithUserID(ctx, owner.ID), floodReq); err != nil || !flood.QueryIsFree { + t.Fatalf("check search posts flood = %+v err %v, want free", flood, err) + } + if ok, err := r.onChannelsSetMainProfileTab(WithUserID(ctx, owner.ID), &tg.ChannelsSetMainProfileTabRequest{Channel: input}); err != nil || !ok { + t.Fatalf("set main profile tab = ok %v err %v, want true", ok, err) + } + if ok, err := r.onChannelsToggleUsername(WithUserID(ctx, owner.ID), &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "public_team", Active: false}); err != nil || !ok { + t.Fatalf("toggle username = ok %v err %v, want true", ok, err) + } + if ok, err := r.onChannelsDeactivateAllUsernames(WithUserID(ctx, owner.ID), input); err != nil || !ok { + t.Fatalf("deactivate usernames = ok %v err %v, want true", ok, err) + } + stillPublic, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channel after deactivate: %v", err) + } + if got := stillPublic.(*tg.MessagesChats).Chats[0].(*tg.Channel); got.Username != "public_team" { + t.Fatalf("channel after fragment username stubs = %+v, want primary username preserved", got) + } + if ok, err := r.onChannelsUpdateUsername(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateUsernameRequest{Channel: input, Username: ""}); err != nil || !ok { + t.Fatalf("clear username = ok %v err %v, want true", ok, err) + } + after, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channel after clear: %v", err) + } + if got := after.(*tg.MessagesChats).Chats[0].(*tg.Channel); got.Username != "" || len(got.Usernames) != 0 { + t.Fatalf("channel after clear = %+v, want username cleared", got) + } +} + +func TestChannelsGetLeftChannelsRPCReturnsLeftFlagAndSafePaging(t *testing.T) { + ctx := context.Background() + const ( + ownerID int64 = 1000000901 + memberID int64 = 1000000902 + ) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System) + older, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Older Left", + MemberUserIDs: []int64{memberID}, + Date: 1700000900, + }) + if err != nil { + t.Fatalf("create older megagroup: %v", err) + } + newer, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Newer Left Broadcast", + Broadcast: true, + MemberUserIDs: []int64{memberID}, + Date: 1700000901, + }) + if err != nil { + t.Fatalf("create newer broadcast: %v", err) + } + if _, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Active Excluded", + MemberUserIDs: []int64{memberID}, + Date: 1700000902, + }); err != nil { + t.Fatalf("create active megagroup: %v", err) + } + if _, err := channelService.LeaveChannel(ctx, memberID, older.Channel.ID, 1700000903); err != nil { + t.Fatalf("leave older channel: %v", err) + } + if _, err := channelService.LeaveChannel(ctx, memberID, newer.Channel.ID, 1700000904); err != nil { + t.Fatalf("leave newer channel: %v", err) + } + + got, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), 0) + if err != nil { + t.Fatalf("get left channels: %v", err) + } + chats, ok := got.(*tg.MessagesChats) + if !ok || len(chats.Chats) != 2 { + t.Fatalf("left channels = %T %+v, want final messages.chats with two chats", got, got) + } + first, ok := chats.Chats[0].(*tg.Channel) + if !ok || first.ID != newer.Channel.ID || !first.Left { + t.Fatalf("first left channel = %+v (%T), want newest with left flag", chats.Chats[0], chats.Chats[0]) + } + second, ok := chats.Chats[1].(*tg.Channel) + if !ok || second.ID != older.Channel.ID || !second.Left { + t.Fatalf("second left channel = %+v (%T), want older with left flag", chats.Chats[1], chats.Chats[1]) + } + + empty, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), 2) + if err != nil { + t.Fatalf("get empty left channels page: %v", err) + } + emptySlice, ok := empty.(*tg.MessagesChatsSlice) + if !ok || emptySlice.Count != 2 || len(emptySlice.Chats) != 0 { + t.Fatalf("empty left page = %T %+v, want empty slice with full count", empty, empty) + } + if _, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), domain.MaxLeftChannelsOffset+1); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("huge offset err = %v, want LIMIT_INVALID", err) + } +} + +func TestChannelsDiscussionGroupRPCPersistsFullChannelLink(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1000000911 + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System) + broadcast, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Discussion Broadcast", + Broadcast: true, + Date: 1700000910, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + group, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Discussion Group", + Date: 1700000911, + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash} + inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash} + + candidates, err := r.onChannelsGetGroupsForDiscussion(WithUserID(ctx, ownerID)) + if err != nil { + t.Fatalf("get groups for discussion: %v", err) + } + candidateChats := candidates.(*tg.MessagesChats).Chats + if len(candidateChats) != 1 || candidateChats[0].(*tg.Channel).ID != group.Channel.ID { + t.Fatalf("discussion candidates = %+v, want the creator megagroup", candidateChats) + } + if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: inputBroadcast, + Group: inputGroup, + }); err != nil || !ok { + t.Fatalf("set discussion group = ok %v err %v, want true", ok, err) + } + fullBroadcast, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputBroadcast) + if err != nil { + t.Fatalf("get full broadcast: %v", err) + } + linkedID, ok := fullBroadcast.FullChat.(*tg.ChannelFull).GetLinkedChatID() + if !ok || linkedID != group.Channel.ID { + t.Fatalf("broadcast linked_chat_id = %d ok %v, want group %d", linkedID, ok, group.Channel.ID) + } + fullGroup, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputGroup) + if err != nil { + t.Fatalf("get full group: %v", err) + } + groupLinkedID, ok := fullGroup.FullChat.(*tg.ChannelFull).GetLinkedChatID() + if !ok || groupLinkedID != broadcast.Channel.ID { + t.Fatalf("group linked_chat_id = %d ok %v, want broadcast %d", groupLinkedID, ok, broadcast.Channel.ID) + } + gotChannel := fullBroadcast.Chats[0].(*tg.Channel) + if !gotChannel.GetHasLink() { + t.Fatalf("broadcast channel = %+v, want has_link", gotChannel) + } + if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: &tg.InputChannelEmpty{}, + Group: inputGroup, + }); err != nil || !ok { + t.Fatalf("unlink discussion group from group side = ok %v err %v, want true", ok, err) + } + fullBroadcast, err = r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputBroadcast) + if err != nil { + t.Fatalf("get full broadcast after unlink: %v", err) + } + if linkedID, ok := fullBroadcast.FullChat.(*tg.ChannelFull).GetLinkedChatID(); ok || linkedID != 0 { + t.Fatalf("broadcast linked_chat_id after unlink = %d ok %v, want unset", linkedID, ok) + } + if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: &tg.InputChannelEmpty{}, + Group: inputGroup, + }); err == nil || !strings.Contains(err.Error(), "LINK_NOT_MODIFIED") { + t.Fatalf("repeat unlink err = %v, want LINK_NOT_MODIFIED", err) + } + if _, err := channelService.SetPreHistoryHidden(ctx, ownerID, group.Channel.ID, true); err != nil { + t.Fatalf("hide group prehistory: %v", err) + } + if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: inputBroadcast, + Group: inputGroup, + }); err == nil || !strings.Contains(err.Error(), "MEGAGROUP_PREHISTORY_HIDDEN") { + t.Fatalf("hidden group link err = %v, want MEGAGROUP_PREHISTORY_HIDDEN", err) + } +} + +func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550002911", FirstName: "Owner"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 92, Phone: "15550002912", FirstName: "Member"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + broadcast, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Discussion Source", + Broadcast: true, + Date: 1700002911, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + group, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Discussion Replies", + MemberUserIDs: []int64{member.ID}, + Date: 1700002912, + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash} + inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash} + if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: inputBroadcast, + Group: inputGroup, + }); err != nil || !ok { + t.Fatalf("set discussion group = ok %v err %v, want true", ok, err) + } + + postUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + Message: "channel post", + RandomID: 2911001, + }) + if err != nil { + t.Fatalf("send broadcast post: %v", err) + } + post := postUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if !post.Post { + t.Fatalf("broadcast post = %#v, want channel post", post) + } + discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + MsgID: post.ID, + }) + if err != nil { + t.Fatalf("get discussion message: %v", err) + } + if len(discussion.Messages) != 1 || len(discussion.Chats) != 2 { + t.Fatalf("discussion = %+v, want linked root message with source and group chats", discussion) + } + root, ok := discussion.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("discussion root = %T, want message", discussion.Messages[0]) + } + if peer, ok := root.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != group.Channel.ID { + t.Fatalf("discussion root peer = %#v, want linked group %d", root.PeerID, group.Channel.ID) + } + if from, ok := root.FromID.(*tg.PeerChannel); !ok || from.ChannelID != broadcast.Channel.ID { + t.Fatalf("discussion root from = %#v, want source channel %d", root.FromID, broadcast.Channel.ID) + } + fwd, ok := root.GetFwdFrom() + if !ok { + t.Fatalf("discussion root fwd_from missing") + } + if channelPost, ok := fwd.GetChannelPost(); !ok || channelPost != post.ID { + t.Fatalf("discussion root channel_post = %d ok %v, want %d", channelPost, ok, post.ID) + } + if savedMsgID, ok := fwd.GetSavedFromMsgID(); !ok || savedMsgID != post.ID { + t.Fatalf("discussion root saved_from_msg_id = %d ok %v, want %d", savedMsgID, ok, post.ID) + } + if savedPeer, ok := fwd.GetSavedFromPeer(); !ok { + t.Fatalf("discussion root saved_from_peer missing") + } else if savedChannel, ok := savedPeer.(*tg.PeerChannel); !ok || savedChannel.ChannelID != broadcast.Channel.ID { + t.Fatalf("discussion root saved_from_peer = %#v, want source channel %d", savedPeer, broadcast.Channel.ID) + } + + replyTo := &tg.InputReplyToMessage{ReplyToMsgID: root.ID} + replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), func() *tg.MessagesSendMessageRequest { + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, + Message: "discussion reply", + RandomID: 2911002, + } + req.SetReplyTo(replyTo) + return req + }()) + if err != nil { + t.Fatalf("send discussion reply: %v", err) + } + comment := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + replies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + MsgID: post.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("get replies: %v", err) + } + replyMessages, replyChats, _ := searchMessagesPayload(t, replies) + if len(replyMessages) != 1 || len(replyChats) != 2 { + t.Fatalf("get replies = %T %+v, want one linked group reply with both channel contexts", replies, replies) + } + gotReply := replyMessages[0].(*tg.Message) + if gotReply.ID != comment.ID || gotReply.Message != "discussion reply" { + t.Fatalf("reply message = %#v, want comment id %d", gotReply, comment.ID) + } + if peer, ok := gotReply.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != group.Channel.ID { + t.Fatalf("reply peer = %#v, want linked group %d", gotReply.PeerID, group.Channel.ID) + } + header, ok := gotReply.ReplyTo.(*tg.MessageReplyHeader) + if !ok { + t.Fatalf("reply header = %#v, want messageReplyHeader", gotReply.ReplyTo) + } + topID, topOK := header.GetReplyToTopID() + if header.ReplyToMsgID != root.ID || !topOK || topID != root.ID { + t.Fatalf("reply header = %#v, want msg/top %d", header, root.ID) + } + views, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + ID: []int{post.ID}, + }) + if err != nil { + t.Fatalf("get message views: %v", err) + } + replyInfo, ok := views.Views[0].GetReplies() + if !ok || !replyInfo.Comments || replyInfo.Replies != 1 { + t.Fatalf("message views replies = %+v ok %v, want one comment", replyInfo, ok) + } + if channelID, ok := replyInfo.GetChannelID(); !ok || channelID != group.Channel.ID { + t.Fatalf("message views channel_id = %d ok %v, want %d", channelID, ok, group.Channel.ID) + } + if maxID, ok := replyInfo.GetMaxID(); !ok || maxID != comment.ID { + t.Fatalf("message views max_id = %d ok %v, want %d", maxID, ok, comment.ID) + } + if ok, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + MsgID: post.ID, + ReadMaxID: comment.ID, + }); err != nil || !ok { + t.Fatalf("read discussion = ok %v err %v, want true", ok, err) + } + afterRead, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + MsgID: post.ID, + }) + if err != nil { + t.Fatalf("get discussion after read: %v", err) + } + if afterRead.ReadInboxMaxID != comment.ID || afterRead.UnreadCount != 0 { + t.Fatalf("discussion after read = %+v, want read inbox %d and no unread", afterRead, comment.ID) + } +} + +func TestChannelUnreadMentionsRPCUsesMentionState(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner", Username: "owner_mention"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Mentioned", Username: "mention_friend"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + created, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Mention RPC", + MemberUserIDs: []int64{member.ID}, + Date: 1700009101, + }) + if err != nil { + t.Fatalf("create megagroup: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: peer, + Message: "hello @mention_friend", + RandomID: 9102001, + }); err != nil { + t.Fatalf("send mention: %v", err) + } + mentions, err := r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{ + Peer: peer, + OffsetID: 1, + AddOffset: -10, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadMentions: %v", err) + } + mentionMessages, _, _ := searchMessagesPayload(t, mentions) + if len(mentionMessages) != 1 { + t.Fatalf("messages.getUnreadMentions = %T %+v, want one unread mention", mentions, mentions) + } + if msg := mentionMessages[0].(*tg.Message); msg.Message != "hello @mention_friend" { + t.Fatalf("mention message = %#v, want sent mention", msg) + } + read, err := r.onMessagesReadMentions(WithUserID(ctx, member.ID), &tg.MessagesReadMentionsRequest{Peer: peer}) + if err != nil { + t.Fatalf("messages.readMentions: %v", err) + } + if read.Pts <= 0 || read.PtsCount != 0 || read.Offset != 0 { + t.Fatalf("messages.readMentions = %+v, want current channel pts and no offset", read) + } + mentions, err = r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{ + Peer: peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadMentions after read: %v", err) + } + mentionMessages, _, _ = searchMessagesPayload(t, mentions) + if got := len(mentionMessages); got != 0 { + t.Fatalf("unread mentions after read = %d, want 0", got) + } +} + +func TestChannelsGetChannelsRejectsHugeVector(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + ids := make([]tg.InputChannelClass, maxGetMessagesIDs+1) + for i := range ids { + ids[i] = &tg.InputChannel{ChannelID: int64(i + 1)} + } + if _, err := r.onChannelsGetChannels(context.Background(), ids); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.getChannels huge vector err = %v, want LIMIT_INVALID", err) + } +} + +func TestChannelsSearchPostsReturnsPublicPostsWithSeekPaging(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91001, Phone: "15550091001", FirstName: "Owner"}) + viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91002, Phone: "15550091002", FirstName: "Viewer"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Public Search", + Broadcast: true, + Date: 1700010000, + }) + if err != nil { + t.Fatalf("create public channel: %v", err) + } + if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: public.Channel.ID, + Username: "public_search_posts", + }); err != nil { + t.Fatalf("publish channel username: %v", err) + } + private, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Private Search", + Broadcast: true, + Date: 1700010001, + }) + if err != nil { + t.Fatalf("create private channel: %v", err) + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: public.Channel.ID, + RandomID: 101, + Message: "launch alpha #ops", + Date: 1700010010, + }); err != nil { + t.Fatalf("send public first: %v", err) + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: public.Channel.ID, + RandomID: 102, + Message: "launch beta #ops", + Date: 1700010020, + }); err != nil { + t.Fatalf("send public second: %v", err) + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: private.Channel.ID, + RandomID: 103, + Message: "launch private #ops", + Date: 1700010030, + }); err != nil { + t.Fatalf("send private: %v", err) + } + + req := &tg.ChannelsSearchPostsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 1, + } + req.SetQuery("launch") + got, err := r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), req) + if err != nil { + t.Fatalf("channels.searchPosts first page: %v", err) + } + slice, ok := got.(*tg.MessagesMessagesSlice) + if !ok { + t.Fatalf("first page = %T %+v, want messagesSlice", got, got) + } + if slice.Count <= len(slice.Messages) { + t.Fatalf("first page count = %d messages=%d, want more page", slice.Count, len(slice.Messages)) + } + if nextRate, ok := slice.GetNextRate(); !ok || nextRate != 1700010020 { + t.Fatalf("first page next_rate = %d ok %v, want newest message date", nextRate, ok) + } + if flood, ok := slice.GetSearchFlood(); !ok || !flood.QueryIsFree { + t.Fatalf("first page search_flood = %+v ok %v, want free flood state", flood, ok) + } + messages, chats, users := searchMessagesPayload(t, got) + if len(messages) != 1 || len(chats) != 1 || len(users) != 1 { + t.Fatalf("first page payload messages=%d chats=%d users=%d, want 1/1/1", len(messages), len(chats), len(users)) + } + first := messages[0].(*tg.Message) + if first.Message != "launch beta #ops" { + t.Fatalf("first result message = %q, want newest public hit", first.Message) + } + if peer, ok := first.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != public.Channel.ID { + t.Fatalf("first result peer = %#v, want public channel %d", first.PeerID, public.Channel.ID) + } + + page2 := &tg.ChannelsSearchPostsRequest{ + OffsetRate: slice.NextRate, + OffsetPeer: &tg.InputPeerChannel{ + ChannelID: public.Channel.ID, + AccessHash: public.Channel.AccessHash, + }, + OffsetID: first.ID, + Limit: 10, + } + page2.SetQuery("launch") + got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), page2) + if err != nil { + t.Fatalf("channels.searchPosts second page: %v", err) + } + messages, chats, _ = searchMessagesPayload(t, got) + if len(messages) != 1 || len(chats) != 1 { + t.Fatalf("second page payload messages=%d chats=%d, want only older public hit", len(messages), len(chats)) + } + if msg := messages[0].(*tg.Message); msg.Message != "launch alpha #ops" { + t.Fatalf("second result message = %q, want older public hit", msg.Message) + } + + hashtagReq := &tg.ChannelsSearchPostsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 10, + } + hashtagReq.SetHashtag("ops") + got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), hashtagReq) + if err != nil { + t.Fatalf("channels.searchPosts hashtag: %v", err) + } + messages, _, _ = searchMessagesPayload(t, got) + if len(messages) != 2 { + t.Fatalf("hashtag results = %d, want two public hits only", len(messages)) + } + for _, item := range messages { + if strings.Contains(item.(*tg.Message).Message, "private") { + t.Fatalf("hashtag leaked private message: %#v", item) + } + } +} + +func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 92001, Phone: "15550092001", FirstName: "Owner"}) + viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 92002, Phone: "15550092002", FirstName: "Viewer"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + dialogService := appdialogs.NewService(memory.NewDialogStore(), channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Dialogs: dialogService, + }, zaptest.NewLogger(t), clock.System) + public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Public Preview RPC", + Broadcast: true, + Date: 1700010100, + }) + if err != nil { + t.Fatalf("create public channel: %v", err) + } + if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: public.Channel.ID, + Username: "public_preview_rpc", + }); err != nil { + t.Fatalf("publish channel username: %v", err) + } + sent, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: public.Channel.ID, + RandomID: 201, + Message: "public preview rpc post", + Date: 1700010110, + }) + if err != nil { + t.Fatalf("send public post: %v", err) + } + input := &tg.InputChannel{ChannelID: public.Channel.ID, AccessHash: public.Channel.AccessHash} + peer := &tg.InputPeerChannel{ChannelID: public.Channel.ID, AccessHash: public.Channel.AccessHash} + + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, viewer.ID), input) + if err != nil { + t.Fatalf("non-member getFullChannel public preview: %v", err) + } + if len(full.Chats) != 1 { + t.Fatalf("full chats = %d, want one channel", len(full.Chats)) + } + chat, ok := full.Chats[0].(*tg.Channel) + if !ok || !chat.Left || chat.ID != public.Channel.ID { + t.Fatalf("full channel chat = %T %+v, want left public channel", full.Chats[0], full.Chats[0]) + } + channelFull, ok := full.FullChat.(*tg.ChannelFull) + if !ok || channelFull.ID != public.Channel.ID || channelFull.UnreadCount != 0 { + t.Fatalf("full chat = %T %+v, want channel full without unread", full.FullChat, full.FullChat) + } + + sendAs, err := r.onChannelsGetSendAs(WithUserID(ctx, viewer.ID), &tg.ChannelsGetSendAsRequest{Peer: peer}) + if err != nil { + t.Fatalf("non-member getSendAs public preview: %v", err) + } + if len(sendAs.Peers) != 1 { + t.Fatalf("sendAs peers = %+v, want only current user peer", sendAs.Peers) + } + if len(sendAs.Chats) != 1 { + t.Fatalf("sendAs chats = %d, want public channel chat", len(sendAs.Chats)) + } + + historyReq := &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 10} + var in bin.Buffer + if err := historyReq.Encode(&in); err != nil { + t.Fatalf("encode getHistory: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch getHistory public preview: %v", err) + } + box, ok := enc.(*tg.MessagesMessagesBox) + if !ok { + t.Fatalf("getHistory response = %T, want boxed messages", enc) + } + history, ok := box.Messages.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("boxed getHistory = %T, want channel messages", box.Messages) + } + foundPost := false + for _, item := range history.Messages { + if msg, ok := item.(*tg.Message); ok && msg.Message == "public preview rpc post" { + foundPost = true + } + } + if !foundPost { + t.Fatalf("history messages = %+v, want public preview post", history.Messages) + } + if len(history.Chats) != 1 { + t.Fatalf("history chats = %d, want public channel chat", len(history.Chats)) + } + historyChat, ok := history.Chats[0].(*tg.Channel) + if !ok || !historyChat.Left || historyChat.ID != public.Channel.ID { + t.Fatalf("history chat = %T %+v, want left public channel", history.Chats[0], history.Chats[0]) + } + + diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, viewer.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: input, + Pts: public.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("non-member getChannelDifference public preview: %v", err) + } + fullDiff, ok := diff.(*tg.UpdatesChannelDifference) + if !ok || !fullDiff.Final || fullDiff.Pts != sent.Event.Pts || len(fullDiff.NewMessages) != 1 { + t.Fatalf("channel difference = %T %+v, want one public preview message at current pts", diff, diff) + } + diffMsg, ok := fullDiff.NewMessages[0].(*tg.Message) + if !ok || diffMsg.Message != "public preview rpc post" { + t.Fatalf("channel difference message = %T %+v, want public preview rpc post", fullDiff.NewMessages[0], fullDiff.NewMessages[0]) + } + if len(fullDiff.Chats) != 1 { + t.Fatalf("channel difference chats = %d, want public channel chat", len(fullDiff.Chats)) + } + diffChat, ok := fullDiff.Chats[0].(*tg.Channel) + if !ok || !diffChat.Left || diffChat.ID != public.Channel.ID { + t.Fatalf("channel difference chat = %T %+v, want left public channel", fullDiff.Chats[0], fullDiff.Chats[0]) + } + + domainPeers, err := r.dialogPeersFromInput(WithUserID(ctx, viewer.ID), viewer.ID, []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}}) + if err != nil { + t.Fatalf("dialog peer conversion public preview: %v", err) + } + if len(domainPeers) != 1 || domainPeers[0].Type != domain.PeerTypeChannel || domainPeers[0].ID != public.Channel.ID { + t.Fatalf("domain peers = %+v, want public channel peer", domainPeers) + } + directPeerDialogs, err := dialogService.GetPeerDialogs(ctx, viewer.ID, domainPeers) + if err != nil { + t.Fatalf("dialog service public preview: %v", err) + } + if len(directPeerDialogs.Dialogs) != 1 || len(directPeerDialogs.ChannelMessages) != 1 || len(directPeerDialogs.Channels) != 1 { + t.Fatalf("direct peer dialogs = %+v, want one dialog/message/channel", directPeerDialogs) + } + + peerDialogsReq := &tg.MessagesGetPeerDialogsRequest{ + Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}}, + } + var peerDialogsIn bin.Buffer + if err := peerDialogsReq.Encode(&peerDialogsIn); err != nil { + t.Fatalf("encode getPeerDialogs: %v", err) + } + peerDialogsEnc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &peerDialogsIn) + if err != nil { + t.Fatalf("dispatch getPeerDialogs public preview: %v", err) + } + peerDialogs, ok := peerDialogsEnc.(*tg.MessagesPeerDialogs) + if !ok { + t.Fatalf("getPeerDialogs response = %T, want peer dialogs", peerDialogsEnc) + } + if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.Messages) != 1 || len(peerDialogs.Chats) != 1 { + t.Fatalf("peer dialogs = %+v, want one dialog/message/channel", peerDialogs) + } + tgDialog, ok := peerDialogs.Dialogs[0].(*tg.Dialog) + if !ok || tgDialog.TopMessage <= 0 || tgDialog.UnreadCount != 0 { + t.Fatalf("peer dialog = %T %+v, want read-only public preview dialog", peerDialogs.Dialogs[0], peerDialogs.Dialogs[0]) + } + tgMessage, ok := peerDialogs.Messages[0].(*tg.Message) + if !ok || tgMessage.Message != "public preview rpc post" { + t.Fatalf("peer dialog message = %T %+v, want public preview rpc post", peerDialogs.Messages[0], peerDialogs.Messages[0]) + } + peerDialogChat, ok := peerDialogs.Chats[0].(*tg.Channel) + if !ok || !peerDialogChat.Left || peerDialogChat.ID != public.Channel.ID { + t.Fatalf("peer dialog chat = %T %+v, want left public channel", peerDialogs.Chats[0], peerDialogs.Chats[0]) + } +} + +func TestMessagesSearchGlobalReturnsJoinedChannelMessages(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91101, Phone: "15550091101", FirstName: "Owner"}) + viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91102, Phone: "15550091102", FirstName: "Viewer"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + joined, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Joined Broadcast", + Broadcast: true, + Date: 1700020000, + }) + if err != nil { + t.Fatalf("create joined channel: %v", err) + } + if _, err := channelService.InviteToChannel(ctx, owner.ID, joined.Channel.ID, []int64{viewer.ID}, 1700020001); err != nil { + t.Fatalf("invite viewer: %v", err) + } + hidden, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Hidden Broadcast", + Broadcast: true, + Date: 1700020002, + }) + if err != nil { + t.Fatalf("create hidden channel: %v", err) + } + for i, body := range []string{"global needle older", "global needle newer"} { + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: joined.Channel.ID, + RandomID: int64(201 + i), + Message: body, + Date: 1700020010 + i*10, + }); err != nil { + t.Fatalf("send joined %d: %v", i, err) + } + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: hidden.Channel.ID, + RandomID: 301, + Message: "global needle hidden", + Date: 1700020030, + }); err != nil { + t.Fatalf("send hidden: %v", err) + } + + req := &tg.MessagesSearchGlobalRequest{ + BroadcastsOnly: true, + Q: "global needle", + Filter: &tg.InputMessagesFilterEmpty{}, + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 1, + } + got, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), req) + if err != nil { + t.Fatalf("messages.searchGlobal first page: %v", err) + } + slice, ok := got.(*tg.MessagesMessagesSlice) + if !ok { + t.Fatalf("first page = %T %+v, want messagesSlice", got, got) + } + if nextRate, ok := slice.GetNextRate(); !ok || nextRate != 1700020020 { + t.Fatalf("first page next_rate = %d ok %v, want newest date", nextRate, ok) + } + messages, chats, users := searchMessagesPayload(t, got) + if len(messages) != 1 || len(chats) != 1 || len(users) != 1 { + t.Fatalf("first payload messages=%d chats=%d users=%d, want 1/1/1", len(messages), len(chats), len(users)) + } + first := messages[0].(*tg.Message) + if first.Message != "global needle newer" { + t.Fatalf("first message = %q, want newest joined channel hit", first.Message) + } + if peer, ok := first.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != joined.Channel.ID { + t.Fatalf("first peer = %#v, want joined channel %d", first.PeerID, joined.Channel.ID) + } + + page2 := &tg.MessagesSearchGlobalRequest{ + BroadcastsOnly: true, + Q: "global needle", + Filter: &tg.InputMessagesFilterEmpty{}, + OffsetRate: slice.NextRate, + OffsetPeer: &tg.InputPeerChannel{ + ChannelID: joined.Channel.ID, + AccessHash: joined.Channel.AccessHash, + }, + OffsetID: first.ID, + Limit: 10, + } + got, err = r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), page2) + if err != nil { + t.Fatalf("messages.searchGlobal second page: %v", err) + } + messages, chats, _ = searchMessagesPayload(t, got) + if len(messages) != 1 || len(chats) != 1 { + t.Fatalf("second payload messages=%d chats=%d, want older joined hit only", len(messages), len(chats)) + } + if msg := messages[0].(*tg.Message); msg.Message != "global needle older" { + t.Fatalf("second message = %q, want older joined hit", msg.Message) + } +} + +func TestChannelsSearchPostsValidatesStubBounds(t *testing.T) { + const userID = int64(1000000001) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + + valid := &tg.ChannelsSearchPostsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + } + valid.SetQuery("launch") + got, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), valid) + if err != nil { + t.Fatalf("channels.searchPosts valid stub: %v", err) + } + if page, ok := got.(*tg.MessagesMessages); !ok || len(page.Messages) != 0 || len(page.Chats) != 0 || len(page.Users) != 0 { + t.Fatalf("channels.searchPosts = %T %+v, want empty messages", got, got) + } + + both := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1} + both.SetQuery("launch") + both.SetHashtag("launch") + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), both); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { + t.Fatalf("channels.searchPosts query+hashtag err = %v, want SEARCH_QUERY_EMPTY", err) + } + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { + t.Fatalf("channels.searchPosts empty query err = %v, want SEARCH_QUERY_EMPTY", err) + } + + huge := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1} + huge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1)) + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), huge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.searchPosts huge query err = %v, want LIMIT_INVALID", err) + } + + badOffset := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, OffsetID: domain.MaxMessageBoxID + 1} + badOffset.SetHashtag("launch") + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badOffset); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("channels.searchPosts huge offset_id err = %v, want MESSAGE_ID_INVALID", err) + } + + badStars := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, AllowPaidStars: -1} + badStars.SetQuery("launch") + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badStars); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.searchPosts negative stars err = %v, want LIMIT_INVALID", err) + } + + badPeer := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerUser{UserID: 42}, Limit: 1} + badPeer.SetQuery("launch") + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badPeer); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") { + t.Fatalf("channels.searchPosts user offset peer err = %v, want PEER_ID_INVALID", err) + } + + floodReq := &tg.ChannelsCheckSearchPostsFloodRequest{} + floodReq.SetQuery("launch") + flood, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodReq) + if err != nil { + t.Fatalf("channels.checkSearchPostsFlood: %v", err) + } + if !flood.QueryIsFree || flood.Remains <= 0 || flood.TotalDaily <= 0 { + t.Fatalf("channels.checkSearchPostsFlood = %+v, want free quota", flood) + } + + if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), &tg.ChannelsCheckSearchPostsFloodRequest{}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { + t.Fatalf("channels.checkSearchPostsFlood empty query err = %v, want SEARCH_QUERY_EMPTY", err) + } + floodHuge := &tg.ChannelsCheckSearchPostsFloodRequest{} + floodHuge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1)) + if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodHuge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.checkSearchPostsFlood huge query err = %v, want LIMIT_INVALID", err) + } +} + +func TestMessagesGetPeerDialogsReturnsRequestedDialogsAndState(t *testing.T) { + msg := domain.Message{ + ID: 3, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000100, + Body: "Login code: 12345", + } + dialogs := &captureDialogs{peerList: domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: msg.Peer, + TopMessage: msg.ID, + TopMessageDate: msg.Date, + UnreadCount: 1, + }}, + Messages: []domain.Message{msg}, + Users: []domain.User{domain.OfficialSystemUser()}, + }} + updates := &captureUpdates{state: domain.UpdateState{Pts: 8, Date: 1700000200, Seq: 4}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetPeerDialogsRequest{ + Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{ + Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + }}, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesPeerDialogs) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesPeerDialogs", enc) + } + if len(dialogs.peers) != 1 || dialogs.peers[0].ID != domain.OfficialSystemUserID { + t.Fatalf("requested peers = %+v, want official peer", dialogs.peers) + } + if got.State.Pts != 8 || got.State.Seq != 4 { + t.Fatalf("state = %+v, want updates state", got.State) + } + if len(got.Dialogs) != 1 || len(got.Messages) != 1 || len(got.Users) != 1 { + t.Fatalf("peer dialogs = %+v, want one dialog/message/user", got) + } +} + +func TestPushLoginMessageSendsUpdateNewMessage(t *testing.T) { + sessions := &captureSessions{} + r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) + msg := domain.Message{ + ID: 99, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000100, + Body: "Login code: 12345", + Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 11}}, + } + event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: 4, PtsCount: 1, Date: msg.Date, Message: msg} + state := domain.UpdateState{Pts: 4, Date: 1700000100, Seq: 2} + + r.pushLoginMessage(context.Background(), [8]byte{}, 55, event, state) + + gotSession := sessions.snapshot() + if gotSession.sessionID != 55 || gotSession.messageType != proto.MessageFromServer { + t.Fatalf("push target = session %d type %v, want session 55 MessageFromServer", gotSession.sessionID, gotSession.messageType) + } + got, ok := gotSession.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", gotSession.message) + } + if len(got.Updates) != 1 || len(got.Users) != 1 { + t.Fatalf("updates payload = %+v, want one update and official user", got) + } + update, ok := got.Updates[0].(*tg.UpdateNewMessage) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateNewMessage", got.Updates[0]) + } + if update.Pts != event.Pts || update.PtsCount != event.PtsCount || got.Seq != state.Seq { + t.Fatalf("update state = pts %d pts_count %d seq %d, want %d/%d/%d", update.Pts, update.PtsCount, got.Seq, event.Pts, event.PtsCount, state.Seq) + } + message, ok := update.Message.(*tg.Message) + if !ok { + t.Fatalf("update message = %T, want *tg.Message", update.Message) + } + if message.ID != msg.ID || message.Message != msg.Body { + t.Fatalf("message = %+v, want login message %+v", message, msg) + } + user, ok := got.Users[0].(*tg.User) + if !ok || user.ID != domain.OfficialSystemUserID || !user.Verified || !user.Support { + t.Fatalf("user = %#v, want verified official system user", got.Users[0]) + } +} + +func TestSignInServiceNotificationMatchesEnterpriseShape(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 9 + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithClientInfo(context.Background(), ClientInfo{ + DeviceModel: "Telegram Desktop", + SystemVersion: "Windows", + AppVersion: "6.8.4", + }) + + got := r.tgSignInServiceNotification(ctx, domain.User{ + ID: 1000000001, + FirstName: "Test", + LastName: "User", + }, authKeyID) + + if len(got.Updates) != 1 { + t.Fatalf("updates = %+v, want one service notification", got.Updates) + } + update, ok := got.Updates[0].(*tg.UpdateServiceNotification) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateServiceNotification", got.Updates[0]) + } + if update.Popup || update.InboxDate == 0 || update.Media == nil { + t.Fatalf("notification flags/media = popup %v inbox %d media %T", update.Popup, update.InboxDate, update.Media) + } + for _, want := range []string{"New login.", "Test User", "Telegram Desktop", "Settings > Devices"} { + if !strings.Contains(update.Message, want) { + t.Fatalf("notification message %q missing %q", update.Message, want) + } + } + if len(update.Entities) < 3 { + t.Fatalf("entities = %+v, want bold entities for title/settings links", update.Entities) + } +} + +func TestLogOutClearsSessionAndUpdateState(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 5 + auth := &captureAuthService{} + updates := &captureUpdates{} + sessions := &captureSessions{authKeyID: authKeyID, authKeyResolved: true, userID: 1000000001, userResolved: true} + r := New(Config{}, Deps{ + Auth: auth, + Updates: updates, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + _, err := r.onAuthLogOut(WithAuthKeyID(context.Background(), authKeyID)) + if err != nil { + t.Fatalf("auth.logOut: %v", err) + } + if auth.loggedOutAuthKeyID != authKeyID { + t.Fatalf("logged out auth key = %x, want %x", auth.loggedOutAuthKeyID, authKeyID) + } + if updates.clearedAuthKeyID != authKeyID || !updates.cleared { + t.Fatalf("cleared auth key = %x cleared=%v, want %x", updates.clearedAuthKeyID, updates.cleared, authKeyID) + } + gotSession := sessions.snapshot() + if gotSession.userID != 0 || !gotSession.userResolved { + t.Fatalf("session user after logout = %d resolved=%v, want 0/true", gotSession.userID, gotSession.userResolved) + } +} + +func TestSignInDifferentUserClearsAuthKeyUpdateState(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 6 + auth := &captureAuthService{signInUser: domain.User{ID: 1000000002, FirstName: "Two"}} + updates := &captureUpdates{} + sessions := &captureSessions{authKeyID: authKeyID, authKeyResolved: true, userID: 1000000001, userResolved: true} + r := New(Config{}, Deps{ + Auth: auth, + Updates: updates, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(WithAuthKeyID(WithSessionID(context.Background(), 77), authKeyID), 1000000001) + + _, err := r.onAuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: "15550000002", PhoneCodeHash: "hash", PhoneCode: "12345"}) + if err != nil { + t.Fatalf("auth.signIn: %v", err) + } + if updates.clearedAuthKeyID != authKeyID || !updates.cleared { + t.Fatalf("cleared auth key = %x cleared=%v, want %x", updates.clearedAuthKeyID, updates.cleared, authKeyID) + } + gotSession := sessions.snapshot() + if gotSession.userID != 1000000002 { + t.Fatalf("session user = %d, want new user 1000000002", gotSession.userID) + } +} + +func TestDialogSettingRPCsRecordDurableUpdates(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 9 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}} + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + toggle := &tg.MessagesToggleDialogPinRequest{Peer: dialogPeer} + toggle.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok { + t.Fatalf("toggle pin = %v, %v", ok, err) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned || updates.events[0].Peer != peer || !updates.events[0].Bool || updates.excludeSessionID != 55 { + t.Fatalf("pin event = %+v, want durable dialog_pinned", updates.events) + } + + if ok, err := r.onMessagesReorderPinnedDialogs(ctx, &tg.MessagesReorderPinnedDialogsRequest{Order: []tg.InputDialogPeerClass{dialogPeer}}); err != nil || !ok { + t.Fatalf("reorder pinned = %v, %v", ok, err) + } + if len(updates.events) != 2 || updates.events[1].Type != domain.UpdateEventPinnedDialogs || len(updates.events[1].Peers) != 1 || updates.events[1].Peers[0] != peer { + t.Fatalf("reorder event = %+v, want durable pinned_dialogs", updates.events) + } + + mark := &tg.MessagesMarkDialogUnreadRequest{Peer: dialogPeer} + mark.SetUnread(true) + if ok, err := r.onMessagesMarkDialogUnread(ctx, mark); err != nil || !ok { + t.Fatalf("mark unread = %v, %v", ok, err) + } + if len(updates.events) != 3 || updates.events[2].Type != domain.UpdateEventDialogUnreadMark || updates.events[2].Peer != peer || !updates.events[2].Bool { + t.Fatalf("unread event = %+v, want durable dialog_unread_mark", updates.events) + } + + parentMark := &tg.MessagesMarkDialogUnreadRequest{ + ParentPeer: &tg.InputPeerChannel{ChannelID: 9001}, + Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}}, + } + parentMark.SetUnread(true) + parentMark.SetParentPeer(parentMark.ParentPeer) + if ok, err := r.onMessagesMarkDialogUnread(ctx, parentMark); err != nil || !ok { + t.Fatalf("mark monoforum unread compat = %v, %v", ok, err) + } + if len(updates.events) != 3 { + t.Fatalf("parent_peer unread compat recorded events = %+v, want no durable event", updates.events) + } + + getParentMarks := &tg.MessagesGetDialogUnreadMarksRequest{ParentPeer: &tg.InputPeerChannel{ChannelID: 9001}} + getParentMarks.SetParentPeer(getParentMarks.ParentPeer) + marks, err := r.onMessagesGetDialogUnreadMarks(ctx, getParentMarks) + if err != nil { + t.Fatalf("get monoforum unread marks compat: %v", err) + } + if len(marks) != 0 { + t.Fatalf("monoforum unread marks = %+v, want empty compat list", marks) + } + + invalidParentMarks := &tg.MessagesGetDialogUnreadMarksRequest{ParentPeer: &tg.InputPeerSelf{}} + invalidParentMarks.SetParentPeer(invalidParentMarks.ParentPeer) + if _, err := r.onMessagesGetDialogUnreadMarks(ctx, invalidParentMarks); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { + t.Fatalf("get unread marks invalid parent err = %v, want PARENT_PEER_INVALID", err) + } + + if ok, err := r.onMessagesHidePeerSettingsBar(ctx, &tg.InputPeerUser{UserID: peer.ID}); err != nil || !ok { + t.Fatalf("hide peer settings = %v, %v", ok, err) + } + if len(updates.events) != 4 || updates.events[3].Type != domain.UpdateEventPeerSettings || updates.events[3].Peer != peer || !updates.events[3].Settings.HiddenPeerSettingsBar { + t.Fatalf("peer settings event = %+v, want durable peer_settings", updates.events) + } +} + +func TestDialogUnreadMarksIncludeChannelDialogs(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, 1000000001, domain.CreateChannelRequest{ + Title: "Unread Mark Channel", + Date: 1700001000, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + dialogs := appdialogs.NewService(memory.NewDialogStore(), channelStore) + r := New(Config{}, Deps{ + Channels: channelSvc, + Dialogs: dialogs, + }, zaptest.NewLogger(t), clock.System) + rpcCtx := WithUserID(ctx, 1000000001) + input := &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }} + + mark := &tg.MessagesMarkDialogUnreadRequest{Peer: input} + mark.SetUnread(true) + if ok, err := r.onMessagesMarkDialogUnread(rpcCtx, mark); err != nil || !ok { + t.Fatalf("mark channel unread = %v, %v", ok, err) + } + marks, err := r.onMessagesGetDialogUnreadMarks(rpcCtx, &tg.MessagesGetDialogUnreadMarksRequest{}) + if err != nil { + t.Fatalf("get channel unread marks: %v", err) + } + if len(marks) != 1 { + t.Fatalf("channel unread marks len = %d, want 1 (%+v)", len(marks), marks) + } + got, ok := marks[0].(*tg.DialogPeer) + if !ok { + t.Fatalf("channel unread mark = %T, want dialogPeer", marks[0]) + } + peer, ok := got.Peer.(*tg.PeerChannel) + if !ok || peer.ChannelID != created.Channel.ID { + t.Fatalf("channel unread mark peer = %#v, want channel %d", got.Peer, created.Channel.ID) + } + dialogsList, err := dialogs.GetPeerDialogs(ctx, 1000000001, []domain.Peer{{Type: domain.PeerTypeChannel, ID: created.Channel.ID}}) + if err != nil { + t.Fatalf("GetPeerDialogs: %v", err) + } + var dialog domain.Dialog + for _, item := range dialogsList.Dialogs { + if item.Peer.Type == domain.PeerTypeChannel && item.Peer.ID == created.Channel.ID { + dialog = item + break + } + } + if dialog.Peer.ID == 0 { + t.Fatalf("channel dialog missing from %+v", dialogsList.Dialogs) + } + if !dialog.UnreadMark { + t.Fatalf("channel dialog unread mark = false, want true") + } + + clear := &tg.MessagesMarkDialogUnreadRequest{Peer: input} + if ok, err := r.onMessagesMarkDialogUnread(rpcCtx, clear); err != nil || !ok { + t.Fatalf("clear channel unread = %v, %v", ok, err) + } + marks, err = r.onMessagesGetDialogUnreadMarks(rpcCtx, &tg.MessagesGetDialogUnreadMarksRequest{}) + if err != nil { + t.Fatalf("get channel unread marks after clear: %v", err) + } + if len(marks) != 0 { + t.Fatalf("channel unread marks after clear = %+v, want empty", marks) + } +} + +func TestDialogFolderRPCsPersistAndRecordUpdates(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 12 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 66) + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 21, Date: 1700000500, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + req := &tg.MessagesUpdateDialogFilterRequest{ID: 2} + req.SetFilter(&tg.DialogFilter{ + ID: 2, + Contacts: true, + Title: tg.TextWithEntities{Text: "Work"}, + IncludePeers: []tg.InputPeerClass{&tg.InputPeerUser{ + UserID: 1000000002, + AccessHash: 222, + }}, + }) + if ok, err := r.onMessagesUpdateDialogFilter(ctx, req); err != nil || !ok { + t.Fatalf("update filter = %v, %v", ok, err) + } + if dialogs.savedFolder.ID != 2 || dialogs.savedFolder.Title != "Work" || !dialogs.savedFolder.Contacts || len(dialogs.savedFolder.IncludePeers) != 1 { + t.Fatalf("saved folder = %+v, want parsed dialog folder", dialogs.savedFolder) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogFilter || updates.events[0].FilterID != 2 || updates.excludeSessionID != 66 { + t.Fatalf("filter event = %+v, want durable dialog_filter", updates.events) + } + + if ok, err := r.onMessagesUpdateDialogFiltersOrder(ctx, []int{2, 2, 0, 3}); err != nil || !ok { + t.Fatalf("update filter order = %v, %v", ok, err) + } + if len(dialogs.folderOrder) != 2 || dialogs.folderOrder[0] != 2 || dialogs.folderOrder[1] != 3 { + t.Fatalf("folder order = %+v, want cleaned custom IDs", dialogs.folderOrder) + } + if len(updates.events) != 2 || updates.events[1].Type != domain.UpdateEventDialogFilterOrder { + t.Fatalf("order event = %+v, want durable dialog_filter_order", updates.events) + } + + if ok, err := r.onMessagesToggleDialogFilterTags(ctx, true); err != nil || !ok { + t.Fatalf("toggle filter tags = %v, %v", ok, err) + } + if !dialogs.tagsEnabled || len(updates.events) != 3 || updates.events[2].Type != domain.UpdateEventDialogFilters { + t.Fatalf("tags/update = tags %v events %+v, want reload update", dialogs.tagsEnabled, updates.events) + } +} + +func TestFoldersEditPeerFoldersPersistsArchiveAndReturnsPtsUpdate(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 13 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 77) + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 31, Date: 1700000600, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + got, err := r.onFoldersEditPeerFolders(ctx, []tg.InputFolderPeer{{ + Peer: &tg.InputPeerUser{UserID: 1000000002}, + FolderID: domain.DialogArchiveFolderID, + }}) + if err != nil { + t.Fatalf("folders.editPeerFolders: %v", err) + } + if len(dialogs.folderPeers) != 1 || dialogs.folderPeers[0].FolderID != domain.DialogArchiveFolderID { + t.Fatalf("folder peers = %+v, want archive update", dialogs.folderPeers) + } + out, ok := got.(*tg.Updates) + if !ok || len(out.Updates) != 1 { + t.Fatalf("updates = %T %+v, want tg.Updates with updateFolderPeers", got, got) + } + update, ok := out.Updates[0].(*tg.UpdateFolderPeers) + if !ok || update.Pts != 31 || update.PtsCount != 1 || len(update.FolderPeers) != 1 { + t.Fatalf("update = %T %+v, want updateFolderPeers pts=31", out.Updates[0], out.Updates[0]) + } +} + +func TestDialogSettingRPCsSkipManualPushWhenReliableDispatch(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 11 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}, reliableDispatch: true} + sessions := &captureSessions{} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + + toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}}} + toggle.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok { + t.Fatalf("toggle pin = %v, %v", ok, err) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned { + t.Fatalf("events = %+v, want durable event recorded", updates.events) + } + if got := sessions.snapshot(); got.message != nil { + t.Fatalf("manual push = %+v, want reliable outbox to be sole online dispatcher", got) + } +} + +func TestUpdatesGetStateMarksSessionReadyForPush(t *testing.T) { + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Sessions: sessions, + Updates: &captureUpdates{state: domain.UpdateState{Pts: 3, Date: 1700000000, Seq: 2}}, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onUpdatesGetState(WithSessionID(context.Background(), 77)) + if err != nil { + t.Fatalf("updates.getState: %v", err) + } + if got.Pts != 3 || got.Seq != 2 { + t.Fatalf("state = %+v, want pts=3 seq=2", got) + } + gotSession := sessions.snapshot() + if gotSession.sessionID != 77 || !gotSession.receives { + t.Fatalf("session ready = id %d receives %v, want 77/true", gotSession.sessionID, gotSession.receives) + } +} + +func TestUpdatesGetStateNudgesDifferenceWhenCurrentStateAhead(t *testing.T) { + sessions := &captureSessions{} + current := domain.UpdateState{Pts: 4, Date: 1700000001} + r := New(Config{}, Deps{ + Sessions: sessions, + Updates: &captureUpdates{ + state: domain.UpdateState{Pts: 3, Date: 1700000000}, + currentState: ¤t, + }, + }, zaptest.NewLogger(t), clock.System) + + if _, err := r.onUpdatesGetState(WithSessionID(context.Background(), 77)); err != nil { + t.Fatalf("updates.getState: %v", err) + } + deadline := time.Now().Add(time.Second) + for { + got := sessions.snapshot() + if _, ok := got.message.(*tg.UpdatesTooLong); ok { + if got.sessionID != 77 || got.messageType != proto.MessageFromServer { + t.Fatalf("nudge target = session %d type %v, want 77/from-server", got.sessionID, got.messageType) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for updatesTooLong nudge, last message=%T", got.message) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestUpdatesDifferenceIncludesLoginMessageAndOfficialUser(t *testing.T) { + msg := domain.Message{ + ID: 88, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000100, + Body: "Login code: 12345", + } + got, ok := tgUpdatesDifference(domain.UpdateDifference{ + State: domain.UpdateState{Pts: 5, Date: msg.Date, Seq: 4}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventNewMessage, + Pts: 5, + PtsCount: 1, + Date: msg.Date, + Message: msg, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 5 || len(got.NewMessages) != 1 || len(got.Users) != 1 { + t.Fatalf("difference = %+v, want one message, one official user, pts=5", got) + } + user, ok := got.Users[0].(*tg.User) + if !ok || user.ID != domain.OfficialSystemUserID { + t.Fatalf("user = %#v, want official system user", got.Users[0]) + } +} + +func TestUpdatesDifferenceIncludesForwardSourceChannelChat(t *testing.T) { + source := domain.Channel{ + ID: 2000000001, + AccessHash: 9001, + Title: "Source Channel", + Broadcast: true, + Date: 1700000000, + } + msg := domain.Message{ + ID: 89, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000101, + Body: "forwarded", + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, + } + got, ok := tgUpdatesDifference(domain.UpdateDifference{ + State: domain.UpdateState{Pts: 6, Date: msg.Date}, + Events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: 6, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Channels: []domain.Channel{source}, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.Chats) != 1 { + t.Fatalf("chats = %+v, want source channel", got.Chats) + } + ch, ok := got.Chats[0].(*tg.Channel) + if !ok || ch.ID != source.ID || ch.Title != source.Title { + t.Fatalf("chat = %#v, want source channel", got.Chats[0]) + } +} + +func TestOutboxEventIncludesForwardSourceChannelChat(t *testing.T) { + source := domain.Channel{ + ID: 2000000002, + AccessHash: 9002, + Title: "Forward Source", + Broadcast: true, + Date: 1700000000, + } + update := tgUpdateForOutboxEvent(domain.UpdateEvent{ + UserID: 1000000001, + Type: domain.UpdateEventNewMessage, + Pts: 7, + PtsCount: 1, + Date: 1700000102, + Message: domain.Message{ + ID: 90, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + Date: 1700000102, + Body: "forwarded", + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, + }, + Channels: []domain.Channel{source}, + }) + if update == nil || len(update.Chats) != 1 { + t.Fatalf("update = %+v, want source channel chat", update) + } + ch, ok := update.Chats[0].(*tg.Channel) + if !ok || ch.ID != source.ID { + t.Fatalf("chat = %#v, want source channel", update.Chats[0]) + } +} + +func TestOutboxEventChannelViewForumAsMessages(t *testing.T) { + update := tgUpdateForOutboxEvent(domain.UpdateEvent{ + UserID: 1000000001, + Type: domain.UpdateEventChannelViewForum, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, + Bool: true, + Date: 1700000200, + }) + if update == nil || len(update.Updates) != 1 { + t.Fatalf("update = %+v, want one updateChannelViewForumAsMessages", update) + } + got, ok := update.Updates[0].(*tg.UpdateChannelViewForumAsMessages) + if !ok || got.ChannelID != 2000000002 || !got.Enabled { + t.Fatalf("update = %#v, want channel view-forum-as-messages enabled", update.Updates[0]) + } +} + +func TestChannelDifferenceIncludesExtraForwardSourceChannel(t *testing.T) { + channel := domain.Channel{ID: 2000000100, AccessHash: 9010, Title: "Megagroup", Megagroup: true, Date: 1700000000, Pts: 3} + source := domain.Channel{ID: 2000000101, AccessHash: 9011, Title: "Source", Broadcast: true, Date: 1700000000} + got, ok := tgChannelDifference(1000000001, domain.ChannelDifference{ + Channel: channel, + NewMessages: []domain.ChannelMessage{{ + ChannelID: channel.ID, + ID: 3, + SenderUserID: 1000000002, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + Date: 1700000103, + Body: "forwarded", + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, + Pts: 3, + }}, + Users: []domain.User{{ID: 1000000002, AccessHash: 42, FirstName: "Bob"}}, + Channels: []domain.Channel{source}, + Pts: 3, + Final: true, + Timeout: 30, + }).(*tg.UpdatesChannelDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesChannelDifference", got) + } + if len(got.Users) != 1 || len(got.Chats) != 2 { + t.Fatalf("difference users/chats = %d/%d, want 1/2", len(got.Users), len(got.Chats)) + } + if ch, ok := got.Chats[1].(*tg.Channel); !ok || ch.ID != source.ID { + t.Fatalf("extra chat = %#v, want source channel", got.Chats[1]) + } +} + +func TestUpdatesDifferenceIncludesReadHistoryInbox(t *testing.T) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} + got, ok := tgUpdatesDifference(domain.UpdateDifference{ + State: domain.UpdateState{Pts: 6, Date: 1700000200, Seq: 5}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventReadHistoryInbox, + Pts: 6, + PtsCount: 1, + Date: 1700000200, + Peer: peer, + MaxID: 12, + StillUnreadCount: 0, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 6 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference = %+v, want one read history update and pts=6", got) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateReadHistoryInbox) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateReadHistoryInbox", got.OtherUpdates[0]) + } + if update.MaxID != 12 || update.Pts != 6 || update.PtsCount != 1 { + t.Fatalf("read update = %+v, want max_id=12 pts=6 pts_count=1", update) + } +} + +func TestUpdatesDifferenceIncludesReadHistoryOutbox(t *testing.T) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + got, ok := tgUpdatesDifference(domain.UpdateDifference{ + State: domain.UpdateState{Pts: 7, Date: 1700000210, Seq: 0}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventReadHistoryOutbox, + Pts: 7, + PtsCount: 1, + Date: 1700000210, + Peer: peer, + MaxID: 9, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.OtherUpdates) != 1 { + t.Fatalf("other updates = %+v, want one read outbox update", got.OtherUpdates) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateReadHistoryOutbox) + if !ok || update.MaxID != 9 || update.Pts != 7 || update.PtsCount != 1 { + t.Fatalf("read outbox = %T %+v, want max_id=9 pts=7", got.OtherUpdates[0], got.OtherUpdates[0]) + } +} + +func TestUpdatesDifferenceIncludesEditMessage(t *testing.T) { + msg := domain.Message{ + ID: 4, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000200, + EditDate: 1700000300, + Out: true, + Body: "edited", + Pts: 8, + } + got, ok := tgUpdatesDifference(domain.UpdateDifference{ + State: domain.UpdateState{Pts: 8, Date: 1700000300, Seq: 0}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventEditMessage, + Pts: 8, + PtsCount: 1, + Date: 1700000300, + Message: msg, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.OtherUpdates) != 1 { + t.Fatalf("other updates = %+v, want one edit update", got.OtherUpdates) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateEditMessage) + if !ok || update.Pts != 8 || update.PtsCount != 1 { + t.Fatalf("edit update = %T %+v, want pts=8", got.OtherUpdates[0], got.OtherUpdates[0]) + } + edited, ok := update.Message.(*tg.Message) + if !ok || edited.Message != "edited" || edited.EditDate != 1700000300 { + t.Fatalf("edited message = %#v, want text and edit_date", update.Message) + } +} + +func TestUpdatesDifferenceIncludesReactionMessageAndUpdate(t *testing.T) { + const ( + aliceID = int64(1000000001) + bobID = int64(1000000002) + ) + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} + reactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: reaction, + Count: 1, + ChosenOrder: 1, + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + UserID: bobID, + Reaction: reaction, + My: true, + ChosenOrder: 1, + Date: 1700000310, + }}, + } + msg := domain.Message{ + ID: 68, + UID: 7001, + OwnerUserID: aliceID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + Date: 1700000300, + Body: "rx", + Reactions: &reactions, + } + got, ok := tgUpdatesDifference(domain.UpdateDifference{ + State: domain.UpdateState{Pts: 9, Date: 1700000310}, + Events: []domain.UpdateEvent{{ + UserID: aliceID, + Type: domain.UpdateEventMessageReactions, + Pts: 9, + PtsCount: 1, + Date: 1700000310, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + Message: msg, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.NewMessages) != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference messages/updates = %d/%d, want 1/1", len(got.NewMessages), len(got.OtherUpdates)) + } + wireMsg, ok := got.NewMessages[0].(*tg.Message) + if !ok || wireMsg.ID != msg.ID { + t.Fatalf("message = %T %+v, want message %d", got.NewMessages[0], got.NewMessages[0], msg.ID) + } + msgReactions, ok := wireMsg.GetReactions() + if !ok || len(msgReactions.Results) != 1 || msgReactions.Results[0].Count != 1 || msgReactions.Results[0].ChosenOrder != 1 { + t.Fatalf("message reactions = %+v set=%v, want chosen reaction", msgReactions, ok) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateMessageReactions) + if !ok || update.MsgID != msg.ID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("reaction update = %T %+v, want update for msg %d", got.OtherUpdates[0], got.OtherUpdates[0], msg.ID) + } +} + +func TestUpdatesDifferenceIncludesDeleteMessages(t *testing.T) { + got, ok := tgUpdatesDifference(domain.UpdateDifference{ + State: domain.UpdateState{Pts: 8, Date: 1700000250, Seq: 0}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventDeleteMessages, + Pts: 8, + PtsCount: 3, + Date: 1700000250, + MessageIDs: []int{3, 4, 5}, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 8 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference = %+v, want one delete update and pts=8", got) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateDeleteMessages) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateDeleteMessages", got.OtherUpdates[0]) + } + if update.Pts != 8 || update.PtsCount != 3 || len(update.Messages) != 3 || update.Messages[0] != 3 || update.Messages[2] != 5 { + t.Fatalf("delete update = %+v, want ids [3 4 5] pts=8 pts_count=3", update) + } +} + +func TestUpdatesDifferenceIncludesSettingsUpdates(t *testing.T) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + got, ok := tgUpdatesDifference(domain.UpdateDifference{ + State: domain.UpdateState{Pts: 5, Date: 1700000300, Seq: 0}, + Events: []domain.UpdateEvent{ + {Type: domain.UpdateEventContactsReset, Pts: 1, PtsCount: 1, Date: 1700000300}, + {Type: domain.UpdateEventDialogPinned, Pts: 2, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: true}, + {Type: domain.UpdateEventPinnedDialogs, Pts: 3, PtsCount: 1, Date: 1700000300, Peers: []domain.Peer{peer}}, + {Type: domain.UpdateEventDialogUnreadMark, Pts: 4, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: false}, + {Type: domain.UpdateEventPeerSettings, Pts: 5, PtsCount: 1, Date: 1700000300, Peer: peer, Settings: domain.PeerSettings{ShareContact: true}}, + }, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 5 || len(got.OtherUpdates) != 5 { + t.Fatalf("difference = %+v, want five settings updates and pts=5", got) + } + if _, ok := got.OtherUpdates[0].(*tg.UpdateContactsReset); !ok { + t.Fatalf("update[0] = %T, want contacts reset", got.OtherUpdates[0]) + } + pinned, ok := got.OtherUpdates[1].(*tg.UpdateDialogPinned) + if !ok || !pinned.Pinned { + t.Fatalf("update[1] = %+v (%T), want pinned dialog", got.OtherUpdates[1], got.OtherUpdates[1]) + } + pinnedDialogs, ok := got.OtherUpdates[2].(*tg.UpdatePinnedDialogs) + if !ok || len(pinnedDialogs.Order) != 1 { + t.Fatalf("update[2] = %T, want pinned dialogs", got.OtherUpdates[2]) + } + unread, ok := got.OtherUpdates[3].(*tg.UpdateDialogUnreadMark) + if !ok || unread.Unread { + t.Fatalf("update[3] = %+v (%T), want unread=false mark", got.OtherUpdates[3], got.OtherUpdates[3]) + } + peerSettings, ok := got.OtherUpdates[4].(*tg.UpdatePeerSettings) + if !ok || !peerSettings.Settings.ShareContact { + t.Fatalf("update[4] = %T, want peer settings", got.OtherUpdates[4]) + } +} + +func TestMessagesGetHistoryReturnsStoredMessages(t *testing.T) { + msg := domain.Message{ + ID: 1, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000100, + Body: "Login code: 12345", + } + messages := &captureMessages{ + list: domain.MessageList{ + Messages: []domain.Message{msg}, + Users: []domain.User{domain.OfficialSystemUser()}, + Count: 1, + Hash: 99, + }, + } + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + Limit: 20, + AddOffset: 1 << 30, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.MessagesMessagesBox) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesMessagesBox", enc) + } + got, ok := box.Messages.(*tg.MessagesMessages) + if !ok { + t.Fatalf("boxed response = %T, want *tg.MessagesMessages", box.Messages) + } + if len(got.Messages) != 1 || len(got.Users) != 1 { + t.Fatalf("history = %+v, want one message and one user", got) + } + if messages.filter.Peer.ID != domain.OfficialSystemUserID || messages.filter.Limit != 20 || messages.filter.AddOffset != domain.MaxMessageHistoryAddOffset { + t.Fatalf("filter = %+v, want official peer limit 20 and clamped add_offset", messages.filter) + } +} + +func TestMessagesSetTypingPushesUserTypingUpdate(t *testing.T) { + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) + var authKeyID [8]byte + authKeyID[0] = 7 + + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + ok, err := r.onMessagesSetTyping(ctx, &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerUser{UserID: 1000000002, AccessHash: 22}, + Action: &tg.SendMessageTypingAction{}, + }) + if err != nil { + t.Fatalf("set typing: %v", err) + } + if !ok { + t.Fatalf("set typing = false, want true") + } + + got := sessions.snapshot() + if got.userID != 1000000002 || got.sessionID != 55 || got.messageType != proto.MessageFromServer { + t.Fatalf("push = user %d exclude session %d type %v, want target/exclude/from_server", got.userID, got.sessionID, got.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { + t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) + } + updateShort, ok := got.message.(*tg.UpdateShort) + if !ok { + t.Fatalf("pushed message = %T, want *tg.UpdateShort", got.message) + } + typing, ok := updateShort.Update.(*tg.UpdateUserTyping) + if !ok { + t.Fatalf("short update = %T, want *tg.UpdateUserTyping", updateShort.Update) + } + if typing.UserID != 1000000001 { + t.Fatalf("typing user_id = %d, want sender", typing.UserID) + } + if _, ok := typing.Action.(*tg.SendMessageTypingAction); !ok { + t.Fatalf("typing action = %T, want *tg.SendMessageTypingAction", typing.Action) + } +} + +func TestMessagesSetTypingRejectsInvalidTopMsgID(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerUser{UserID: 1000000002, AccessHash: 22}, + Action: &tg.SendMessageTypingAction{}, + } + req.SetTopMsgID(domain.MaxMessageBoxID + 1) + + ok, err := r.onMessagesSetTyping(WithUserID(context.Background(), 1000000001), req) + if ok || err == nil || !strings.Contains(err.Error(), "MSG_ID_INVALID") { + t.Fatalf("set typing invalid top_msg_id = ok %v err %v, want MSG_ID_INVALID", ok, err) + } +} + +func TestMessagesSetTypingPushesChannelTypingTopMsgID(t *testing.T) { + const ( + ownerID = int64(1000000001) + memberID = int64(1000000002) + topicID = 7 + ) + channels := appchannels.NewService(memory.NewChannelStore()) + created, err := channels.CreateChannel(context.Background(), ownerID, domain.CreateChannelRequest{ + Title: "topic group", + CreatorUserID: ownerID, + Megagroup: true, + MemberUserIDs: []int64{memberID}, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sessions := &captureScopedSessions{captureSessions: &captureSessions{ + channelViewers: map[int64][]int64{created.Channel.ID: {memberID}}, + }} + r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + var authKeyID [8]byte + authKeyID[0] = 9 + + req := &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }, + Action: &tg.SendMessageTypingAction{}, + } + req.SetTopMsgID(topicID) + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), ownerID), authKeyID), 77) + + ok, err := r.onMessagesSetTyping(ctx, req) + if err != nil { + t.Fatalf("set channel typing: %v", err) + } + if !ok { + t.Fatalf("set channel typing = false, want true") + } + got := sessions.snapshot() + if got.userID != memberID || got.sessionID != 77 || got.messageType != proto.MessageFromServer { + t.Fatalf("channel typing push = user %d exclude session %d type %v, want member/exclude/from_server", got.userID, got.sessionID, got.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { + t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) + } + updates, ok := got.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", got.message) + } + if len(updates.Updates) != 1 { + t.Fatalf("updates len = %d, want 1", len(updates.Updates)) + } + typing, ok := updates.Updates[0].(*tg.UpdateChannelUserTyping) + if !ok { + t.Fatalf("channel update = %T, want *tg.UpdateChannelUserTyping", updates.Updates[0]) + } + if typing.ChannelID != created.Channel.ID || typing.TopMsgID != topicID { + t.Fatalf("channel typing = channel %d top %d, want channel %d top %d", typing.ChannelID, typing.TopMsgID, created.Channel.ID, topicID) + } + from, ok := typing.FromID.(*tg.PeerUser) + if !ok || from.UserID != ownerID { + t.Fatalf("typing from = %T %+v, want owner peer", typing.FromID, typing.FromID) + } + if _, ok := typing.Action.(*tg.SendMessageTypingAction); !ok { + t.Fatalf("typing action = %T, want *tg.SendMessageTypingAction", typing.Action) + } +} + +func TestMessagesSetTypingSkipsChannelMemberWithoutViewerInterest(t *testing.T) { + const ( + ownerID = int64(1000000001) + memberID = int64(1000000002) + ) + channels := appchannels.NewService(memory.NewChannelStore()) + created, err := channels.CreateChannel(context.Background(), ownerID, domain.CreateChannelRequest{ + Title: "quiet group", + CreatorUserID: ownerID, + Megagroup: true, + MemberUserIDs: []int64{memberID}, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sessions := &captureScopedSessions{captureSessions: &captureSessions{ + channelMembers: map[int64][]int64{created.Channel.ID: {memberID}}, + }} + r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + + ok, err := r.onMessagesSetTyping(WithUserID(context.Background(), ownerID), &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }, + Action: &tg.SendMessageTypingAction{}, + }) + if err != nil { + t.Fatalf("set channel typing: %v", err) + } + if !ok { + t.Fatalf("set channel typing = false, want true") + } + if got := sessions.snapshot(); got.message != nil { + t.Fatalf("typing push without viewer interest = %+v, want none", got) + } +} + +func TestMessagesGetMessagesReturnsOwnerMessages(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + messages := &captureMessages{list: domain.MessageList{ + Messages: []domain.Message{{ + ID: 7, + OwnerUserID: userID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Date: 1700000000, + Body: "reply source", + }}, + Count: 1, + }} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + peerID: {ID: peerID, FirstName: "Bob"}, + }}, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onMessagesGetMessages(WithUserID(context.Background(), userID), []tg.InputMessageClass{&tg.InputMessageID{ID: 7}}) + if err != nil { + t.Fatalf("get messages: %v", err) + } + box, ok := got.(*tg.MessagesMessages) + if !ok || len(box.Messages) != 1 { + t.Fatalf("response = %T %+v, want one messages.messages", got, got) + } + msg, ok := box.Messages[0].(*tg.Message) + if !ok || msg.ID != 7 || msg.Message != "reply source" { + t.Fatalf("message = %#v, want source message", box.Messages[0]) + } + if len(box.Users) != 1 { + t.Fatalf("users = %+v, want peer user", box.Users) + } +} + +func TestMessagesSaveDraftPushesDraftUpdateToOtherSessions(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{ + Sessions: sessions, + Users: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + peerID: {ID: peerID, FirstName: "Bob"}, + }}, + }, zaptest.NewLogger(t), clock.System) + var authKeyID [8]byte + authKeyID[0] = 8 + + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKeyID), 66) + ok, err := r.onMessagesSaveDraft(ctx, &tg.MessagesSaveDraftRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + Message: "1111", + Entities: []tg.MessageEntityClass{ + &tg.MessageEntityBold{Offset: 0, Length: 4}, + }, + }) + if err != nil { + t.Fatalf("save draft: %v", err) + } + if !ok { + t.Fatalf("save draft = false, want true") + } + + got := sessions.snapshot() + if got.userID != userID || got.sessionID != 66 || got.messageType != proto.MessageFromServer { + t.Fatalf("push = user %d exclude session %d type %v, want self/exclude/from_server", got.userID, got.sessionID, got.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { + t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) + } + updates, ok := got.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", got.message) + } + if len(updates.Updates) != 1 { + t.Fatalf("updates = %+v, want one update", updates.Updates) + } + update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateDraftMessage", updates.Updates[0]) + } + peer, ok := update.Peer.(*tg.PeerUser) + if !ok || peer.UserID != peerID { + t.Fatalf("draft peer = %#v, want peer user %d", update.Peer, peerID) + } + draft, ok := update.Draft.(*tg.DraftMessage) + if !ok || draft.Message != "1111" || len(draft.Entities) != 1 { + t.Fatalf("draft = %#v, want message and entities", update.Draft) + } + if len(updates.Users) != 2 { + t.Fatalf("users = %+v, want self and peer", updates.Users) + } +} + +func TestMessagesUpdateSavedReactionTagPersistsAndPushesRefresh(t *testing.T) { + const userID = int64(1000000001) + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Channels: appchannels.NewService(memory.NewChannelStore()), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + req := &tg.MessagesUpdateSavedReactionTagRequest{ + Reaction: &tg.ReactionEmoji{Emoticon: "\U0001f44d"}, + } + req.SetTitle("Fav") + ok, err := r.onMessagesUpdateSavedReactionTag(WithSessionID(WithUserID(context.Background(), userID), 55), req) + if err != nil || !ok { + t.Fatalf("update saved reaction tag = %v, %v, want true nil", ok, err) + } + + got, err := r.onMessagesGetSavedReactionTags(WithUserID(context.Background(), userID), &tg.MessagesGetSavedReactionTagsRequest{}) + if err != nil { + t.Fatalf("get saved reaction tags: %v", err) + } + page, ok := got.(*tg.MessagesSavedReactionTags) + if !ok || len(page.Tags) != 1 { + t.Fatalf("saved reaction tags = %T %+v, want one tag", got, got) + } + if emoji, ok := page.Tags[0].Reaction.(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" || page.Tags[0].Title != "Fav" { + t.Fatalf("saved reaction tag = %+v, want persisted thumb/Fav", page.Tags[0]) + } + + push := sessions.snapshot() + if push.userID != userID || push.sessionID != 55 || push.messageType != proto.MessageFromServer { + t.Fatalf("push = user %d exclude session %d type %v, want self/exclude/from_server", push.userID, push.sessionID, push.messageType) + } + updates, ok := push.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", push.message) + } + if len(updates.Updates) != 1 { + t.Fatalf("updates = %+v, want one update", updates.Updates) + } + if _, ok := updates.Updates[0].(*tg.UpdateSavedReactionTags); !ok { + t.Fatalf("update = %T, want *tg.UpdateSavedReactionTags", updates.Updates[0]) + } +} + +func TestMessagesSaveDraftPersistsAndGetAllDraftsReturnsForumDraft(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Forum", + Forum: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + dialogStore := memory.NewDialogStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + Dialogs: appdialogs.NewService(dialogStore, channelStore), + }, zaptest.NewLogger(t), clock.System) + + reply := &tg.InputReplyToMessage{} + reply.SetTopMsgID(123) + ok, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + ReplyTo: reply, + Message: "topic draft", + Media: &tg.InputMediaWebPage{ + URL: "https://example.test/draft", + Optional: true, + }, + }) + if err != nil || !ok { + t.Fatalf("save forum draft = %v, %v", ok, err) + } + drafts, err := dialogStore.ListDrafts(ctx, owner.ID, 10) + if err != nil { + t.Fatalf("list stored drafts: %v", err) + } + if len(drafts) != 1 || drafts[0].TopMessageID != 123 || drafts[0].Message != "topic draft" || drafts[0].WebPage == nil { + t.Fatalf("stored drafts = %+v, want forum draft with webpage", drafts) + } + + got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, owner.ID)) + if err != nil { + t.Fatalf("get all drafts: %v", err) + } + updates, ok := got.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("get all drafts = %T %+v, want one updates", got, got) + } + update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) + if !ok { + t.Fatalf("draft update = %T", updates.Updates[0]) + } + if top, ok := update.GetTopMsgID(); !ok || top != 123 { + t.Fatalf("top_msg_id = %d/%v, want 123/true", top, ok) + } + draft, ok := update.Draft.(*tg.DraftMessage) + if !ok || draft.Message != "topic draft" { + t.Fatalf("draft = %#v, want message draft", update.Draft) + } + if media, ok := draft.Media.(*tg.InputMediaWebPage); !ok || media.URL != "https://example.test/draft" { + t.Fatalf("draft media = %#v, want webpage", draft.Media) + } + if len(updates.Chats) != 1 { + t.Fatalf("chats = %+v, want channel context", updates.Chats) + } +} + +func TestMessagesGetDialogsIncludesCloudDraft(t *testing.T) { + ctx := context.Background() + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + dialogStore := memory.NewDialogStore() + if err := dialogStore.Upsert(ctx, userID, domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }); err != nil { + t.Fatalf("upsert dialog: %v", err) + } + if err := dialogStore.SaveDraft(ctx, userID, domain.DialogDraft{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Date: 1700000100, + Message: "saved draft", + }); err != nil { + t.Fatalf("save draft: %v", err) + } + r := New(Config{}, Deps{ + Users: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + peerID: {ID: peerID, FirstName: "Bob"}, + }}, + Dialogs: appdialogs.NewService(dialogStore), + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 10, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + got, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("get dialogs: %v", err) + } + box, ok := got.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("response = %T, want boxed messages.dialogs", got) + } + dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) + if !ok || len(dialogs.Dialogs) != 1 { + t.Fatalf("dialogs = %T %+v, want one messages.dialogs", box.Dialogs, box.Dialogs) + } + dialog, ok := dialogs.Dialogs[0].(*tg.Dialog) + if !ok { + t.Fatalf("dialog = %T", dialogs.Dialogs[0]) + } + draft, ok := dialog.Draft.(*tg.DraftMessage) + if !ok || draft.Message != "saved draft" { + t.Fatalf("dialog draft = %#v, want saved draft", dialog.Draft) + } +} + +func TestMessagesClearAllDraftsClearsAndPushesEmptyUpdates(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + dialogs := &captureDialogs{drafts: []domain.DialogDraft{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Date: 1700000100, + Message: "draft", + }}} + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{ + Dialogs: dialogs, + Sessions: sessions, + Users: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + peerID: {ID: peerID, FirstName: "Bob"}, + }}, + }, zaptest.NewLogger(t), clock.System) + ok, err := r.onMessagesClearAllDrafts(WithSessionID(WithUserID(context.Background(), userID), 9)) + if err != nil || !ok { + t.Fatalf("clear all drafts = %v, %v", ok, err) + } + got := sessions.snapshot() + updates, ok := got.message.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed = %T %+v, want one update", got.message, got.message) + } + update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) + if !ok { + t.Fatalf("update = %T", updates.Updates[0]) + } + if _, ok := update.Draft.(*tg.DraftMessageEmpty); !ok { + t.Fatalf("draft = %#v, want draftMessageEmpty", update.Draft) + } + if len(dialogs.drafts) != 0 { + t.Fatalf("capture drafts = %+v, want cleared", dialogs.drafts) + } +} + +func TestTGUserIncludesRecentlyStatusForTypingEligibility(t *testing.T) { + got := tgUser(domain.User{ID: 1000000002, FirstName: "Bob"}) + if _, ok := got.Status.(*tg.UserStatusRecently); !ok { + t.Fatalf("status = %T, want *tg.UserStatusRecently", got.Status) + } + + online := tgUser(domain.User{ + ID: 1000000002, + FirstName: "Bob", + Status: domain.UserStatus{Kind: domain.UserStatusOnline, Expires: 1700000300}, + }) + if status, ok := online.Status.(*tg.UserStatusOnline); !ok || status.Expires != 1700000300 { + t.Fatalf("online status = %#v, want userStatusOnline expires=1700000300", online.Status) + } + + offline := tgUser(domain.User{ + ID: 1000000002, + FirstName: "Bob", + Status: domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: 1700000000}, + }) + if status, ok := offline.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != 1700000000 { + t.Fatalf("offline status = %#v, want userStatusOffline was_online=1700000000", offline.Status) + } +} + +func TestRouterTGUserUsesPersistedLastSeen(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + got := r.tgUser(domain.User{ID: 1000000002, FirstName: "Bob", LastSeenAt: 1700000000}) + if status, ok := got.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != 1700000000 { + t.Fatalf("status = %#v, want userStatusOffline was_online=1700000000", got.Status) + } +} + +func TestAccountUpdateStatusPushesPresenceToOnlineContacts(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: bob}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}} + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + callCtx := WithSessionID(WithUserID(ctx, alice.ID), 77) + + ok, err := r.onAccountUpdateStatus(callCtx, false) + if err != nil || !ok { + t.Fatalf("account.updateStatus online = %v, %v", ok, err) + } + gotPushes := sessions.pushedUserIDs() + if !reflect.DeepEqual(gotPushes, []int64{alice.ID, bob.ID}) { + t.Fatalf("pushed users = %+v, want self and online contact", gotPushes) + } + update := pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != alice.ID { + t.Fatalf("status user = %d, want alice", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("status = %#v, want online with future expires", update.Status) + } + + ok, err = r.onAccountUpdateStatus(callCtx, true) + if err != nil || !ok { + t.Fatalf("account.updateStatus offline = %v, %v", ok, err) + } + update = pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != alice.ID { + t.Fatalf("offline status user = %d, want alice", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOffline); !ok || status.WasOnline == 0 { + t.Fatalf("status = %#v, want offline with was_online", update.Status) + } +} + +func TestAccountUpdateStatusPersistsLastSeen(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + const now = 1700000200 + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + + ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, alice.ID), 77), false) + if err != nil || !ok { + t.Fatalf("account.updateStatus online = %v, %v", ok, err) + } + stored, found, err := userStore.ByID(ctx, alice.ID) + if err != nil || !found { + t.Fatalf("load alice found=%v err=%v", found, err) + } + if stored.LastSeenAt != now { + t.Fatalf("last_seen_at after online = %d, want %d", stored.LastSeenAt, now) + } +} + +func TestAccountUpdateStatusPushesPresenceToOnlinePrivateDialogPeers(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + dialogs := memory.NewDialogStore() + if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }}, + Users: []domain.User{bob}, + }); err != nil { + t.Fatalf("save dialogs: %v", err) + } + sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} + r := New(Config{}, Deps{ + Dialogs: appdialogs.NewService(dialogs), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false) + if err != nil || !ok { + t.Fatalf("bob account.updateStatus online = %v, %v", ok, err) + } + gotPushes := sessions.pushedUserIDs() + if !reflect.DeepEqual(gotPushes, []int64{bob.ID, alice.ID}) { + t.Fatalf("pushed users = %+v, want self and online private dialog peer", gotPushes) + } + update := pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != bob.ID { + t.Fatalf("status user = %d, want bob", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("status = %#v, want online with future expires", update.Status) + } +} + +func TestContactsStatusesUsesPersistedLastSeen(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + const lastSeen = 1700000000 + if err := userStore.UpdateLastSeen(ctx, bob.ID, lastSeen); err != nil { + t.Fatalf("update last seen: %v", err) + } + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: domain.User{ID: bob.ID, FirstName: "Old Bob", Contact: true}}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts, userStore), + Users: appusers.NewService(userStore), + }, zaptest.NewLogger(t), clock.System) + + statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("contacts.getStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].UserID != bob.ID { + t.Fatalf("statuses = %+v, want bob only", statuses) + } + if status, ok := statuses[0].Status.(*tg.UserStatusOffline); !ok || status.WasOnline != lastSeen { + t.Fatalf("status = %#v, want userStatusOffline was_online=%d", statuses[0].Status, lastSeen) + } +} + +func TestContactsStatusesAndContactsUsePresence(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: bob}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts), + }, zaptest.NewLogger(t), clock.System) + + if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false); err != nil || !ok { + t.Fatalf("bob account.updateStatus = %v, %v", ok, err) + } + statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("contacts.getStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].UserID != bob.ID { + t.Fatalf("statuses = %+v, want bob only", statuses) + } + if _, ok := statuses[0].Status.(*tg.UserStatusOnline); !ok { + t.Fatalf("contacts.getStatuses status = %T, want online", statuses[0].Status) + } + + contactsRes, err := r.onContactsGetContacts(WithUserID(ctx, alice.ID), 0) + if err != nil { + t.Fatalf("contacts.getContacts: %v", err) + } + full, ok := contactsRes.(*tg.ContactsContacts) + if !ok || len(full.Users) != 1 { + t.Fatalf("contacts result = %T %+v, want one full contacts response", contactsRes, contactsRes) + } + user, ok := full.Users[0].(*tg.User) + if !ok || user.ID != bob.ID { + t.Fatalf("contacts user = %T %+v, want bob", full.Users[0], full.Users[0]) + } + if _, ok := user.Status.(*tg.UserStatusOnline); !ok { + t.Fatalf("contacts user status = %T, want online", user.Status) + } +} + +func TestContactsStatusesUsesOnlineSessionFallback(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: bob}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts), + Sessions: &captureSessions{onlineUserIDs: []int64{bob.ID}}, + }, zaptest.NewLogger(t), clock.System) + + statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("contacts.getStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].UserID != bob.ID { + t.Fatalf("statuses = %+v, want bob only", statuses) + } + if status, ok := statuses[0].Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("fallback status = %#v, want online from active session", statuses[0].Status) + } +} + +func TestContactsStatusesHonorsExplicitOffline(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: bob}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts), + Sessions: &captureSessions{onlineUserIDs: []int64{bob.ID}}, + }, zaptest.NewLogger(t), clock.System) + if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), true); err != nil || !ok { + t.Fatalf("bob account.updateStatus offline = %v, %v", ok, err) + } + + statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("contacts.getStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].UserID != bob.ID { + t.Fatalf("statuses = %+v, want bob only", statuses) + } + if status, ok := statuses[0].Status.(*tg.UserStatusOffline); !ok || status.WasOnline == 0 { + t.Fatalf("status = %#v, want explicit offline", statuses[0].Status) + } +} + +func TestSessionOfflinePersistsLastSeenAndPushes(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, bob.ID, domain.ContactList{Contacts: []domain.Contact{{User: alice}}}); err != nil { + t.Fatalf("save bob contacts: %v", err) + } + const now = 1700000300 + sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts, userStore), + Users: appusers.NewService(userStore), + Sessions: sessions, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + + r.SessionOffline([8]byte{1, 2, 3}, 22, bob.ID, true) + + stored, found, err := userStore.ByID(ctx, bob.ID) + if err != nil || !found { + t.Fatalf("load bob found=%v err=%v", found, err) + } + if stored.LastSeenAt != now { + t.Fatalf("last_seen_at = %d, want %d", stored.LastSeenAt, now) + } + if got := sessions.pushedUserIDs(); !reflect.DeepEqual(got, []int64{bob.ID, alice.ID}) { + t.Fatalf("pushed users = %+v, want bob self and online contact alice", got) + } + update := pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != bob.ID { + t.Fatalf("status user = %d, want bob", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != now { + t.Fatalf("status = %#v, want offline was_online=%d", update.Status, now) + } +} + +func TestAccountUpdateStatusKeepsUserOnlineUntilAllSessionsOffline(t *testing.T) { + const userID = int64(1000000001) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + session1 := WithSessionID(WithUserID(context.Background(), userID), 1) + session2 := WithSessionID(WithUserID(context.Background(), userID), 2) + + if ok, err := r.onAccountUpdateStatus(session1, false); err != nil || !ok { + t.Fatalf("session1 online = %v, %v", ok, err) + } + if ok, err := r.onAccountUpdateStatus(session2, false); err != nil || !ok { + t.Fatalf("session2 online = %v, %v", ok, err) + } + if ok, err := r.onAccountUpdateStatus(session1, true); err != nil || !ok { + t.Fatalf("session1 offline = %v, %v", ok, err) + } + if status := r.userPresenceStatus(userID); status.Kind != domain.UserStatusOnline { + t.Fatalf("aggregate after one offline = %+v, want online", status) + } + if ok, err := r.onAccountUpdateStatus(session2, true); err != nil || !ok { + t.Fatalf("session2 offline = %v, %v", ok, err) + } + if status := r.userPresenceStatus(userID); status.Kind != domain.UserStatusOffline || status.WasOnline == 0 { + t.Fatalf("aggregate after all offline = %+v, want offline", status) + } +} + +func pushedUserStatus(t *testing.T, msg bin.Encoder) *tg.UpdateUserStatus { + t.Helper() + updates, ok := msg.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed message = %T %+v, want one update", msg, msg) + } + update, ok := updates.Updates[0].(*tg.UpdateUserStatus) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateUserStatus", updates.Updates[0]) + } + return update +} + +type fixedClock struct { + now time.Time +} + +func (c fixedClock) Now() time.Time { + return c.now +} + +func (c fixedClock) Timer(d time.Duration) clock.Timer { + return clock.System.Timer(d) +} + +func (c fixedClock) Ticker(d time.Duration) clock.Ticker { + return clock.System.Ticker(d) +} + +func TestMessagesSendReactionPrivatePeerReturnsReactionUpdate(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + now = int64(1700000200) + ) + messages := &captureMessages{} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + req := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + MsgID: 7, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + Big: true, + } + req.SetReaction(req.Reaction) + req.SetAddToRecent(true) + + updates, err := r.onMessagesSendReaction(WithUserID(context.Background(), userID), req) + if err != nil { + t.Fatalf("messages.sendReaction private: %v", err) + } + if messages.setReactionReq.UserID != userID || messages.setReactionReq.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: peerID}) || messages.setReactionReq.MessageID != req.MsgID || !messages.setReactionReq.Big || !messages.setReactionReq.AddToRecent { + t.Fatalf("set reaction req = %+v, want private peer/message context", messages.setReactionReq) + } + got := updates.(*tg.Updates).Updates + if len(got) != 1 { + t.Fatalf("updates = %+v, want one reaction update", got) + } + update, ok := got[0].(*tg.UpdateMessageReactions) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateMessageReactions", got[0]) + } + peer, ok := update.Peer.(*tg.PeerUser) + if !ok || peer.UserID != peerID || update.MsgID != req.MsgID { + t.Fatalf("update peer/msg = %+v/%d, want peer %d msg %d", update.Peer, update.MsgID, peerID, req.MsgID) + } + if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 || update.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("reaction results = %+v, want one chosen reaction", update.Reactions.Results) + } +} + +func TestMessagesSendReactionPrivatePushesViewerLocalMessageID(t *testing.T) { + const ( + aliceID = int64(1000000001) + bobID = int64(1000000002) + now = int64(1700000200) + ) + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} + aliceReactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: reaction, + Count: 1, + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + UserID: bobID, + Reaction: reaction, + Date: int(now), + }}, + } + bobReactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: reaction, + Count: 1, + ChosenOrder: 1, + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + UserID: bobID, + Reaction: reaction, + My: true, + ChosenOrder: 1, + Date: int(now), + }}, + } + messages := &captureMessages{ + setReactionRes: domain.PrivateMessageReactionsResult{ + Messages: []domain.Message{ + { + ID: 68, + UID: 7001, + OwnerUserID: aliceID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + Date: int(now), + Reactions: &aliceReactions, + }, + { + ID: 64, + UID: 7001, + OwnerUserID: bobID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + Date: int(now), + Reactions: &bobReactions, + }, + }, + Reactions: bobReactions, + }, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{Messages: messages, Sessions: sessions}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + req := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: aliceID, AccessHash: 11}, + MsgID: 64, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + } + req.SetReaction(req.Reaction) + + updates, err := r.onMessagesSendReaction(WithSessionID(WithUserID(context.Background(), bobID), 77), req) + if err != nil { + t.Fatalf("messages.sendReaction private: %v", err) + } + self := updates.(*tg.Updates).Updates[0].(*tg.UpdateMessageReactions) + if peer, ok := self.Peer.(*tg.PeerUser); !ok || peer.UserID != aliceID || self.MsgID != 64 { + t.Fatalf("self update peer/msg = %#v/%d, want alice/msg64", self.Peer, self.MsgID) + } + if got := sessions.pushedUserIDs(); len(got) != 2 || got[0] != bobID || got[1] != aliceID { + t.Fatalf("pushed users = %+v, want bob then alice", got) + } + pushed := sessions.snapshot() + if pushed.userID != aliceID || pushed.sessionID != 77 || pushed.messageType != proto.MessageFromServer { + t.Fatalf("last push = user %d session %d type %v, want alice/exclude bob/from_server", pushed.userID, pushed.sessionID, pushed.messageType) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Updates) != 1 { + t.Fatalf("pushed message = %T %+v, want one updates container", pushed.message, pushed.message) + } + other, ok := pushedUpdates.Updates[0].(*tg.UpdateMessageReactions) + if !ok { + t.Fatalf("pushed update = %T, want *tg.UpdateMessageReactions", pushedUpdates.Updates[0]) + } + peer, ok := other.Peer.(*tg.PeerUser) + if !ok || peer.UserID != bobID || other.MsgID != 68 { + t.Fatalf("pushed update peer/msg = %#v/%d, want bob/msg68", other.Peer, other.MsgID) + } + if len(other.Reactions.Results) != 1 || other.Reactions.Results[0].Count != 1 || other.Reactions.Results[0].ChosenOrder != 0 { + t.Fatalf("pushed reaction results = %+v, want one non-chosen reaction", other.Reactions.Results) + } +} + +func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) { + sender := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Sender"} + recipient := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Recipient"} + messages := &captureMessages{} + metrics := &captureRPCMetrics{} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{sender.ID: sender, recipient.ID: recipient}}, + Metrics: metrics, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, + Message: "hello", + RandomID: 123456, + Entities: []tg.MessageEntityClass{ + &tg.MessageEntityBold{Offset: 0, Length: 5}, + }, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), sender.ID), [8]byte{}, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.UpdatesBox) + if !ok { + t.Fatalf("response = %T, want *tg.UpdatesBox", enc) + } + got, ok := box.Updates.(*tg.Updates) + if !ok { + t.Fatalf("boxed response = %T, want *tg.Updates", box.Updates) + } + if messages.sendUserID != sender.ID || messages.sendReq.SenderUserID != sender.ID || messages.sendReq.RecipientUserID != recipient.ID || messages.sendReq.OriginSessionID != 77 { + t.Fatalf("send context = user %d req %+v, want sender/recipient/session", messages.sendUserID, messages.sendReq) + } + if len(messages.sendReq.Entities) != 1 || messages.sendReq.Entities[0].Type != domain.MessageEntityBold { + t.Fatalf("entities = %+v, want bold entity converted to domain", messages.sendReq.Entities) + } + if len(got.Updates) != 2 { + t.Fatalf("updates = %+v, want message id + new message", got.Updates) + } + if id, ok := got.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != 1 || id.RandomID != req.RandomID { + t.Fatalf("update id = %#v, want id=1 random_id=%d", got.Updates[0], req.RandomID) + } + newMsg, ok := got.Updates[1].(*tg.UpdateNewMessage) + if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 { + t.Fatalf("new message update = %#v, want pts=1 pts_count=1", got.Updates[1]) + } + msg, ok := newMsg.Message.(*tg.Message) + if !ok || !msg.Out || msg.PeerID.(*tg.PeerUser).UserID != recipient.ID || msg.Message != req.Message { + t.Fatalf("message = %#v, want outgoing private text to recipient", newMsg.Message) + } + if metrics.messageSend != 1 || metrics.messageSendErr != nil { + t.Fatalf("metrics send=%d err=%v, want one successful send", metrics.messageSend, metrics.messageSendErr) + } +} + +func TestMessagesSendMessageSupportsReplyAndFlags(t *testing.T) { + const ( + senderID = int64(1000000001) + recipientID = int64(1000000002) + ) + messages := &captureMessages{} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{senderID: {ID: senderID, FirstName: "Sender"}, recipientID: {ID: recipientID, FirstName: "Recipient"}}}, + }, zaptest.NewLogger(t), clock.System) + reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} + reply.SetQuoteText("hello") + reply.SetQuoteOffset(1) + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: recipientID}, + Message: "reply", + RandomID: 456, + Silent: true, + } + req.SetNoforwards(true) + req.SetReplyTo(reply) + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), senderID), [8]byte{}, 88, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if messages.sendReq.ReplyTo == nil || messages.sendReq.ReplyTo.MessageID != 7 || messages.sendReq.ReplyTo.Peer.ID != recipientID || messages.sendReq.ReplyTo.QuoteText != "hello" { + t.Fatalf("reply request = %+v, want reply metadata", messages.sendReq.ReplyTo) + } + if !messages.sendReq.Silent || !messages.sendReq.NoForwards { + t.Fatalf("send flags silent=%v noforwards=%v, want true/true", messages.sendReq.Silent, messages.sendReq.NoForwards) + } + box, ok := enc.(*tg.UpdatesBox) + if !ok { + t.Fatalf("response = %T, want *tg.UpdatesBox", enc) + } + got := box.Updates.(*tg.Updates) + newMsg := got.Updates[1].(*tg.UpdateNewMessage) + msg := newMsg.Message.(*tg.Message) + if !msg.Silent || !msg.Noforwards { + t.Fatalf("message flags silent=%v noforwards=%v, want true/true", msg.Silent, msg.Noforwards) + } + header, ok := msg.ReplyTo.(*tg.MessageReplyHeader) + if !ok || header.ReplyToMsgID != 7 { + t.Fatalf("reply header = %#v, want msg id 7", msg.ReplyTo) + } +} + +func TestMessagesSendMessageRejectsHugeReplyQuoteOffset(t *testing.T) { + const ( + senderID = int64(1000000001) + recipientID = int64(1000000002) + ) + messages := &captureMessages{} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{recipientID: {ID: recipientID, FirstName: "Recipient"}}}, + }, zaptest.NewLogger(t), clock.System) + reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} + reply.SetQuoteText("hello") + reply.SetQuoteOffset(domain.MaxMessageReplyQuoteOffset + 1) + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: recipientID}, + Message: "reply", + RandomID: 457, + } + req.SetReplyTo(reply) + + if _, err := r.onMessagesSendMessage(WithUserID(context.Background(), senderID), req); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("huge quote offset err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } + if messages.sendReq.RandomID != 0 { + t.Fatalf("send request reached service: %+v", messages.sendReq) + } +} + +func TestMessageReplyFromInputUnsupportedShapesReturnExplicitErrors(t *testing.T) { + const userID = int64(1000000001) + ctx := WithUserID(context.Background(), userID) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 1000000002} + withReplyMsg := func(update func(*tg.InputReplyToMessage)) *tg.InputReplyToMessage { + reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} + update(reply) + return reply + } + cases := []struct { + name string + input tg.InputReplyToClass + want string + }{ + { + name: "story", + input: &tg.InputReplyToStory{Peer: &tg.InputPeerUser{UserID: 1000000003}, StoryID: 1}, + want: "STORY_ID_INVALID", + }, + { + name: "monoforum constructor", + input: &tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerChannel{ChannelID: 1000000004}}, + want: "REPLY_TO_MONOFORUM_PEER_INVALID", + }, + { + name: "monoforum field", + input: withReplyMsg(func(reply *tg.InputReplyToMessage) { + reply.SetMonoforumPeerID(&tg.InputPeerChannel{ChannelID: 1000000004}) + }), + want: "REPLY_TO_MONOFORUM_PEER_INVALID", + }, + { + name: "todo item", + input: withReplyMsg(func(reply *tg.InputReplyToMessage) { + reply.SetTodoItemID(1) + }), + want: "REPLY_MESSAGE_ID_INVALID", + }, + { + name: "poll option", + input: withReplyMsg(func(reply *tg.InputReplyToMessage) { + reply.SetPollOption([]byte{1}) + }), + want: "POLL_OPTION_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.messageReplyFromInput(ctx, userID, peer, tc.input); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("reply err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestMessagesSendMessageUnsupportedOptionErrors(t *testing.T) { + const ( + senderID = int64(1000000001) + recipientID = int64(1000000002) + ) + ctx := WithUserID(context.Background(), senderID) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + base := func() *tg.MessagesSendMessageRequest { + return &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: recipientID}, + Message: "hello", + RandomID: 456, + } + } + suggested := func() tg.SuggestedPost { + post := tg.SuggestedPost{} + post.SetAccepted(true) + return post + } + cases := []struct { + name string + req *tg.MessagesSendMessageRequest + want string + }{ + { + name: "reply markup", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetReplyMarkup(&tg.ReplyKeyboardHide{}) + return req + }(), + want: "REPLY_MARKUP_INVALID", + }, + { + name: "quick reply", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetQuickReplyShortcut(&tg.InputQuickReplyShortcut{Shortcut: "hello"}) + return req + }(), + want: "SHORTCUT_INVALID", + }, + { + name: "effect", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetEffect(1) + return req + }(), + want: "EFFECT_ID_INVALID", + }, + { + name: "negative paid stars", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetAllowPaidStars(-1) + return req + }(), + want: "STARS_AMOUNT_INVALID", + }, + { + name: "paid stars", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetAllowPaidStars(1) + return req + }(), + want: "PAYMENT_UNSUPPORTED", + }, + { + name: "paid floodskip", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetAllowPaidFloodskip(true) + return req + }(), + want: "PAYMENT_UNSUPPORTED", + }, + { + name: "suggested post", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetSuggestedPost(suggested()) + return req + }(), + want: "SUGGESTED_POST_PEER_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onMessagesSendMessage(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("send err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestMessagesForwardMessagesRecordsRequestAndReturnsUpdates(t *testing.T) { + const ( + ownerID = int64(1000000001) + fromID = int64(1000000002) + toID = int64(1000000003) + ) + messages := &captureMessages{} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{ + ownerID: {ID: ownerID, FirstName: "Owner"}, + fromID: {ID: fromID, FirstName: "From"}, + toID: {ID: toID, FirstName: "To"}, + }}, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: fromID}, + ToPeer: &tg.InputPeerUser{UserID: toID}, + ID: []int{3, 4}, + RandomID: []int64{1001, 1002}, + Silent: true, + Noforwards: true, + } + replyTo := &tg.InputReplyToMessage{ReplyToMsgID: 9} + replyTo.SetQuoteText("target") + req.SetReplyTo(replyTo) + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), ownerID), [8]byte{}, 99, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if messages.forwardUserID != ownerID || messages.forwardReq.FromPeer.ID != fromID || messages.forwardReq.ToUserID != toID || len(messages.forwardReq.MessageIDs) != 2 || messages.forwardReq.OriginSessionID != 99 { + t.Fatalf("forward request = user %d %+v, want owner/from/to/session", messages.forwardUserID, messages.forwardReq) + } + if messages.forwardReq.ReplyTo == nil || messages.forwardReq.ReplyTo.MessageID != 9 || messages.forwardReq.ReplyTo.Peer.ID != toID || messages.forwardReq.ReplyTo.QuoteText != "target" { + t.Fatalf("forward reply = %+v, want target peer reply metadata", messages.forwardReq.ReplyTo) + } + box, ok := enc.(*tg.UpdatesBox) + if !ok { + t.Fatalf("response = %T, want *tg.UpdatesBox", enc) + } + got := box.Updates.(*tg.Updates) + if len(got.Updates) != 4 { + t.Fatalf("updates = %+v, want two message ids and two new messages", got.Updates) + } + if id, ok := got.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 1001 { + t.Fatalf("first update = %#v, want updateMessageID random 1001", got.Updates[0]) + } + newMsg := got.Updates[1].(*tg.UpdateNewMessage) + msg := newMsg.Message.(*tg.Message) + if msg.FwdFrom.Date == 0 || !msg.Silent || !msg.Noforwards { + t.Fatalf("forwarded message = %#v, want fwd header and flags", msg) + } + if header, ok := msg.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != 9 { + t.Fatalf("forwarded reply = %#v, want reply header id=9", msg.ReplyTo) + } + hasForwardAuthor := false + for _, user := range got.Users { + if u, ok := user.(*tg.User); ok && u.ID == fromID { + hasForwardAuthor = true + break + } + } + if !hasForwardAuthor { + t.Fatalf("forward users = %+v, want original author %d for fwd_from", got.Users, fromID) + } +} + +func TestMessagesForwardMessagesUnsupportedOptionErrors(t *testing.T) { + const ownerID = int64(1000000001) + ctx := WithUserID(context.Background(), ownerID) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + base := func() *tg.MessagesForwardMessagesRequest { + return &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: 1000000002}, + ToPeer: &tg.InputPeerUser{UserID: 1000000003}, + ID: []int{3}, + RandomID: []int64{1001}, + } + } + suggested := func() tg.SuggestedPost { + post := tg.SuggestedPost{} + post.SetAccepted(true) + return post + } + cases := []struct { + name string + req *tg.MessagesForwardMessagesRequest + want string + }{ + { + name: "quick reply", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetQuickReplyShortcut(&tg.InputQuickReplyShortcut{Shortcut: "hello"}) + return req + }(), + want: "SHORTCUT_INVALID", + }, + { + name: "effect", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetEffect(1) + return req + }(), + want: "EFFECT_ID_INVALID", + }, + { + name: "video timestamp without media model", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetVideoTimestamp(10) + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "negative paid stars", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetAllowPaidStars(-1) + return req + }(), + want: "STARS_AMOUNT_INVALID", + }, + { + name: "paid floodskip", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetAllowPaidFloodskip(true) + return req + }(), + want: "PAYMENT_UNSUPPORTED", + }, + { + name: "suggested post", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetSuggestedPost(suggested()) + return req + }(), + want: "SUGGESTED_POST_PEER_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onMessagesForwardMessages(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("forward err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestMessagesEditMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + var authKeyID [8]byte + authKeyID[0] = 9 + messages := &captureMessages{} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + ID: 3, + } + req.SetMessage("edited") + req.SetEntities([]tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 6}}) + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.UpdatesBox) + if !ok { + t.Fatalf("response = %T, want *tg.UpdatesBox", enc) + } + got, ok := box.Updates.(*tg.Updates) + if !ok || len(got.Updates) != 1 { + t.Fatalf("boxed updates = %T %+v, want one update", box.Updates, box.Updates) + } + edit, ok := got.Updates[0].(*tg.UpdateEditMessage) + if !ok || edit.Pts != 7 || edit.PtsCount != 1 { + t.Fatalf("edit update = %#v, want pts=7 count=1", got.Updates[0]) + } + msg, ok := edit.Message.(*tg.Message) + if !ok || msg.ID != 3 || msg.Message != "edited" { + t.Fatalf("edited message = %#v, want id=3 text edited", edit.Message) + } + if messages.editReq.OwnerUserID != userID || messages.editReq.Peer.ID != peerID || messages.editReq.ID != 3 || messages.editReq.OriginAuthKeyID != authKeyID || messages.editReq.OriginSessionID != 77 { + t.Fatalf("edit request = %+v, want owner peer message id and origin", messages.editReq) + } + if len(messages.editReq.Entities) != 1 || messages.editReq.Entities[0].Type != domain.MessageEntityBold { + t.Fatalf("edit entities = %+v, want bold", messages.editReq.Entities) + } +} + +func TestMessagesEditMessageOptionBoundaries(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + ctx := WithUserID(context.Background(), userID) + r := New(Config{}, Deps{Messages: &captureMessages{}}, zaptest.NewLogger(t), clock.System) + + webPreviewReq := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + ID: 3, + } + webPreviewReq.SetMessage("edited with link") + webPreviewReq.SetMedia(&tg.InputMediaWebPage{URL: "https://example.test/"}) + if _, err := r.onMessagesEditMessage(ctx, webPreviewReq); err != nil { + t.Fatalf("edit with webpage media err = %v, want text-only downgrade", err) + } + + cases := []struct { + name string + req *tg.MessagesEditMessageRequest + want string + }{ + { + name: "quick reply shortcut", + req: func() *tg.MessagesEditMessageRequest { + req := &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3} + req.SetMessage("edited") + req.SetQuickReplyShortcutID(11) + return req + }(), + want: "MESSAGE_ID_INVALID", + }, + { + name: "unsupported media", + req: func() *tg.MessagesEditMessageRequest { + req := &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3} + req.SetMessage("edited") + req.SetMedia(&tg.InputMediaUploadedPhoto{}) + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "reply markup", + req: func() *tg.MessagesEditMessageRequest { + req := &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3} + req.SetMessage("edited") + req.SetReplyMarkup(&tg.ReplyKeyboardHide{}) + return req + }(), + want: "REPLY_MARKUP_INVALID", + }, + { + name: "message flag missing", + req: &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3}, + want: "MESSAGE_EMPTY", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onMessagesEditMessage(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("edit err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestMessagesGetMessageEditDataPrivateValidatesAuthor(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + messages := &captureMessages{list: domain.MessageList{ + Messages: []domain.Message{{ + ID: 3, + OwnerUserID: userID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, + Out: true, + Body: "editable", + }}, + }} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + + got, err := r.onMessagesGetMessageEditData(WithUserID(context.Background(), userID), &tg.MessagesGetMessageEditDataRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + ID: 3, + }) + if err != nil { + t.Fatalf("get private edit data: %v", err) + } + if got.GetCaption() { + t.Fatalf("private edit data caption = true, want false for text-only message") + } + + messages.list.Messages[0].Out = false + if _, err := r.onMessagesGetMessageEditData(WithUserID(context.Background(), userID), &tg.MessagesGetMessageEditDataRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + ID: 3, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_AUTHOR_REQUIRED") { + t.Fatalf("non-author edit data err = %v, want MESSAGE_AUTHOR_REQUIRED", err) + } +} + +func TestMessagesGetOutboxReadDateReturnsDate(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + messages := &captureMessages{outboxReadDate: 1700000300} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetOutboxReadDateRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + MsgID: 3, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), userID), [8]byte{}, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.OutboxReadDate) + if !ok || got.Date != 1700000300 { + t.Fatalf("response = %T %#v, want outboxReadDate date", enc, enc) + } + if messages.outboxReadDateReq.OwnerUserID != userID || messages.outboxReadDateReq.Peer.ID != peerID || messages.outboxReadDateReq.ID != 3 { + t.Fatalf("read date request = %+v, want owner peer message id", messages.outboxReadDateReq) + } +} + +func TestMessagesReadMessageContentsPushesUpdateToOtherSessions(t *testing.T) { + authKeyID := [8]byte{9, 9, 9} + messages := &captureMessages{ + readContentsRes: domain.ReadMessageContentsResult{ + OwnerUserID: 1000000001, + MessageIDs: []int{7, 8}, + }, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Messages: messages, + Updates: &captureUpdates{state: domain.UpdateState{Pts: 42, Date: 1700000200}}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + + affected, err := r.onMessagesReadMessageContents(ctx, []int{7, 8}) + if err != nil { + t.Fatalf("messages.readMessageContents: %v", err) + } + if affected.Pts != 42 || affected.PtsCount != 0 { + t.Fatalf("affected = %+v, want pts=42 pts_count=0", affected) + } + if messages.readContentsReq.OwnerUserID != 1000000001 || !reflect.DeepEqual(messages.readContentsReq.IDs, []int{7, 8}) { + t.Fatalf("read contents req = %+v", messages.readContentsReq) + } + snap := sessions.snapshot() + if snap.userID != 1000000001 || snap.sessionID != 55 || snap.messageType != proto.MessageFromServer { + t.Fatalf("push target = %+v", snap) + } + updates, ok := snap.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", snap.message) + } + if len(updates.Updates) != 1 { + t.Fatalf("updates = %+v", updates.Updates) + } + read, ok := updates.Updates[0].(*tg.UpdateReadMessagesContents) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateReadMessagesContents", updates.Updates[0]) + } + if !reflect.DeepEqual(read.Messages, []int{7, 8}) || read.Pts != 42 || read.PtsCount != 0 { + t.Fatalf("read update = %+v", read) + } +} + +func TestMessagesReadHistoryMarksDialogRead(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 7 + messages := &captureMessages{readResult: domain.ReadHistoryResult{ + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + MaxID: 12, + Changed: true, + InboxEvent: domain.UpdateEvent{ + Type: domain.UpdateEventReadHistoryInbox, + Pts: 5, + PtsCount: 1, + Date: 1700000100, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + MaxID: 12, + }, + }} + updates := &captureUpdates{state: domain.UpdateState{Pts: 5, Date: 1700000100, Seq: 3}} + sessions := &captureSessions{} + r := New(Config{}, Deps{Messages: messages, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesReadHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + MaxID: 12, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), authKeyID, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesAffectedMessages) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesAffectedMessages", enc) + } + if messages.readPeer.ID != domain.OfficialSystemUserID || messages.readMaxID != 12 { + t.Fatalf("read = peer %+v max %d, want official/12", messages.readPeer, messages.readMaxID) + } + if got.Pts != 5 || got.PtsCount != 1 { + t.Fatalf("affected = %+v, want recorded read-history pts", got) + } + gotSession := sessions.snapshot() + if gotSession.userID != 1000000001 || gotSession.messageType != proto.MessageFromServer { + t.Fatalf("push target = user %d type %v, want read update push to other sessions", gotSession.userID, gotSession.messageType) + } +} + +func TestMessagesDeleteMessagesPassesOwnerContext(t *testing.T) { + const userID = int64(1000000001) + var authKeyID [8]byte + authKeyID[0] = 10 + messages := &captureMessages{deleteMessagesRes: domain.DeleteMessagesResult{ + OwnerUserID: userID, + Deleted: []domain.DeletedMessagesForUser{{ + UserID: userID, + MessageIDs: []int{2, 3}, + Event: domain.UpdateEvent{Pts: 9, PtsCount: 2}, + }}, + }} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesDeleteMessagesRequest{Revoke: true, ID: []int{3, 2}} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesAffectedMessages) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesAffectedMessages", enc) + } + if got.Pts != 9 || got.PtsCount != 2 { + t.Fatalf("affected = %+v, want pts=9 pts_count=2", got) + } + if messages.deleteMessagesReq.OwnerUserID != userID || !messages.deleteMessagesReq.Revoke || messages.deleteMessagesReq.OriginSessionID != 77 || messages.deleteMessagesReq.OriginAuthKeyID != authKeyID { + t.Fatalf("delete request = %+v, want owner/revoke/current session", messages.deleteMessagesReq) + } + if len(messages.deleteMessagesReq.IDs) != 2 || messages.deleteMessagesReq.IDs[0] != 3 || messages.deleteMessagesReq.IDs[1] != 2 { + t.Fatalf("delete ids = %+v, want request order [3 2]", messages.deleteMessagesReq.IDs) + } +} + +func TestMessagesDeleteHistoryPassesJustClearContext(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + var authKeyID [8]byte + authKeyID[0] = 11 + messages := &captureMessages{deleteHistoryRes: domain.DeleteMessagesResult{ + OwnerUserID: userID, + Deleted: []domain.DeletedMessagesForUser{{ + UserID: userID, + MessageIDs: []int{1, 2, 3}, + Event: domain.UpdateEvent{Pts: 12, PtsCount: 3}, + }}, + }} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesDeleteHistoryRequest{ + JustClear: true, + Revoke: true, + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + MaxID: 15, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 88, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesAffectedHistory) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesAffectedHistory", enc) + } + if got.Pts != 12 || got.PtsCount != 3 { + t.Fatalf("affected = %+v, want pts=12 pts_count=3", got) + } + reqGot := messages.deleteHistoryReq + if reqGot.OwnerUserID != userID || reqGot.Peer.ID != peerID || reqGot.MaxID != 15 || !reqGot.JustClear || !reqGot.Revoke || reqGot.OriginSessionID != 88 || reqGot.OriginAuthKeyID != authKeyID { + t.Fatalf("delete history request = %+v, want owner peer max_id flags current session", reqGot) + } +} + +type staticUsersService struct { + user domain.User +} + +type mapUsersService struct { + users map[int64]domain.User +} + +type captureAuthService struct { + resolvedAuthKeyID [8]byte + hasResolved bool + resolveCount int + userID int64 + userIDCount int + signInUser domain.User + loggedOutAuthKeyID [8]byte +} + +type blockingUserAuthService struct { + userID int64 + started chan struct{} + release chan struct{} + once sync.Once + mu sync.Mutex + count int +} + +func newBlockingUserAuthService(userID int64) *blockingUserAuthService { + return &blockingUserAuthService{ + userID: userID, + started: make(chan struct{}), + release: make(chan struct{}), + } +} + +func (s *blockingUserAuthService) UserIDCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.count +} + +func (s *blockingUserAuthService) BindTempAuthKey(context.Context, int64, domain.TempAuthKeyBinding) error { + return nil +} + +func (s *blockingUserAuthService) ResolveAuthKey(context.Context, [8]byte) ([8]byte, bool, error) { + return [8]byte{}, false, nil +} + +func (s *blockingUserAuthService) UserID(ctx context.Context, _ [8]byte) (int64, bool, error) { + s.mu.Lock() + s.count++ + s.mu.Unlock() + s.once.Do(func() { close(s.started) }) + select { + case <-s.release: + return s.userID, s.userID != 0, nil + case <-ctx.Done(): + return 0, false, ctx.Err() + } +} + +func (s *blockingUserAuthService) SendCode(context.Context, string) (string, error) { + return "", nil +} + +func (s *blockingUserAuthService) SignIn(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { + return domain.User{}, domain.Message{}, false, nil +} + +func (s *blockingUserAuthService) SignUp(context.Context, domain.Authorization, string, string, string, string) (domain.User, domain.Message, error) { + return domain.User{}, domain.Message{}, nil +} + +func (s *blockingUserAuthService) LogOut(context.Context, [8]byte) error { + return nil +} + +func (s *captureAuthService) BindTempAuthKey(context.Context, int64, domain.TempAuthKeyBinding) error { + return nil +} + +func (s *captureAuthService) ResolveAuthKey(context.Context, [8]byte) ([8]byte, bool, error) { + s.resolveCount++ + return s.resolvedAuthKeyID, s.hasResolved, nil +} + +func (s *captureAuthService) UserID(context.Context, [8]byte) (int64, bool, error) { + s.userIDCount++ + return s.userID, s.userID != 0, nil +} + +func (s *captureAuthService) SendCode(context.Context, string) (string, error) { + return "", nil +} + +func (s *captureAuthService) SignIn(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { + if s.signInUser.ID != 0 { + return s.signInUser, domain.Message{}, false, nil + } + return domain.User{}, domain.Message{}, false, nil +} + +func (s *captureAuthService) SignUp(context.Context, domain.Authorization, string, string, string, string) (domain.User, domain.Message, error) { + return domain.User{}, domain.Message{}, nil +} + +func (s *captureAuthService) LogOut(_ context.Context, authKeyID [8]byte) error { + s.loggedOutAuthKeyID = authKeyID + return nil +} + +func (s staticUsersService) Self(context.Context, int64) (domain.User, error) { + return s.user, nil +} + +func (s staticUsersService) ByID(_ context.Context, _, userID int64) (domain.User, bool, error) { + if userID == s.user.ID { + return s.user, true, nil + } + if userID == domain.OfficialSystemUserID { + return domain.OfficialSystemUser(), true, nil + } + return domain.User{}, false, nil +} + +func (s staticUsersService) ByIDs(_ context.Context, _ int64, userIDs []int64) ([]domain.User, error) { + out := make([]domain.User, 0, len(userIDs)) + seen := map[int64]struct{}{} + for _, userID := range userIDs { + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + if userID == s.user.ID { + out = append(out, s.user) + } else if userID == domain.OfficialSystemUserID { + out = append(out, domain.OfficialSystemUser()) + } + } + return out, nil +} + +func (s mapUsersService) Self(_ context.Context, userID int64) (domain.User, error) { + return s.users[userID], nil +} + +func (s mapUsersService) ByID(_ context.Context, _, userID int64) (domain.User, bool, error) { + u, ok := s.users[userID] + return u, ok, nil +} + +func (s mapUsersService) ByIDs(_ context.Context, _ int64, userIDs []int64) ([]domain.User, error) { + out := make([]domain.User, 0, len(userIDs)) + seen := map[int64]struct{}{} + for _, userID := range userIDs { + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + if u, ok := s.users[userID]; ok { + out = append(out, u) + } + } + return out, nil +} + +type captureUsersService struct { + user domain.User + userID int64 +} + +func (s *captureUsersService) Self(_ context.Context, userID int64) (domain.User, error) { + s.userID = userID + return s.user, nil +} + +func (s *captureUsersService) ByID(_ context.Context, currentUserID, userID int64) (domain.User, bool, error) { + s.userID = currentUserID + if userID == s.user.ID { + return s.user, true, nil + } + return domain.User{}, false, nil +} + +func (s *captureUsersService) ByIDs(_ context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) { + s.userID = currentUserID + out := make([]domain.User, 0, len(userIDs)) + for _, userID := range userIDs { + if userID == s.user.ID { + out = append(out, s.user) + } + } + return out, nil +} + +type captureSessions struct { + mu sync.Mutex + sessionID int64 + userID int64 + userResolved bool + authKeyID [8]byte + authKeyResolved bool + receives bool + messageType proto.MessageType + message bin.Encoder + pushUserIDs []int64 + onlineUserIDs []int64 + channelViewers map[int64][]int64 + channelMembers map[int64][]int64 +} + +type captureSessionsSnapshot struct { + sessionID int64 + userID int64 + userResolved bool + authKeyID [8]byte + authKeyResolved bool + receives bool + messageType proto.MessageType + message bin.Encoder +} + +func (s *captureSessions) snapshot() captureSessionsSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + return captureSessionsSnapshot{ + sessionID: s.sessionID, + userID: s.userID, + userResolved: s.userResolved, + authKeyID: s.authKeyID, + authKeyResolved: s.authKeyResolved, + receives: s.receives, + messageType: s.messageType, + message: s.message, + } +} + +func (s *captureSessions) pushedUserIDs() []int64 { + s.mu.Lock() + defer s.mu.Unlock() + return append([]int64(nil), s.pushUserIDs...) +} + +func (s *captureSessions) BindAuthKey(sessionID int64, authKeyID [8]byte) { + s.mu.Lock() + defer s.mu.Unlock() + if s.authKeyResolved && s.authKeyID != authKeyID { + s.userID = 0 + s.userResolved = false + } + s.sessionID = sessionID + s.authKeyID = authKeyID + s.authKeyResolved = true +} + +func (s *captureSessions) AuthKeyID(int64) ([8]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.authKeyID, s.authKeyResolved +} + +func (s *captureSessions) BindUser(sessionID, userID int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionID = sessionID + s.userID = userID + s.userResolved = true +} + +func (s *captureSessions) UserID(int64) (int64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.userID, s.userID != 0 +} + +func (s *captureSessions) UserIDResolved(int64) (int64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.userID, s.userResolved +} + +func (s *captureSessions) UnbindAuthKey(authKeyID [8]byte) int { + s.mu.Lock() + defer s.mu.Unlock() + if s.authKeyID == authKeyID { + s.userID = 0 + s.userResolved = true + return 1 + } + return 0 +} + +func (s *captureSessions) SetReceivesUpdates(sessionID int64, receives bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionID = sessionID + s.receives = receives +} + +func (s *captureSessions) PushToSession(_ context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionID = sessionID + s.messageType = t + s.message = msg + return nil +} + +func (s *captureSessions) PushToUserExceptSession(_ context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.userID = userID + s.sessionID = excludeSessionID + s.messageType = t + s.message = msg + s.pushUserIDs = append(s.pushUserIDs, userID) + return 1, nil +} + +func (s *captureSessions) OnlineUserIDs(limit int) []int64 { + s.mu.Lock() + defer s.mu.Unlock() + ids := append([]int64(nil), s.onlineUserIDs...) + if limit > 0 && len(ids) > limit { + ids = ids[:limit] + } + return ids +} + +func (s *captureSessions) IsUserOnline(userID int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range s.onlineUserIDs { + if id == userID { + return true + } + } + return false +} + +func (s *captureSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 { + s.mu.Lock() + defer s.mu.Unlock() + online := make(map[int64]struct{}, len(s.onlineUserIDs)) + for _, id := range s.onlineUserIDs { + online[id] = struct{}{} + } + out := make([]int64, 0, len(candidateUserIDs)) + seen := map[int64]struct{}{} + for _, id := range candidateUserIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if _, ok := online[id]; !ok { + continue + } + out = append(out, id) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +func (s *captureSessions) TrackChannelInterest(_ [8]byte, _ int64, userID int64, channelIDs []int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.channelViewers == nil { + s.channelViewers = make(map[int64][]int64) + } + for channelID, viewers := range s.channelViewers { + out := viewers[:0] + for _, viewerID := range viewers { + if viewerID != userID { + out = append(out, viewerID) + } + } + if len(out) == 0 { + delete(s.channelViewers, channelID) + continue + } + s.channelViewers[channelID] = out + } + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + s.channelViewers[channelID] = append(s.channelViewers[channelID], userID) + } +} + +func (s *captureSessions) ClearChannelInterest(_ [8]byte, _ int64, userID int64) { + s.mu.Lock() + defer s.mu.Unlock() + for channelID, viewers := range s.channelViewers { + out := viewers[:0] + for _, viewerID := range viewers { + if viewerID != userID { + out = append(out, viewerID) + } + } + if len(out) == 0 { + delete(s.channelViewers, channelID) + continue + } + s.channelViewers[channelID] = out + } +} + +func (s *captureSessions) OnlineChannelUserIDs(channelID int64, limit int) []int64 { + s.mu.Lock() + defer s.mu.Unlock() + return limitIDs(s.channelViewers[channelID], limit) +} + +func (s *captureSessions) SetSessionChannelMemberships(_ [8]byte, _ int64, userID int64, channelIDs []int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.channelMembers == nil { + s.channelMembers = make(map[int64][]int64) + } + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + s.channelMembers[channelID] = append(s.channelMembers[channelID], userID) + } +} + +func (s *captureSessions) AddUserChannelMembership(userID, channelID int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.channelMembers == nil { + s.channelMembers = make(map[int64][]int64) + } + s.channelMembers[channelID] = append(s.channelMembers[channelID], userID) +} + +func (s *captureSessions) RemoveUserChannelMembership(userID, channelID int64) { + s.mu.Lock() + defer s.mu.Unlock() + members := s.channelMembers[channelID] + out := members[:0] + for _, id := range members { + if id != userID { + out = append(out, id) + } + } + if len(out) == 0 { + delete(s.channelMembers, channelID) + return + } + s.channelMembers[channelID] = out +} + +func (s *captureSessions) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 { + s.mu.Lock() + defer s.mu.Unlock() + return limitIDs(s.channelMembers[channelID], limit) +} + +func limitIDs(ids []int64, limit int) []int64 { + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +type captureUpdates struct { + state domain.UpdateState + currentState *domain.UpdateState + authKeyID [8]byte + userID int64 + clearedAuthKeyID [8]byte + cleared bool + date int + events []domain.UpdateEvent + excludeSessionID int64 + reliableDispatch bool +} + +func (s *captureUpdates) UsesReliableDispatch() bool { + return s.reliableDispatch +} + +func (s *captureUpdates) GetState(_ context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + return s.state, nil +} + +func (s *captureUpdates) CurrentState(_ context.Context, userID int64) (domain.UpdateState, error) { + s.userID = userID + if s.currentState != nil { + return *s.currentState, nil + } + return s.state, nil +} + +func (s *captureUpdates) GetDifference(_ context.Context, authKeyID [8]byte, userID int64, _ domain.UpdateState) (domain.UpdateDifference, error) { + s.authKeyID = authKeyID + s.userID = userID + return domain.UpdateDifference{State: s.state}, nil +} + +func (s *captureUpdates) ClearAuthKey(_ context.Context, authKeyID [8]byte) error { + s.clearedAuthKeyID = authKeyID + s.cleared = true + return nil +} + +func (s *captureUpdates) RecordNewMessage(_ context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + s.date = msg.Date + event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: s.state.Pts, PtsCount: 1, Date: msg.Date, Message: msg} + s.events = append(s.events, event) + return event, s.state, nil +} + +func (s *captureUpdates) RecordReadHistory(_ context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + s.excludeSessionID = excludeSessionID + event := domain.UpdateEvent{ + Type: domain.UpdateEventReadHistoryInbox, + Pts: s.state.Pts, + PtsCount: 1, + Date: s.state.Date, + Peer: read.Peer, + MaxID: read.MaxID, + StillUnreadCount: read.StillUnreadCount, + } + s.events = append(s.events, event) + return event, s.state, nil +} + +func (s *captureUpdates) RecordContactsReset(_ context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventContactsReset}) +} + +func (s *captureUpdates) RecordDialogPinned(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogPinned, Peer: peer, Bool: pinned}) +} + +func (s *captureUpdates) RecordPinnedDialogs(_ context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPinnedDialogs, Peers: append([]domain.Peer(nil), order...)}) +} + +func (s *captureUpdates) RecordDialogUnreadMark(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogUnreadMark, Peer: peer, Bool: unread}) +} + +func (s *captureUpdates) RecordPeerSettings(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPeerSettings, Peer: peer, Settings: settings}) +} + +func (s *captureUpdates) RecordDialogFilter(_ context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilter, FilterID: folderID, DialogFilter: folder}) +} + +func (s *captureUpdates) RecordDialogFilterOrder(_ context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilterOrder, FilterOrder: append([]int(nil), order...)}) +} + +func (s *captureUpdates) RecordDialogFiltersReload(_ context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilters}) +} + +func (s *captureUpdates) RecordFolderPeers(_ context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventFolderPeers, FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...)}) +} + +func (s *captureUpdates) RecordChannelAvailableMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelAvailable, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + MaxID: availableMinID, + }) +} + +func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelViewForum, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + Bool: enabled, + }) +} + +func (s *captureUpdates) recordCapturedEvent(authKeyID [8]byte, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + if event.Pts == 0 { + event.Pts = s.state.Pts + } + if event.PtsCount == 0 { + event.PtsCount = 1 + } + if event.Date == 0 { + event.Date = s.state.Date + } + event.UserID = userID + s.events = append(s.events, event) + return event, s.state, nil +} + +type captureMessages struct { + list domain.MessageList + filter domain.MessageFilter + sendResult domain.SendPrivateTextResult + sendUserID int64 + sendReq domain.SendPrivateTextRequest + forwardUserID int64 + forwardReq domain.ForwardPrivateMessagesRequest + forwardRes domain.ForwardPrivateMessagesResult + readResult domain.ReadHistoryResult + readReq domain.ReadHistoryRequest + readPeer domain.Peer + readMaxID int + readContentsReq domain.ReadMessageContentsRequest + readContentsRes domain.ReadMessageContentsResult + setReactionReq domain.SetPrivateMessageReactionsRequest + setReactionRes domain.PrivateMessageReactionsResult + getReactionReq domain.PrivateMessageReactionsRequest + getReactionRes domain.PrivateMessageReactionsResult + editReq domain.EditMessageRequest + editRes domain.EditMessageResult + outboxReadDateReq domain.OutboxReadDateRequest + outboxReadDate int + deleteMessagesReq domain.DeleteMessagesRequest + deleteMessagesRes domain.DeleteMessagesResult + deleteHistoryReq domain.DeleteHistoryRequest + deleteHistoryRes domain.DeleteMessagesResult +} + +func (s *captureMessages) SendPrivateText(_ context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + s.sendUserID = userID + s.sendReq = req + if s.sendResult.SenderMessage.ID == 0 { + s.sendResult.SenderMessage = domain.Message{ + ID: 1, + OwnerUserID: req.SenderUserID, + RandomID: req.RandomID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, + Date: req.Date, + Out: true, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: req.Message, + Entities: req.Entities, + ReplyTo: req.ReplyTo, + Forward: req.Forward, + Pts: 1, + } + s.sendResult.SenderEvent = domain.UpdateEvent{ + UserID: req.SenderUserID, + Type: domain.UpdateEventNewMessage, + Pts: 1, + PtsCount: 1, + Date: req.Date, + Message: s.sendResult.SenderMessage, + } + } + return s.sendResult, nil +} + +func (s *captureMessages) ForwardPrivateMessages(_ context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + s.forwardUserID = userID + s.forwardReq = req + if len(s.forwardRes.SenderMessages) == 0 { + s.forwardRes.OwnerUserID = userID + s.forwardRes.SenderMessages = make([]domain.Message, 0, len(req.MessageIDs)) + s.forwardRes.SenderEvents = make([]domain.UpdateEvent, 0, len(req.MessageIDs)) + for i := range req.MessageIDs { + msg := domain.Message{ + ID: i + 1, + OwnerUserID: req.OwnerUserID, + RandomID: req.RandomIDs[i], + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.ToUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + Date: req.Date, + Out: true, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: "forwarded", + ReplyTo: req.ReplyTo, + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: req.FromPeer.ID}, Date: req.Date - 1}, + Pts: i + 1, + } + event := domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: req.Date, + Message: msg, + } + s.forwardRes.SenderMessages = append(s.forwardRes.SenderMessages, msg) + s.forwardRes.SenderEvents = append(s.forwardRes.SenderEvents, event) + } + } + return s.forwardRes, nil +} + +func (s *captureMessages) GetMessages(_ context.Context, _ int64, ids []int) (domain.MessageList, error) { + byID := make(map[int]domain.Message, len(s.list.Messages)) + for _, msg := range s.list.Messages { + byID[msg.ID] = msg + } + out := domain.MessageList{Messages: make([]domain.Message, 0, len(ids)), Users: s.list.Users} + for _, id := range ids { + if msg, ok := byID[id]; ok { + out.Messages = append(out.Messages, msg) + } + } + return out, nil +} + +func (s *captureMessages) GetHistory(_ context.Context, _ int64, filter domain.MessageFilter) (domain.MessageList, error) { + s.filter = filter + return s.list, nil +} + +func (s *captureMessages) Search(_ context.Context, _ int64, filter domain.MessageFilter) (domain.MessageList, error) { + s.filter = filter + return s.list, nil +} + +func (s *captureMessages) ReadHistory(_ context.Context, _ int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { + s.readReq = req + s.readPeer = req.Peer + s.readMaxID = req.MaxID + if s.readResult.OwnerUserID == 0 { + s.readResult.OwnerUserID = req.OwnerUserID + } + if s.readResult.Peer.ID == 0 { + s.readResult.Peer = req.Peer + } + if s.readResult.MaxID == 0 { + s.readResult.MaxID = req.MaxID + } + return s.readResult, nil +} + +func (s *captureMessages) ReadMessageContents(_ context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { + s.readContentsReq = req + if s.readContentsRes.OwnerUserID == 0 { + s.readContentsRes.OwnerUserID = userID + } + return s.readContentsRes, nil +} + +func (s *captureMessages) GetOutboxReadDate(_ context.Context, _ int64, req domain.OutboxReadDateRequest) (int, error) { + s.outboxReadDateReq = req + if s.outboxReadDate == 0 { + return 0, domain.ErrMessageNotReadYet + } + return s.outboxReadDate, nil +} + +func (s *captureMessages) SetMessageReactions(_ context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + s.setReactionReq = req + if len(s.setReactionRes.Messages) == 0 { + if len(req.Reactions) == 0 { + reactions := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} + s.setReactionRes = domain.PrivateMessageReactionsResult{ + Messages: []domain.Message{{ + ID: req.MessageID, + OwnerUserID: userID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, + Date: req.Date, + Reactions: &reactions, + }}, + Reactions: reactions, + } + return s.setReactionRes, nil + } + reactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: req.Reactions[0], + Count: 1, + ChosenOrder: 1, + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + UserID: userID, + Reaction: req.Reactions[0], + My: true, + Big: req.Big, + ChosenOrder: 1, + Date: req.Date, + }}, + } + s.setReactionRes = domain.PrivateMessageReactionsResult{ + Messages: []domain.Message{{ + ID: req.MessageID, + OwnerUserID: userID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, + Date: req.Date, + Reactions: &reactions, + }}, + Reactions: reactions, + } + } + return s.setReactionRes, nil +} + +func (s *captureMessages) GetMessageReactions(_ context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + s.getReactionReq = req + if len(s.getReactionRes.Messages) == 0 && len(req.IDs) > 0 { + reactions := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} + s.getReactionRes = domain.PrivateMessageReactionsResult{ + Messages: []domain.Message{{ + ID: req.IDs[0], + OwnerUserID: userID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, + Reactions: &reactions, + }}, + Reactions: reactions, + } + } + return s.getReactionRes, nil +} + +func (s *captureMessages) EditMessage(_ context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) { + s.editReq = req + if s.editRes.OwnerUserID == 0 { + s.editRes.OwnerUserID = userID + } + if len(s.editRes.Edited) == 0 { + msg := domain.Message{ + ID: req.ID, + OwnerUserID: req.OwnerUserID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + Date: req.EditDate - 10, + EditDate: req.EditDate, + Out: true, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Pts: 7, + } + s.editRes.Edited = []domain.EditedMessageForUser{{ + UserID: req.OwnerUserID, + Message: msg, + Event: domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventEditMessage, + Pts: 7, + PtsCount: 1, + Date: req.EditDate, + Message: msg, + }, + }} + } + return s.editRes, nil +} + +func (s *captureMessages) DeleteMessages(_ context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { + s.deleteMessagesReq = req + if s.deleteMessagesRes.OwnerUserID == 0 { + s.deleteMessagesRes.OwnerUserID = userID + } + return s.deleteMessagesRes, nil +} + +func (s *captureMessages) DeleteHistory(_ context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + s.deleteHistoryReq = req + if s.deleteHistoryRes.OwnerUserID == 0 { + s.deleteHistoryRes.OwnerUserID = userID + } + return s.deleteHistoryRes, nil +} + +type captureDialogs struct { + list domain.DialogList + peerList domain.DialogList + folderList domain.DialogFolderList + filter domain.DialogFilter + peers []domain.Peer + savedFolder domain.DialogFolder + deletedFolderID int + folderOrder []int + tagsEnabled bool + folderPeers []domain.FolderPeerUpdate + drafts []domain.DialogDraft + savedDraft domain.DialogDraft + deletedDraft struct { + peer domain.Peer + topMessageID int + } +} + +type captureRPCMetrics struct { + messageSend int + messageDup bool + messageSendErr error + rateLimited int +} + +func (m *captureRPCMetrics) MessageSend(_ time.Duration, duplicate bool, err error) { + m.messageSend++ + m.messageDup = duplicate + m.messageSendErr = err +} + +func (m *captureRPCMetrics) MessageRateLimited(retryAfterSeconds int) { + m.rateLimited = retryAfterSeconds +} + +func (m *captureRPCMetrics) OutboxClaimed(int) {} + +func (m *captureRPCMetrics) OutboxDelivered(time.Duration) {} + +func (m *captureRPCMetrics) OutboxFailed(error) {} + +func (s *captureDialogs) GetDialogs(_ context.Context, _ int64, filter domain.DialogFilter) (domain.DialogList, error) { + s.filter = filter + return s.list, nil +} + +func (s *captureDialogs) GetPeerDialogs(_ context.Context, _ int64, peers []domain.Peer) (domain.DialogList, error) { + s.peers = append([]domain.Peer(nil), peers...) + return s.peerList, nil +} + +func (s *captureDialogs) SaveDraft(_ context.Context, _ int64, draft domain.DialogDraft) error { + s.savedDraft = draft + return nil +} + +func (s *captureDialogs) DeleteDraft(_ context.Context, _ int64, peer domain.Peer, topMessageID int) (bool, error) { + s.deletedDraft.peer = peer + s.deletedDraft.topMessageID = topMessageID + return true, nil +} + +func (s *captureDialogs) ListDrafts(_ context.Context, _ int64, _ int) ([]domain.DialogDraft, error) { + return append([]domain.DialogDraft(nil), s.drafts...), nil +} + +func (s *captureDialogs) ClearDrafts(_ context.Context, _ int64, _ int) ([]domain.DialogDraft, error) { + drafts := append([]domain.DialogDraft(nil), s.drafts...) + s.drafts = nil + return drafts, nil +} + +func (s *captureDialogs) TogglePinned(_ context.Context, _ int64, peer domain.Peer, _ bool) (bool, error) { + s.peers = []domain.Peer{peer} + return true, nil +} + +func (s *captureDialogs) ReorderPinned(_ context.Context, _ int64, order []domain.Peer, _ bool) error { + s.peers = append([]domain.Peer(nil), order...) + return nil +} + +func (s *captureDialogs) MarkUnread(_ context.Context, _ int64, peer domain.Peer, _ bool) (bool, error) { + s.peers = []domain.Peer{peer} + return true, nil +} + +func (s *captureDialogs) UnreadMarks(_ context.Context, _ int64) ([]domain.Peer, error) { + return s.peers, nil +} + +func (s *captureDialogs) HidePeerSettingsBar(_ context.Context, _ int64, peer domain.Peer) (bool, error) { + s.peers = []domain.Peer{peer} + return true, nil +} + +func (s *captureDialogs) PeerSettingsBarHidden(_ context.Context, _ int64, _ domain.Peer) (bool, error) { + return false, nil +} + +func (s *captureDialogs) GetDialogFolders(_ context.Context, _ int64) (domain.DialogFolderList, error) { + return s.folderList, nil +} + +func (s *captureDialogs) SaveDialogFolder(_ context.Context, _ int64, folder domain.DialogFolder) error { + s.savedFolder = folder + return nil +} + +func (s *captureDialogs) DeleteDialogFolder(_ context.Context, _ int64, folderID int) error { + s.deletedFolderID = folderID + return nil +} + +func (s *captureDialogs) ReorderDialogFolders(_ context.Context, _ int64, order []int) error { + s.folderOrder = append([]int(nil), order...) + return nil +} + +func (s *captureDialogs) ToggleDialogFolderTags(_ context.Context, _ int64, enabled bool) error { + s.tagsEnabled = enabled + return nil +} + +func (s *captureDialogs) EditPeerFolders(_ context.Context, _ int64, peers []domain.FolderPeerUpdate) error { + s.folderPeers = append([]domain.FolderPeerUpdate(nil), peers...) + return nil +} diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go new file mode 100644 index 00000000..9457a5c6 --- /dev/null +++ b/internal/rpc/send_media.go @@ -0,0 +1,539 @@ +package rpc + +import ( + "context" + "errors" + "strconv" + "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 本文件实现 messages.uploadMedia / sendMedia / sendMultiMedia 的 photo/document/sticker 主路径, +// 并抽取 sendOutgoing 作为「已校验的一条出站消息(文本或媒体)落地」的共享实现,私聊与频道共用。 + +// outgoingSend 是 sendOutgoing 的入参:一条已校验的出站消息。 +type outgoingSend struct { + randomID int64 + message string + entities []tg.MessageEntityClass + media *domain.MessageMedia + silent bool + noforwards bool + replyToInput tg.InputReplyToClass + sendAsInput tg.InputPeerClass + clearDraft bool +} + +// sendOutgoing 把一条出站消息落地到私聊或频道,返回 *tg.Updates、是否重复、错误。 +// media 为空即纯文本。校验(长度/random_id/限流)由调用方完成。 +func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Peer, p outgoingSend) (tg.UpdatesClass, bool, error) { + sendAs, err := r.resolveSendAsPeer(ctx, userID, peer, p.sendAsInput) + if err != nil { + return nil, false, err + } + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return nil, false, peerIDInvalidErr() + } + replyTo, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput) + if err != nil { + return nil, false, err + } + mentionUserIDs, err := r.mentionedUserIDsFromMessage(ctx, userID, p.message, p.entities) + if err != nil { + return nil, false, err + } + res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{ + UserID: userID, + ChannelID: peer.ID, + RandomID: p.randomID, + Message: p.message, + Entities: domainMessageEntities(p.entities), + Media: p.media, + MentionUserIDs: mentionUserIDs, + Silent: p.silent, + NoForwards: p.noforwards, + ReplyTo: replyTo, + SendAs: sendAs, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, false, channelInvalidErr(err) + } + updates := r.channelMessageUpdates(ctx, userID, res, p.randomID) + if !res.Duplicate { + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelMessageUpdates(ctx, viewerUserID, res, 0) + }) + r.pushChannelDiscussionUpdate(ctx, userID, res.Discussion) + } + if p.clearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } + return updates, res.Duplicate, nil + } + if peer.Type != domain.PeerTypeUser { + return nil, false, peerIDInvalidErr() + } + if r.deps.Messages == nil { + return nil, false, peerIDInvalidErr() + } + if r.deps.Users != nil && peer.ID != userID { + if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil { + return nil, false, internalErr() + } else if !found { + return nil, false, peerIDInvalidErr() + } + } + replyTo, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput) + if err != nil { + return nil, false, err + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: peer.ID, + RandomID: p.randomID, + Message: p.message, + Entities: domainMessageEntities(p.entities), + Media: p.media, + Silent: p.silent, + NoForwards: p.noforwards, + ReplyTo: replyTo, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, false, messageSendErr(err) + } + users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage) + chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage) + if p.clearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } + return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, p.randomID, true, users, chats), res.Duplicate, nil +} + +// onMessagesUploadMedia 解析 InputMedia(上传或引用),返回可复用的 tg.MessageMedia。 +func (r *Router) onMessagesUploadMedia(ctx context.Context, req *tg.MessagesUploadMediaRequest) (tg.MessageMediaClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, mediaInvalidErr() + } + if len(req.BusinessConnectionID) > maxBusinessConnIDLength { + return nil, limitInvalidErr() + } + if _, ok := req.Peer.(*tg.InputPeerEmpty); !ok { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + } + if _, ok := req.Media.(*tg.InputMediaEmpty); ok { + return &tg.MessageMediaEmpty{}, nil + } + media, err := r.resolveInputMedia(ctx, userID, req.Media) + if err != nil { + return nil, err + } + if media == nil { + return nil, mediaInvalidErr() + } + return tgMessageMedia(media), nil +} + +// onMessagesSendMedia 发送一条带媒体的消息(photo/document/sticker),私聊与频道均支持。 +func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMediaRequest) (tg.UpdatesClass, error) { + if req.RandomID == 0 { + return nil, randomIDEmptyErr() + } + if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { + return nil, mediaCaptionTooLongErr() + } + if len(req.Entities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + if req.ScheduleDate != 0 || req.ScheduleRepeatPeriod != 0 { + return nil, scheduleDateInvalidErr() + } + if req.Media == nil { + return nil, mediaInvalidErr() + } + // InputMediaEmpty / WebPage:退化为纯文本发送(复用 sendMessage 校验与流程)。 + switch req.Media.(type) { + case *tg.InputMediaEmpty, *tg.InputMediaWebPage: + return r.onMessagesSendMessage(ctx, sendMessageRequestFromSendMedia(req)) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + if r.deps.Limiter != nil { + allowed, retryAfter, err := r.deps.Limiter.Allow(ctx, "messages:send:"+strconv.FormatInt(userID, 10), sendMessageRateLimit, sendMessageRateWindow) + if err != nil { + return nil, internalErr() + } + if !allowed { + r.metrics().MessageRateLimited(retryAfter) + return nil, floodWaitErr(retryAfter) + } + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + media, err := r.resolveInputMedia(ctx, userID, req.Media) + if err != nil { + return nil, err + } + if media == nil { + return nil, mediaInvalidErr() + } + updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ + randomID: req.RandomID, + message: req.Message, + entities: req.Entities, + media: media, + silent: req.Silent, + noforwards: req.Noforwards, + replyToInput: req.ReplyTo, + sendAsInput: req.SendAs, + clearDraft: req.ClearDraft, + }) + if err != nil { + return nil, err + } + return updates, nil +} + +// onMessagesSendMultiMedia 发送相册(多条媒体)。本阶段不绑定 grouped_id(各条作为独立消息呈现)。 +func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesSendMultiMediaRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + if len(req.MultiMedia) == 0 || len(req.MultiMedia) > maxSendMultiMediaItems { + return nil, limitInvalidErr() + } + if req.ScheduleDate != 0 { + return nil, scheduleDateInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + for _, item := range req.MultiMedia { + if item.RandomID == 0 { + return nil, randomIDEmptyErr() + } + if utf8.RuneCountInString(item.Message) > maxSendMessageTextLength { + return nil, mediaCaptionTooLongErr() + } + if len(item.Entities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + if item.Media == nil { + return nil, mediaInvalidErr() + } + } + + combined := make([]tg.UpdateClass, 0, len(req.MultiMedia)*2) + usersByID := map[int64]tg.UserClass{} + chatsByID := map[int64]tg.ChatClass{} + date := 0 + for _, item := range req.MultiMedia { + media, err := r.resolveInputMedia(ctx, userID, item.Media) + if err != nil { + return nil, err + } + if media == nil { + return nil, mediaInvalidErr() + } + result, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ + randomID: item.RandomID, + message: item.Message, + entities: item.Entities, + media: media, + silent: req.Silent, + noforwards: req.Noforwards, + replyToInput: req.ReplyTo, + sendAsInput: req.SendAs, + }) + if err != nil { + return nil, err + } + if upd, ok := result.(*tg.Updates); ok { + combined = append(combined, upd.Updates...) + for _, u := range upd.Users { + if id := userClassID(u); id != 0 { + usersByID[id] = u + } + } + for _, c := range upd.Chats { + if id := chatClassID(c); id != 0 { + chatsByID[id] = c + } + } + if upd.Date != 0 { + date = upd.Date + } + } + } + return &tg.Updates{ + Updates: combined, + Users: mapValuesUsers(usersByID), + Chats: mapValuesChats(chatsByID), + Date: date, + }, nil +} + +// resolveInputMedia 把 tg.InputMedia 解析为 domain.MessageMedia(上传则落库,引用则加载)。 +// 返回 nil 表示 InputMediaEmpty(调用方退化为纯文本)。 +func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) { + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } + switch in := input.(type) { + case *tg.InputMediaEmpty: + return nil, nil + case *tg.InputMediaUploadedPhoto: + if in.File == nil { + return nil, mediaInvalidErr() + } + ref, ok := uploadedFileRef(userID, in.File) + if !ok { + return nil, fileReferenceInvalidErr() + } + photo, err := r.deps.Files.CreatePhotoFromUpload(ctx, ref) + if err != nil { + return nil, mediaUploadErr(err) + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil + case *tg.InputMediaUploadedDocument: + if in.File == nil { + return nil, mediaInvalidErr() + } + ref, ok := uploadedFileRef(userID, in.File) + if !ok { + return nil, fileReferenceInvalidErr() + } + spec := domain.DocumentSpec{ + MimeType: in.MimeType, + Attributes: domainDocumentAttributes(in.Attributes), + ForceFile: in.ForceFile, + } + if thumb, ok := in.GetThumb(); ok { + if tref, ok := uploadedFileRef(userID, thumb); ok { + spec.Thumb = &tref + } + } + doc, err := r.deps.Files.CreateDocumentFromUpload(ctx, ref, spec) + if err != nil { + return nil, mediaUploadErr(err) + } + return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil + case *tg.InputMediaPhoto: + photoID, ok := inputPhotoID(in.ID) + if !ok { + return nil, photoInvalidErr() + } + photo, found, err := r.deps.Files.GetPhoto(ctx, photoID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, photoInvalidErr() + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil + case *tg.InputMediaDocument: + docIDs, ok := inputDocumentCandidateIDs(in.ID) + if !ok { + return nil, mediaInvalidErr() + } + var doc domain.Document + found := false + for _, docID := range docIDs { + var err error + doc, found, err = r.deps.Files.GetDocument(ctx, docID) + if err != nil { + return nil, internalErr() + } + if found { + break + } + } + if !found { + return nil, mediaInvalidErr() + } + return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil + default: + // geo / contact / poll / venue / dice / story / 等本阶段不支持。 + return nil, mediaInvalidErr() + } +} + +// messageMediaFromDocument 由 Document 构造 MessageMedia,并从属性推导 Video/Round/Voice 标志。 +func messageMediaFromDocument(doc domain.Document, spoiler bool, ttl int) *domain.MessageMedia { + media := &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &doc, Spoiler: spoiler, TTLSeconds: ttl} + for _, attr := range doc.Attributes { + switch attr.Kind { + case domain.DocAttrVideo: + media.Video = true + if attr.RoundMessage { + media.Round = true + } + case domain.DocAttrAudio: + if attr.Voice { + media.Voice = true + } + } + } + return media +} + +func inputPhotoID(input tg.InputPhotoClass) (int64, bool) { + if p, ok := input.(*tg.InputPhoto); ok && p.ID != 0 { + return p.ID, true + } + return 0, false +} + +func inputDocumentID(input tg.InputDocumentClass) (int64, bool) { + if d, ok := input.(*tg.InputDocument); ok && d.ID != 0 { + return d.ID, true + } + return 0, false +} + +func inputDocumentCandidateIDs(input tg.InputDocumentClass) ([]int64, bool) { + if d, ok := input.(*tg.InputDocument); ok && d.ID != 0 { + return []int64{d.ID}, true + } + return nil, false +} + +// domainDocumentAttributes 把 tg.DocumentAttribute 反向转为 domain(InputMediaUploadedDocument 用)。 +func domainDocumentAttributes(attrs []tg.DocumentAttributeClass) []domain.DocumentAttribute { + out := make([]domain.DocumentAttribute, 0, len(attrs)) + for _, a := range attrs { + switch v := a.(type) { + case *tg.DocumentAttributeImageSize: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: v.W, H: v.H}) + case *tg.DocumentAttributeAnimated: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}) + case *tg.DocumentAttributeSticker: + attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: v.Alt, Mask: v.Mask} + if id, hash, ok := inputStickerSetIDs(v.Stickerset); ok { + attr.StickerSetID = id + attr.StickerSetAccessHash = hash + } + out = append(out, attr) + case *tg.DocumentAttributeVideo: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming}) + case *tg.DocumentAttributeAudio: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: v.Duration, Voice: v.Voice, Title: v.Title, Performer: v.Performer, Waveform: v.Waveform}) + case *tg.DocumentAttributeFilename: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: v.FileName}) + case *tg.DocumentAttributeCustomEmoji: + attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: v.Alt, Free: v.Free, TextColor: v.TextColor} + if id, hash, ok := inputStickerSetIDs(v.Stickerset); ok { + attr.StickerSetID = id + attr.StickerSetAccessHash = hash + } + out = append(out, attr) + } + } + return out +} + +func inputStickerSetIDs(input tg.InputStickerSetClass) (int64, int64, bool) { + if s, ok := input.(*tg.InputStickerSetID); ok { + return s.ID, s.AccessHash, true + } + return 0, 0, false +} + +// sendMessageRequestFromSendMedia 把 sendMedia(空媒体)的字段映射到 sendMessage 请求。 +func sendMessageRequestFromSendMedia(req *tg.MessagesSendMediaRequest) *tg.MessagesSendMessageRequest { + return &tg.MessagesSendMessageRequest{ + Silent: req.Silent, + Background: req.Background, + ClearDraft: req.ClearDraft, + Noforwards: req.Noforwards, + UpdateStickersetsOrder: req.UpdateStickersetsOrder, + InvertMedia: req.InvertMedia, + AllowPaidFloodskip: req.AllowPaidFloodskip, + Peer: req.Peer, + ReplyTo: req.ReplyTo, + Message: req.Message, + RandomID: req.RandomID, + ReplyMarkup: req.ReplyMarkup, + Entities: req.Entities, + ScheduleDate: req.ScheduleDate, + ScheduleRepeatPeriod: req.ScheduleRepeatPeriod, + SendAs: req.SendAs, + QuickReplyShortcut: req.QuickReplyShortcut, + Effect: req.Effect, + AllowPaidStars: req.AllowPaidStars, + SuggestedPost: req.SuggestedPost, + } +} + +func mediaUploadErr(err error) error { + switch { + case errors.Is(err, domain.ErrFilePartsInvalid): + return filePartsInvalidErr() + case errors.Is(err, domain.ErrPhotoInvalid): + return photoInvalidErr() + case errors.Is(err, domain.ErrDocumentInvalid): + return mediaInvalidErr() + default: + return internalErr() + } +} + +func userClassID(u tg.UserClass) int64 { + if v, ok := u.(*tg.User); ok { + return v.ID + } + return 0 +} + +func chatClassID(c tg.ChatClass) int64 { + switch v := c.(type) { + case *tg.Channel: + return v.ID + case *tg.Chat: + return v.ID + } + return 0 +} + +func mapValuesUsers(m map[int64]tg.UserClass) []tg.UserClass { + out := make([]tg.UserClass, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + return out +} + +func mapValuesChats(m map[int64]tg.ChatClass) []tg.ChatClass { + out := make([]tg.ChatClass, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + return out +} diff --git a/internal/rpc/send_media_test.go b/internal/rpc/send_media_test.go new file mode 100644 index 00000000..7fda7e8c --- /dev/null +++ b/internal/rpc/send_media_test.go @@ -0,0 +1,238 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// fakeFiles 是 FilesService 的最小测试替身:贴纸文档可解析,上传图片返回固定 Photo。 +type fakeFiles struct { + docs map[int64]domain.Document + photos map[int64]domain.Photo + reactions []domain.AvailableReaction + sets map[domain.StickerSetKind][]domain.StickerSet +} + +func (f *fakeFiles) SaveFilePart(context.Context, int64, int64, int, []byte) (bool, error) { + return true, nil +} +func (f *fakeFiles) SaveBigFilePart(context.Context, int64, int64, int, int, []byte) (bool, error) { + return true, nil +} +func (f *fakeFiles) GetFile(context.Context, domain.FileDownloadRequest) (domain.FileChunk, bool, error) { + return domain.FileChunk{}, false, nil +} +func (f *fakeFiles) ListAvailableReactions(context.Context) ([]domain.AvailableReaction, error) { + return append([]domain.AvailableReaction(nil), f.reactions...), nil +} +func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) { + out := make([]domain.Document, 0, len(ids)) + for _, id := range ids { + if d, ok := f.docs[id]; ok { + out = append(out, d) + } + } + return out, nil +} +func (f *fakeFiles) ResolveStickerSet(context.Context, domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + return domain.StickerSet{}, nil, false, nil +} +func (f *fakeFiles) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + sets := f.sets[kind] + return append([]domain.StickerSet(nil), sets...), nil +} +func (f *fakeFiles) CreatePhotoFromUpload(_ context.Context, _ domain.UploadedFileRef) (domain.Photo, error) { + return domain.Photo{ID: 777, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600}}}, nil +} +func (f *fakeFiles) CreateAvatarFromUpload(_ context.Context, _ domain.UploadedFileRef) (domain.Photo, error) { + return domain.Photo{ID: 778, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "a", W: 160, H: 160}, {Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640}}}, nil +} +func (f *fakeFiles) CreateDocumentFromUpload(_ context.Context, _ domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) { + return domain.Document{ID: 888, AccessHash: 8, DCID: 2, MimeType: spec.MimeType, Attributes: spec.Attributes}, nil +} +func (f *fakeFiles) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) { + p, ok := f.photos[id] + return p, ok, nil +} +func (f *fakeFiles) GetDocument(_ context.Context, id int64) (domain.Document, bool, error) { + d, ok := f.docs[id] + return d, ok, nil +} +func (f *fakeFiles) UploadProfilePhoto(context.Context, domain.PeerType, int64, domain.UploadedFileRef, int) (domain.Photo, error) { + return domain.Photo{}, nil +} +func (f *fakeFiles) SetCurrentProfilePhoto(context.Context, domain.PeerType, int64, int64, int) (domain.Photo, bool, error) { + return domain.Photo{}, false, nil +} +func (f *fakeFiles) CurrentProfilePhoto(context.Context, domain.PeerType, int64) (domain.Photo, bool, error) { + return domain.Photo{}, false, nil +} +func (f *fakeFiles) GetProfilePhotos(context.Context, domain.PeerType, int64, int, int, int64) ([]domain.Photo, int, error) { + return nil, 0, nil +} +func (f *fakeFiles) DeleteProfilePhotos(context.Context, domain.PeerType, int64, []int64) (int, error) { + return 0, nil +} + +func newMediaTestRouter(t *testing.T) (*Router, domain.User, domain.User) { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550009002", FirstName: "Friend"}) + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 555: { + ID: 555, + AccessHash: 5, + DCID: 2, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: 99, StickerSetAccessHash: 7}}, + }, + }, + photos: map[int64]domain.Photo{}, + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Messages: appmessages.NewService(messageStore, dialogStore), + Files: files, + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + return r, owner, friend +} + +func newMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message { + t.Helper() + upd, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("expected *tg.Updates, got %T", updates) + } + for _, u := range upd.Updates { + if nm, ok := u.(*tg.UpdateNewMessage); ok { + msg, ok := nm.Message.(*tg.Message) + if !ok { + t.Fatalf("expected *tg.Message, got %T", nm.Message) + } + return msg + } + } + t.Fatal("no UpdateNewMessage found") + return nil +} + +func TestSendMediaPrivateSticker(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}}, + RandomID: 1001, + }) + if err != nil { + t.Fatalf("sendMedia sticker: %v", err) + } + msg := newMessageFromUpdates(t, updates) + media, ok := msg.Media.(*tg.MessageMediaDocument) + if !ok { + t.Fatalf("expected MessageMediaDocument, got %T", msg.Media) + } + doc, ok := media.Document.(*tg.Document) + if !ok { + t.Fatalf("expected tg.Document, got %T", media.Document) + } + if want := int64(555); doc.ID != want { + t.Errorf("document id = %d, want %d", doc.ID, want) + } + if doc.DCID != 2 { + t.Errorf("document dc_id = %d, want 2", doc.DCID) + } + hasSticker := false + for _, a := range doc.Attributes { + if _, ok := a.(*tg.DocumentAttributeSticker); ok { + hasSticker = true + } + } + if !hasSticker { + t.Error("document missing sticker attribute") + } +} + +func TestSendMediaPrivateUploadedPhoto(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 42, Parts: 1, Name: "p.jpg"}}, + Message: "caption", + RandomID: 1002, + }) + if err != nil { + t.Fatalf("sendMedia photo: %v", err) + } + msg := newMessageFromUpdates(t, updates) + if msg.Message != "caption" { + t.Errorf("caption = %q, want %q", msg.Message, "caption") + } + media, ok := msg.Media.(*tg.MessageMediaPhoto) + if !ok { + t.Fatalf("expected MessageMediaPhoto, got %T", msg.Media) + } + photo, ok := media.Photo.(*tg.Photo) + if !ok { + t.Fatalf("expected tg.Photo, got %T", media.Photo) + } + if photo.ID != 777 { + t.Errorf("photo id = %d, want 777", photo.ID) + } +} + +func TestUploadMediaReturnsReusableMedia(t *testing.T) { + ctx := context.Background() + r, owner, _ := newMediaTestRouter(t) + + media, err := r.onMessagesUploadMedia(WithUserID(ctx, owner.ID), &tg.MessagesUploadMediaRequest{ + Peer: &tg.InputPeerEmpty{}, + Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}}, + }) + if err != nil { + t.Fatalf("uploadMedia: %v", err) + } + if _, ok := media.(*tg.MessageMediaDocument); !ok { + t.Fatalf("expected MessageMediaDocument, got %T", media) + } +} + +func TestStickerSetDoesNotExposeUnserviceableDownloadThumb(t *testing.T) { + set := tgStickerSet(domain.StickerSet{ + ID: 99, + AccessHash: 7, + Title: "Set", + ShortName: "set", + ThumbDCID: 2, + ThumbVersion: 123, + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1, 2, 3}}, + {Kind: domain.PhotoSizeKindDefault, Type: "a", W: 100, H: 100, Size: 4096}, + }, + }) + thumbs, ok := set.GetThumbs() + if !ok || len(thumbs) != 1 { + t.Fatalf("thumbs = %#v, want only non-downloadable path thumb", thumbs) + } + if _, ok := thumbs[0].(*tg.PhotoPathSize); !ok { + t.Fatalf("thumb[0] = %T, want PhotoPathSize", thumbs[0]) + } +} diff --git a/internal/rpc/stats.go b/internal/rpc/stats.go new file mode 100644 index 00000000..ff4307b0 --- /dev/null +++ b/internal/rpc/stats.go @@ -0,0 +1,211 @@ +package rpc + +import ( + "context" + "fmt" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +const ( + maxStatsPublicForwardsLimit = 100 + maxStatsOffsetLength = 128 + maxStatsGraphTokenLength = 128 +) + +func (r *Router) registerStats(d *tg.ServerDispatcher) { + d.OnStatsGetBroadcastStats(r.onStatsGetBroadcastStats) + d.OnStatsGetMegagroupStats(r.onStatsGetMegagroupStats) + d.OnStatsGetMessageStats(r.onStatsGetMessageStats) + d.OnStatsGetMessagePublicForwards(r.onStatsGetMessagePublicForwards) + d.OnStatsLoadAsyncGraph(r.onStatsLoadAsyncGraph) + d.OnStatsGetStoryStats(r.onStatsGetStoryStats) + d.OnStatsGetStoryPublicForwards(r.onStatsGetStoryPublicForwards) + d.OnStatsGetPollStats(r.onStatsGetPollStats) +} + +func (r *Router) onStatsGetBroadcastStats(ctx context.Context, req *tg.StatsGetBroadcastStatsRequest) (*tg.StatsBroadcastStats, error) { + view, err := r.statsChannelView(ctx, req.Channel) + if err != nil { + return nil, err + } + if !view.Channel.Broadcast { + return nil, tgerr400("BROADCAST_REQUIRED") + } + return r.emptyBroadcastStats(), nil +} + +func (r *Router) onStatsGetMegagroupStats(ctx context.Context, req *tg.StatsGetMegagroupStatsRequest) (*tg.StatsMegagroupStats, error) { + view, err := r.statsChannelView(ctx, req.Channel) + if err != nil { + return nil, err + } + if !view.Channel.Megagroup { + return nil, tgerr400("MEGAGROUP_REQUIRED") + } + return r.emptyMegagroupStats(), nil +} + +func (r *Router) onStatsGetMessageStats(ctx context.Context, req *tg.StatsGetMessageStatsRequest) (*tg.StatsMessageStats, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if _, err := r.statsChannelView(ctx, req.Channel); err != nil { + return nil, err + } + return &tg.StatsMessageStats{ + ViewsGraph: r.emptyStatsGraph("Views"), + ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"), + }, nil +} + +func (r *Router) onStatsGetMessagePublicForwards(ctx context.Context, req *tg.StatsGetMessagePublicForwardsRequest) (*tg.StatsPublicForwards, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit || len(req.Offset) > maxStatsOffsetLength { + return nil, limitInvalidErr() + } + if _, err := r.statsChannelView(ctx, req.Channel); err != nil { + return nil, err + } + return emptyStatsPublicForwards(), nil +} + +func (r *Router) onStatsLoadAsyncGraph(_ context.Context, req *tg.StatsLoadAsyncGraphRequest) (tg.StatsGraphClass, error) { + if len(req.Token) > maxStatsGraphTokenLength { + return &tg.StatsGraphError{Error: "GRAPH_INVALID_RELOAD"}, nil + } + return &tg.StatsGraphError{Error: "GRAPH_INVALID_RELOAD"}, nil +} + +func (r *Router) onStatsGetStoryStats(ctx context.Context, req *tg.StatsGetStoryStatsRequest) (*tg.StatsStoryStats, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := r.validateStatsPeer(ctx, req.Peer); err != nil { + return nil, err + } + return &tg.StatsStoryStats{ + ViewsGraph: r.emptyStatsGraph("Views"), + ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"), + }, nil +} + +func (r *Router) onStatsGetStoryPublicForwards(ctx context.Context, req *tg.StatsGetStoryPublicForwardsRequest) (*tg.StatsPublicForwards, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit || len(req.Offset) > maxStatsOffsetLength { + return nil, limitInvalidErr() + } + if err := r.validateStatsPeer(ctx, req.Peer); err != nil { + return nil, err + } + return emptyStatsPublicForwards(), nil +} + +func (r *Router) onStatsGetPollStats(ctx context.Context, req *tg.StatsGetPollStatsRequest) (*tg.StatsPollStats, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := r.validateStatsPeer(ctx, req.Peer); err != nil { + return nil, err + } + return &tg.StatsPollStats{VotesGraph: r.emptyStatsGraph("Votes")}, nil +} + +func (r *Router) statsChannelView(ctx context.Context, input tg.InputChannelClass) (domain.ChannelView, error) { + _, view, err := r.channelView(ctx, input) + if err != nil { + return domain.ChannelView{}, err + } + if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin { + return domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED") + } + return view, nil +} + +func (r *Router) validateStatsPeer(ctx context.Context, peer tg.InputPeerClass) error { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return internalErr() + } + _, err = r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + return err +} + +func (r *Router) emptyBroadcastStats() *tg.StatsBroadcastStats { + return &tg.StatsBroadcastStats{ + Period: r.statsDateRange(), + Followers: tg.StatsAbsValueAndPrev{}, + ViewsPerPost: tg.StatsAbsValueAndPrev{}, + SharesPerPost: tg.StatsAbsValueAndPrev{}, + ReactionsPerPost: tg.StatsAbsValueAndPrev{}, + ViewsPerStory: tg.StatsAbsValueAndPrev{}, + SharesPerStory: tg.StatsAbsValueAndPrev{}, + ReactionsPerStory: tg.StatsAbsValueAndPrev{}, + EnabledNotifications: tg.StatsPercentValue{}, + GrowthGraph: r.emptyStatsGraph("Growth"), + FollowersGraph: r.emptyStatsGraph("Followers"), + MuteGraph: r.emptyStatsGraph("Muted"), + TopHoursGraph: r.emptyStatsGraph("Hours"), + InteractionsGraph: r.emptyStatsGraph("Interactions"), + IvInteractionsGraph: r.emptyStatsGraph("Instant Views"), + ViewsBySourceGraph: r.emptyStatsGraph("Views"), + NewFollowersBySourceGraph: r.emptyStatsGraph("Followers"), + LanguagesGraph: r.emptyStatsGraph("Languages"), + ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"), + StoryInteractionsGraph: r.emptyStatsGraph("Stories"), + StoryReactionsByEmotionGraph: r.emptyStatsGraph("Story Reactions"), + } +} + +func (r *Router) emptyMegagroupStats() *tg.StatsMegagroupStats { + return &tg.StatsMegagroupStats{ + Period: r.statsDateRange(), + Members: tg.StatsAbsValueAndPrev{}, + Messages: tg.StatsAbsValueAndPrev{}, + Viewers: tg.StatsAbsValueAndPrev{}, + Posters: tg.StatsAbsValueAndPrev{}, + GrowthGraph: r.emptyStatsGraph("Growth"), + MembersGraph: r.emptyStatsGraph("Members"), + NewMembersBySourceGraph: r.emptyStatsGraph("Members"), + LanguagesGraph: r.emptyStatsGraph("Languages"), + MessagesGraph: r.emptyStatsGraph("Messages"), + ActionsGraph: r.emptyStatsGraph("Actions"), + TopHoursGraph: r.emptyStatsGraph("Hours"), + WeekdaysGraph: r.emptyStatsGraph("Weekdays"), + TopPosters: []tg.StatsGroupTopPoster{}, + TopAdmins: []tg.StatsGroupTopAdmin{}, + TopInviters: []tg.StatsGroupTopInviter{}, + } +} + +func (r *Router) statsDateRange() tg.StatsDateRangeDays { + now := int(r.clock.Now().Unix()) + return tg.StatsDateRangeDays{MinDate: now - 86400, MaxDate: now} +} + +func (r *Router) emptyStatsGraph(label string) *tg.StatsGraph { + nowMillis := r.clock.Now().UnixMilli() + prevMillis := nowMillis - 86400000 + data := fmt.Sprintf( + `{"columns":[["x",%d,%d],["y0",0,0]],"types":{"x":"x","y0":"line"},"names":{"y0":%q},"colors":{"y0":"blue#4a90e2"}}`, + prevMillis, + nowMillis, + label, + ) + return &tg.StatsGraph{JSON: tg.DataJSON{Data: data}} +} + +func emptyStatsPublicForwards() *tg.StatsPublicForwards { + return &tg.StatsPublicForwards{ + Count: 0, + Forwards: []tg.PublicForwardClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} diff --git a/internal/rpc/stickers.go b/internal/rpc/stickers.go new file mode 100644 index 00000000..3cd39619 --- /dev/null +++ b/internal/rpc/stickers.go @@ -0,0 +1,148 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// 本文件把 reaction / sticker 资源 RPC 接到真实 seed 数据(documents / sticker_sets / +// available_reactions);Files 服务缺失或资源未导入时回退到 tdesktop 兼容 stub。 + +func (r *Router) onMessagesGetAvailableReactions(ctx context.Context, hash int) (tg.MessagesAvailableReactionsClass, error) { + if r.deps.Files == nil { + return tdesktop.AvailableReactions(hash), nil + } + reactions, err := r.deps.Files.ListAvailableReactions(ctx) + if err != nil { + return nil, internalErr() + } + if len(reactions) == 0 { + return tdesktop.AvailableReactions(hash), nil + } + catalogHash := availableReactionsHash(reactions) + if hash == catalogHash { + return &tg.MessagesAvailableReactionsNotModified{}, nil + } + docs, err := r.deps.Files.GetDocuments(ctx, reactionDocumentIDs(reactions)) + if err != nil { + return nil, internalErr() + } + return tgAvailableReactions(reactions, documentsByID(docs), catalogHash), nil +} + +func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGetStickerSetRequest) (tg.MessagesStickerSetClass, error) { + if r.deps.Files == nil { + return tdesktop.StickerSet(req), nil + } + ref, ok := stickerSetRefFromInput(req.Stickerset) + if !ok { + return tdesktop.StickerSet(req), nil + } + set, docs, found, err := r.deps.Files.ResolveStickerSet(ctx, ref) + if err != nil { + return nil, internalErr() + } + if !found { + // 未 seed 的系统集 / 未知短名:回退兼容 stub,避免破坏客户端。 + return tdesktop.StickerSet(req), nil + } + if req.Hash != 0 && req.Hash == set.Hash { + return &tg.MessagesStickerSetNotModified{}, nil + } + return tgMessagesStickerSet(set, docs), nil +} + +func (r *Router) onMessagesGetAllStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) { + return r.allStickersForKind(ctx, hash, domain.StickerSetKindStickers) +} + +func (r *Router) onMessagesGetEmojiStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) { + return r.allStickersForKind(ctx, hash, domain.StickerSetKindEmoji) +} + +func (r *Router) allStickersForKind(ctx context.Context, hash int64, kind domain.StickerSetKind) (tg.MessagesAllStickersClass, error) { + if r.deps.Files == nil { + return messagesAllStickersEmpty(hash), nil + } + sets, err := r.deps.Files.ListStickerSets(ctx, kind) + if err != nil { + return nil, internalErr() + } + if len(sets) == 0 { + return messagesAllStickersEmpty(hash), nil + } + catalogHash := stickerSetsCatalogHash(sets) + if hash == catalogHash { + return &tg.MessagesAllStickersNotModified{}, nil + } + return &tg.MessagesAllStickers{Hash: catalogHash, Sets: tgStickerSets(sets)}, nil +} + +func documentsByID(docs []domain.Document) map[int64]domain.Document { + m := make(map[int64]domain.Document, len(docs)) + for _, d := range docs { + m[d.ID] = d + } + return m +} + +// availableReactionsHash 用 reaction 的核心字段算稳定 hash(供 *NotModified 缓存判定)。 +func availableReactionsHash(reactions []domain.AvailableReaction) int { + values := make([]int64, 0, len(reactions)*10) + for _, r := range reactions { + values = append(values, + int64(len([]rune(r.Reaction))), + boolHashValue(r.Inactive), + boolHashValue(r.Premium), + r.StaticIconID, + r.AppearAnimationID, + r.SelectAnimationID, + r.ActivateAnimationID, + r.EffectAnimationID, + r.AroundAnimationID, + r.CenterIconID, + ) + } + return int(tdesktopCountHash(values) & 0x7fffffff) +} + +func stickerSetsCatalogHash(sets []domain.StickerSet) int64 { + values := make([]int64, 0, len(sets)) + for _, set := range sets { + if set.ID == 0 { + return 0 + } + if set.Archived { + continue + } + values = append(values, int64(set.Hash)) + } + return int64(tdesktopCountHash(values)) +} + +func boolHashValue(v bool) int64 { + if v { + return 1 + } + return 0 +} + +func tdesktopCountHash(values []int64) uint64 { + var hash uint64 + for _, value := range values { + hash = tdesktopHashUpdate(hash, value) + } + return hash +} + +func tdesktopHashUpdate(hash uint64, value int64) uint64 { + hash ^= hash >> 21 + hash ^= hash << 35 + hash ^= hash >> 4 + hash += uint64(value) + return hash +} diff --git a/internal/rpc/stickers_test.go b/internal/rpc/stickers_test.go new file mode 100644 index 00000000..0cf53245 --- /dev/null +++ b/internal/rpc/stickers_test.go @@ -0,0 +1,188 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func TestStickerSetsCatalogHashMatchesTDesktopFormula(t *testing.T) { + sets := []domain.StickerSet{ + {ID: 10, Hash: 123}, + {ID: 11, Hash: 456}, + } + got := stickerSetsCatalogHash(sets) + const want int64 = 4284229878340 + if got != want { + t.Fatalf("stickerSetsCatalogHash() = %d, want %d", got, want) + } + if old := mediaCatalogHash([]int64{10, 123, 11, 456}); old == got { + t.Fatalf("test fixture no longer distinguishes old media hash from TDesktop hash: %d", got) + } +} + +func TestMessagesGetAllStickersUsesTDesktopHashForNotModified(t *testing.T) { + ctx := context.Background() + files := &fakeFiles{ + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindStickers: { + { + ID: 10, + AccessHash: 100, + ShortName: "one", + Title: "One", + Count: 1, + Hash: 123, + Installed: true, + }, + { + ID: 11, + AccessHash: 110, + ShortName: "two", + Title: "Two", + Count: 1, + Hash: 456, + Installed: true, + }, + }, + }, + } + r := &Router{deps: Deps{Files: files}} + + first, err := r.onMessagesGetAllStickers(ctx, 0) + if err != nil { + t.Fatalf("first getAllStickers: %v", err) + } + full, ok := first.(*tg.MessagesAllStickers) + if !ok { + t.Fatalf("first getAllStickers = %T, want *tg.MessagesAllStickers", first) + } + const wantHash int64 = 4284229878340 + if full.Hash != wantHash { + t.Fatalf("first hash = %d, want %d", full.Hash, wantHash) + } + + second, err := r.onMessagesGetAllStickers(ctx, full.Hash) + if err != nil { + t.Fatalf("second getAllStickers: %v", err) + } + if _, ok := second.(*tg.MessagesAllStickersNotModified); !ok { + t.Fatalf("second getAllStickers = %T, want *tg.MessagesAllStickersNotModified", second) + } +} + +func TestMessagesGetAvailableReactionsNotModified(t *testing.T) { + ctx := context.Background() + reactions := []domain.AvailableReaction{ + { + Reaction: "👍", + Title: "Like", + StaticIconID: 101, + AppearAnimationID: 102, + SelectAnimationID: 103, + ActivateAnimationID: 104, + EffectAnimationID: 105, + AroundAnimationID: 106, + CenterIconID: 107, + }, + } + files := &fakeFiles{reactions: reactions} + r := &Router{deps: Deps{Files: files}} + + first, err := r.onMessagesGetAvailableReactions(ctx, 0) + if err != nil { + t.Fatalf("first getAvailableReactions: %v", err) + } + full, ok := first.(*tg.MessagesAvailableReactions) + if !ok { + t.Fatalf("first getAvailableReactions = %T, want *tg.MessagesAvailableReactions", first) + } + if full.Hash == 0 { + t.Fatal("first getAvailableReactions returned zero hash") + } + + second, err := r.onMessagesGetAvailableReactions(ctx, full.Hash) + if err != nil { + t.Fatalf("second getAvailableReactions: %v", err) + } + if _, ok := second.(*tg.MessagesAvailableReactionsNotModified); !ok { + t.Fatalf("second getAvailableReactions = %T, want *tg.MessagesAvailableReactionsNotModified", second) + } +} + +func TestTGDocumentCompactsCachedThumbToDownloadableSize(t *testing.T) { + doc := tgDocument(domain.Document{ + ID: 100, + AccessHash: 1, + DCID: 2, + MimeType: "application/x-tgsticker", + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindCached, Type: "m", W: 128, H: 128, Bytes: []byte("webp")}, + }, + }) + full, ok := doc.(*tg.Document) + if !ok { + t.Fatalf("tgDocument = %T, want *tg.Document", doc) + } + if len(full.Thumbs) != 1 { + t.Fatalf("thumbs = %d, want 1", len(full.Thumbs)) + } + size, ok := full.Thumbs[0].(*tg.PhotoSize) + if !ok { + t.Fatalf("thumb = %T, want *tg.PhotoSize", full.Thumbs[0]) + } + if size.Type != "m" || size.W != 128 || size.H != 128 || size.Size != 4 { + t.Fatalf("thumb size = %+v, want downloadable m 128x128 size=4", size) + } +} + +func TestTGDocumentUsesDomainDocumentID(t *testing.T) { + const documentID int64 = 1382305375846410902 + + doc := tgDocument(domain.Document{ + ID: documentID, + AccessHash: 1, + DCID: 2, + MimeType: "application/x-tgsticker", + }) + full, ok := doc.(*tg.Document) + if !ok { + t.Fatalf("tgDocument = %T, want *tg.Document", doc) + } + if full.ID != documentID { + t.Fatalf("document id = %d, want %d", full.ID, documentID) + } +} + +func TestMessagesGetCustomEmojiDocumentsUsesDomainIDs(t *testing.T) { + const documentID int64 = 1382305375846410902 + ctx := WithUserID(context.Background(), 1780269504) + r := &Router{deps: Deps{Files: &fakeFiles{ + docs: map[int64]domain.Document{ + documentID: { + ID: documentID, + AccessHash: 1, + DCID: 2, + MimeType: "application/x-tgsticker", + }, + }, + }}} + + docs, err := r.onMessagesGetCustomEmojiDocuments(ctx, []int64{documentID}) + if err != nil { + t.Fatalf("getCustomEmojiDocuments: %v", err) + } + if len(docs) != 1 { + t.Fatalf("docs = %d, want 1", len(docs)) + } + doc, ok := docs[0].(*tg.Document) + if !ok { + t.Fatalf("doc = %T, want *tg.Document", docs[0]) + } + if doc.ID != documentID { + t.Fatalf("doc id = %d, want %d", doc.ID, documentID) + } +} diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go new file mode 100644 index 00000000..b59d8001 --- /dev/null +++ b/internal/rpc/stories.go @@ -0,0 +1,49 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// registerStories 注册第一阶段 TDesktop 启动所需 stories.* RPC 兼容响应。 +func (r *Router) registerStories(d *tg.ServerDispatcher) { + d.OnStoriesGetAllStories(func(ctx context.Context, req *tg.StoriesGetAllStoriesRequest) (tg.StoriesAllStoriesClass, error) { + return tdesktop.AllStories(), nil + }) + d.OnStoriesGetStoriesArchive(func(ctx context.Context, req *tg.StoriesGetStoriesArchiveRequest) (*tg.StoriesStories, error) { + return tdesktop.StoriesArchive(), nil + }) + d.OnStoriesGetPinnedStories(func(ctx context.Context, req *tg.StoriesGetPinnedStoriesRequest) (*tg.StoriesStories, error) { + return tdesktop.PinnedStories(), nil + }) + d.OnStoriesGetAlbums(func(ctx context.Context, req *tg.StoriesGetAlbumsRequest) (tg.StoriesAlbumsClass, error) { + return tdesktop.StoryAlbums(), nil + }) + d.OnStoriesSendReaction(r.onStoriesSendReaction) +} + +func (r *Router) onStoriesSendReaction(ctx context.Context, req *tg.StoriesSendReactionRequest) (tg.UpdatesClass, error) { + if req.StoryID <= 0 || req.StoryID > domain.MaxMessageBoxID { + return nil, storyIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if err := validateReactionClass(req.Reaction); err != nil { + return nil, err + } + if parsed, err := domainMessageReactionFromTL(req.Reaction); err == nil { + if err := r.recordMessageReactionUse(ctx, userID, []domain.MessageReaction{parsed}, req.GetAddToRecent(), int(r.clock.Now().Unix())); err != nil { + return nil, internalErr() + } + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} diff --git a/internal/rpc/update_peer_refs.go b/internal/rpc/update_peer_refs.go new file mode 100644 index 00000000..f3ef3a3c --- /dev/null +++ b/internal/rpc/update_peer_refs.go @@ -0,0 +1,283 @@ +package rpc + +import ( + "context" + + "telesrv/internal/domain" +) + +func (r *Router) enrichUpdateEvents(ctx context.Context, viewerUserID int64, events []domain.UpdateEvent) []domain.UpdateEvent { + if len(events) == 0 { + return events + } + out := append([]domain.UpdateEvent(nil), events...) + for i := range out { + if out[i].Type == domain.UpdateEventMessageReactions { + out[i] = r.enrichMessageReactionEvent(ctx, viewerUserID, out[i]) + } + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + addDomainPeerRef(out[i].Peer, 0, userIDs, channelIDs) + for _, peer := range out[i].Peers { + addDomainPeerRef(peer, 0, userIDs, channelIDs) + } + collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs) + out[i].Users = r.withUsersPresence(mergeDomainUsers(out[i].Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) + out[i].Channels = mergeDomainChannels(out[i].Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) + } + return out +} + +func (r *Router) enrichMessageReactionEvent(ctx context.Context, viewerUserID int64, event domain.UpdateEvent) domain.UpdateEvent { + if r.deps.Messages == nil || event.Message.ID <= 0 { + return event + } + peer := event.Message.Peer + if peer.Type == "" || peer.ID == 0 { + peer = event.Peer + } + if peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return event + } + res, err := r.deps.Messages.GetMessageReactions(ctx, viewerUserID, domain.PrivateMessageReactionsRequest{ + OwnerUserID: viewerUserID, + Peer: peer, + IDs: []int{event.Message.ID}, + }) + if err != nil { + return event + } + for _, msg := range res.Messages { + if msg.OwnerUserID == viewerUserID && msg.ID == event.Message.ID { + msg.Pts = event.Pts + event.Message = msg + event.Peer = msg.Peer + return event + } + } + return event +} + +func (r *Router) enrichChannelDifference(ctx context.Context, viewerUserID int64, diff domain.ChannelDifference) domain.ChannelDifference { + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, event := range diff.Events { + collectChannelUpdatePeerRefs(event, diff.Channel.ID, userIDs, channelIDs) + } + for _, msg := range diff.NewMessages { + collectChannelMessagePeerRefs(msg, diff.Channel.ID, userIDs, channelIDs) + } + for _, event := range diff.OtherUpdates { + collectChannelUpdatePeerRefs(event, diff.Channel.ID, userIDs, channelIDs) + } + diff.Users = r.withUsersPresence(mergeDomainUsers(diff.Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) + diff.Channels = mergeDomainChannels(diff.Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) + return diff +} + +func (r *Router) enrichChannelHistory(ctx context.Context, viewerUserID int64, history domain.ChannelHistory) domain.ChannelHistory { + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, msg := range history.Messages { + collectChannelMessagePeerRefs(msg, history.Channel.ID, userIDs, channelIDs) + } + for _, topic := range history.Topics { + if topic.CreatorUserID != 0 { + userIDs[topic.CreatorUserID] = struct{}{} + } + } + history.Users = r.withUsersPresence(mergeDomainUsers(history.Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) + history.Channels = mergeDomainChannels(history.Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) + return history +} + +func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { + addDomainPeerRef(msg.From, currentChannelID, userIDs, channelIDs) + addDomainPeerRef(msg.Peer, currentChannelID, userIDs, channelIDs) + if msg.Forward != nil { + addDomainPeerRef(msg.Forward.From, currentChannelID, userIDs, channelIDs) + } + if msg.ReplyTo != nil { + addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs) + } + if msg.Reactions != nil { + for _, reaction := range msg.Reactions.Recent { + if reaction.UserID != 0 { + userIDs[reaction.UserID] = struct{}{} + } + } + } +} + +func collectChannelUpdatePeerRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { + if event.SenderUserID != 0 { + userIDs[event.SenderUserID] = struct{}{} + } + for _, id := range event.UserIDs { + if id != 0 { + userIDs[id] = struct{}{} + } + } + for _, member := range []domain.ChannelMember{event.Previous, event.Participant} { + if member.UserID != 0 { + userIDs[member.UserID] = struct{}{} + } + if member.InviterUserID != 0 { + userIDs[member.InviterUserID] = struct{}{} + } + } + collectChannelMessagePeerRefs(event.Message, currentChannelID, userIDs, channelIDs) +} + +func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { + if msg.SenderUserID != 0 { + userIDs[msg.SenderUserID] = struct{}{} + } + addDomainPeerRef(msg.From, currentChannelID, userIDs, channelIDs) + if msg.SendAs != nil { + addDomainPeerRef(*msg.SendAs, currentChannelID, userIDs, channelIDs) + } + if msg.Forward != nil { + addDomainPeerRef(msg.Forward.From, currentChannelID, userIDs, channelIDs) + } + if msg.ReplyTo != nil { + addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs) + } + if msg.Action != nil { + for _, id := range msg.Action.UserIDs { + if id != 0 { + userIDs[id] = struct{}{} + } + } + } + if msg.Reactions != nil { + for _, reaction := range msg.Reactions.Recent { + if reaction.UserID != 0 { + userIDs[reaction.UserID] = struct{}{} + } + } + } +} + +func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { + switch peer.Type { + case domain.PeerTypeUser: + if peer.ID != 0 { + userIDs[peer.ID] = struct{}{} + } + case domain.PeerTypeChannel: + if peer.ID != 0 && peer.ID != currentChannelID { + channelIDs[peer.ID] = struct{}{} + } + } +} + +func (r *Router) domainUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []domain.User { + if len(ids) == 0 { + return nil + } + out := make([]domain.User, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + switch { + case id == domain.OfficialSystemUserID: + out = append(out, r.withUserPresence(domain.OfficialSystemUser())) + case r.deps.Users == nil: + continue + case id == currentUserID: + if u, err := r.deps.Users.Self(ctx, currentUserID); err == nil && u.ID != 0 { + out = append(out, r.withUserPresence(u)) + } + default: + if u, found, err := r.deps.Users.ByID(ctx, currentUserID, id); err == nil && found { + out = append(out, r.withUserPresence(u)) + } + } + } + return out +} + +func (r *Router) domainChannelsForIDs(ctx context.Context, currentUserID int64, ids []int64) []domain.Channel { + if r.deps.Channels == nil || len(ids) == 0 { + return nil + } + out := make([]domain.Channel, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + view, err := r.deps.Channels.GetChannel(ctx, currentUserID, id) + if err != nil || view.Channel.ID == 0 { + continue + } + out = append(out, view.Channel) + } + return out +} + +func mergeDomainUsers(base []domain.User, extra ...domain.User) []domain.User { + out := append([]domain.User(nil), base...) + seen := make(map[int64]struct{}, len(out)+len(extra)) + for _, u := range out { + if u.ID != 0 { + seen[u.ID] = struct{}{} + } + } + for _, u := range extra { + if u.ID == 0 { + continue + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + out = append(out, u) + } + return out +} + +func mergeDomainChannels(base []domain.Channel, extra ...domain.Channel) []domain.Channel { + out := append([]domain.Channel(nil), base...) + seen := make(map[int64]struct{}, len(out)+len(extra)) + for _, ch := range out { + if ch.ID != 0 { + seen[ch.ID] = struct{}{} + } + } + for _, ch := range extra { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + out = append(out, ch) + } + return out +} + +func mapKeys(items map[int64]struct{}) []int64 { + if len(items) == 0 { + return nil + } + out := make([]int64, 0, len(items)) + for id := range items { + if id != 0 { + out = append(out, id) + } + } + return out +} diff --git a/internal/rpc/updates.go b/internal/rpc/updates.go new file mode 100644 index 00000000..95e7cac3 --- /dev/null +++ b/internal/rpc/updates.go @@ -0,0 +1,107 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +const updatesTooLongNudgeDelay = 300 * time.Millisecond + +// registerUpdates 注册 updates.* RPC handler。 +func (r *Router) registerUpdates(d *tg.ServerDispatcher) { + d.OnUpdatesGetState(r.onUpdatesGetState) + d.OnUpdatesGetDifference(r.onUpdatesGetDifference) +} + +// onUpdatesGetState 处理 updates.getState(第一阶段返回零状态)。 +func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Updates == nil { + r.markSessionReceivesUpdates(ctx, userID) + return &tg.UpdatesState{Date: int(r.clock.Now().Unix())}, nil + } + st, err := r.deps.Updates.GetState(ctx, id, userID) + if err != nil { + return nil, internalErr() + } + current, err := r.deps.Updates.CurrentState(ctx, userID) + if err != nil { + return nil, internalErr() + } + r.markSessionReceivesUpdates(ctx, userID) + if current.Pts > st.Pts { + r.scheduleCurrentSessionDifferenceNudge(ctx) + } + return ptr(tgUpdateState(st)), nil +} + +func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetDifferenceRequest) (tg.UpdatesDifferenceClass, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Updates == nil { + now := int(r.clock.Now().Unix()) + r.markSessionReceivesUpdates(ctx, userID) + return &tg.UpdatesDifferenceEmpty{Date: now}, nil + } + st, err := r.deps.Updates.GetDifference(ctx, id, userID, domain.UpdateState{ + Pts: req.Pts, + Qts: req.Qts, + Date: req.Date, + }) + if err != nil { + return nil, internalErr() + } + r.markSessionReceivesUpdates(ctx, userID) + if len(st.Events) == 0 { + return &tg.UpdatesDifferenceEmpty{Date: st.State.Date, Seq: st.State.Seq}, nil + } + st.Events = r.enrichUpdateEvents(ctx, userID, st.Events) + return tgUpdatesDifference(st), nil +} + +func (r *Router) markSessionReceivesUpdates(ctx context.Context, userID int64) { + if r.deps.Sessions == nil { + return + } + r.syncSessionChannelMemberships(ctx, userID) + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + if scoped, ok := r.scopedSessions(); ok { + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + scoped.SetReceivesUpdatesForAuthKey(rawAuthKeyID, sessionID, true) + return + } + } + r.deps.Sessions.SetReceivesUpdates(sessionID, true) +} + +func (r *Router) scheduleCurrentSessionDifferenceNudge(ctx context.Context) { + pushCtx := context.Background() + if sessionID, ok := SessionIDFrom(ctx); ok { + pushCtx = WithSessionID(pushCtx, sessionID) + } + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + pushCtx = WithRawAuthKeyID(pushCtx, rawAuthKeyID) + } + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + pushCtx = WithAuthKeyID(pushCtx, authKeyID) + } + time.AfterFunc(updatesTooLongNudgeDelay, func() { + r.pushCurrentSessionMessage(pushCtx, "push updatesTooLong after getState", &tg.UpdatesTooLong{}) + }) +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/rpc/upload.go b/internal/rpc/upload.go new file mode 100644 index 00000000..00cf537d --- /dev/null +++ b/internal/rpc/upload.go @@ -0,0 +1,188 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// registerUpload 注册 upload.* RPC handler(分片上传 + 文件下载)。 +func (r *Router) registerUpload(d *tg.ServerDispatcher) { + d.OnUploadSaveFilePart(r.onUploadSaveFilePart) + d.OnUploadSaveBigFilePart(r.onUploadSaveBigFilePart) + d.OnUploadGetFile(r.onUploadGetFile) + d.OnUploadGetFileHashes(r.onUploadGetFileHashes) +} + +func (r *Router) onUploadSaveFilePart(ctx context.Context, req *tg.UploadSaveFilePartRequest) (bool, error) { + if r.deps.Files == nil { + return false, notImplementedErr() + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if !ok || userID == 0 { + return false, fileIDInvalidErr() + } + if req.FilePart < 0 { + return false, filePartInvalidErr() + } + saved, err := r.deps.Files.SaveFilePart(ctx, userID, req.FileID, req.FilePart, req.Bytes) + if err != nil { + return false, fileSaveErr(err) + } + return saved, nil +} + +func (r *Router) onUploadSaveBigFilePart(ctx context.Context, req *tg.UploadSaveBigFilePartRequest) (bool, error) { + if r.deps.Files == nil { + return false, notImplementedErr() + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if !ok || userID == 0 { + return false, fileIDInvalidErr() + } + if req.FilePart < 0 { + return false, filePartInvalidErr() + } + saved, err := r.deps.Files.SaveBigFilePart(ctx, userID, req.FileID, req.FilePart, req.FileTotalParts, req.Bytes) + if err != nil { + return false, fileSaveErr(err) + } + return saved, nil +} + +func (r *Router) onUploadGetFile(ctx context.Context, req *tg.UploadGetFileRequest) (tg.UploadFileClass, error) { + if r.deps.Files == nil { + return nil, notImplementedErr() + } + key, ok := fileLocationKey(req.Location) + if !ok { + return nil, locationInvalidErr() + } + chunk, found, err := r.deps.Files.GetFile(ctx, domain.FileDownloadRequest{ + LocationKey: key, + Offset: req.Offset, + Limit: req.Limit, + }) + if err != nil { + return nil, internalErr() + } + if found { + return &tg.UploadFile{ + Type: storageFileType(chunk.MimeType, chunk.Bytes), + Mtime: 0, + Bytes: chunk.Bytes, + }, nil + } + return nil, locationInvalidErr() +} + +// onUploadGetFileHashes 返回空 hash 列表:本阶段不做 CDN/分片完整性校验,客户端据空列表直接信任数据。 +func (r *Router) onUploadGetFileHashes(ctx context.Context, req *tg.UploadGetFileHashesRequest) ([]tg.FileHash, error) { + return []tg.FileHash{}, nil +} + +// fileLocationKey 把 tg.InputFileLocation 推导为 file_blobs 的 location_key。 +// 约定: +// +// doc: 文档主体 +// doc:: 文档缩略图 +// photo:: 照片某尺寸(头像 big→c / small→a) +func fileLocationKey(location tg.InputFileLocationClass) (string, bool) { + switch loc := location.(type) { + case *tg.InputDocumentFileLocation: + if loc.ID == 0 { + return "", false + } + if loc.ThumbSize == "" { + return fmt.Sprintf("doc:%d", loc.ID), true + } + return fmt.Sprintf("doc:%d:%s", loc.ID, loc.ThumbSize), true + case *tg.InputPhotoFileLocation: + if loc.ID == 0 || loc.ThumbSize == "" { + return "", false + } + return fmt.Sprintf("photo:%d:%s", loc.ID, loc.ThumbSize), true + case *tg.InputPeerPhotoFileLocation: + if loc.PhotoID == 0 { + return "", false + } + size := "a" + if loc.Big { + size = "c" + } + return fmt.Sprintf("photo:%d:%s", loc.PhotoID, size), true + default: + // InputFileLocation(legacy volume/local/secret) / InputStickerSetThumb 等本阶段不生成对应资源。 + return "", false + } +} + +// storageFileType 映射 storage.FileType,优先信任字节魔数以兼容历史上写错 mime 的 seed blob。 +func storageFileType(mime string, data []byte) tg.StorageFileTypeClass { + switch sniffImageType(data) { + case "jpeg": + return &tg.StorageFileJpeg{} + case "png": + return &tg.StorageFilePng{} + case "gif": + return &tg.StorageFileGif{} + case "webp": + return &tg.StorageFileWebp{} + } + switch { + case strings.Contains(mime, "webp"): + return &tg.StorageFileWebp{} + case strings.Contains(mime, "jpeg"), strings.Contains(mime, "jpg"): + return &tg.StorageFileJpeg{} + case strings.Contains(mime, "png"): + return &tg.StorageFilePng{} + case strings.Contains(mime, "gif"): + return &tg.StorageFileGif{} + case strings.Contains(mime, "mp4"), strings.Contains(mime, "quicktime"), strings.Contains(mime, "video"): + return &tg.StorageFileMov{} + } + return &tg.StorageFileUnknown{} +} + +// sniffImageType 用魔数探测常见图片类型。 +func sniffImageType(data []byte) string { + if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "jpeg" + } + if len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' { + return "png" + } + if len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F' { + return "gif" + } + if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && + data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' { + return "webp" + } + return "" +} + +// fileSaveErr 把 files 服务的分片错误映射为 rpc_error。 +func fileSaveErr(err error) error { + switch { + case errors.Is(err, domain.ErrFilePartInvalid): + return filePartInvalidErr() + case errors.Is(err, domain.ErrFilePartsInvalid): + return filePartsInvalidErr() + case errors.Is(err, domain.ErrFilePartTooBig): + return filePartTooBigErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/upload_test.go b/internal/rpc/upload_test.go new file mode 100644 index 00000000..52d6c70c --- /dev/null +++ b/internal/rpc/upload_test.go @@ -0,0 +1,34 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +func TestStorageFileTypePrefersMagicOverMime(t *testing.T) { + webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'} + if _, ok := storageFileType("image/jpeg", webp).(*tg.StorageFileWebp); !ok { + t.Fatalf("webp bytes mislabeled as jpeg should return StorageFileWebp") + } +} + +func TestStorageFileTypeFallsBackToMime(t *testing.T) { + if _, ok := storageFileType("image/png", nil).(*tg.StorageFilePng); !ok { + t.Fatalf("png mime without bytes should return StorageFilePng") + } +} + +func TestFileLocationKeyUsesDocumentID(t *testing.T) { + key, ok := fileLocationKey(&tg.InputDocumentFileLocation{ + ID: 1382305375846410902, + ThumbSize: "m", + }) + if !ok { + t.Fatal("fileLocationKey returned !ok") + } + const want = "doc:1382305375846410902:m" + if key != want { + t.Fatalf("key = %q, want %q", key, want) + } +} diff --git a/internal/rpc/users.go b/internal/rpc/users.go new file mode 100644 index 00000000..8dbbbcc3 --- /dev/null +++ b/internal/rpc/users.go @@ -0,0 +1,187 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/app/users" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +const maxSavedMusicLimit = 100 + +// registerUsers 注册 users.* RPC handler。 +func (r *Router) registerUsers(d *tg.ServerDispatcher) { + d.OnUsersGetUsers(r.onUsersGetUsers) + d.OnUsersGetFullUser(r.onUsersGetFullUser) + d.OnUsersGetSavedMusic(r.onUsersGetSavedMusic) + d.OnUsersGetSavedMusicByID(r.onUsersGetSavedMusicByID) +} + +// onUsersGetUsers 处理 users.getUsers:支持 self 和已知 user peer(含 777000 官方账号)。 +func (r *Router) onUsersGetUsers(ctx context.Context, ids []tg.InputUserClass) ([]tg.UserClass, error) { + currentUserID, authorized, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + out := make([]tg.UserClass, 0, len(ids)) + for _, in := range ids { + if r.deps.Users == nil { + continue + } + switch v := in.(type) { + case *tg.InputUserSelf: + if !authorized { + continue + } + u, err := r.deps.Users.Self(ctx, currentUserID) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + continue // 未登录:getUsers 尽力而为,跳过 self + } + return nil, internalErr() + } + out = append(out, r.tgSelfUser(u)) + case *tg.InputUser: + if !authorized { + continue + } + u, found, err := r.deps.Users.ByID(ctx, currentUserID, v.UserID) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + continue + } + return nil, internalErr() + } + if !found || (v.AccessHash != 0 && v.AccessHash != u.AccessHash) { + continue + } + out = append(out, r.tgUser(u)) + } + } + return out, nil +} + +func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (*tg.UsersUserFull, error) { + if r.deps.Users == nil { + return emptyUserFull(), nil + } + currentUserID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + u, found, err := r.userFromInput(ctx, currentUserID, id) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + return emptyUserFull(), nil + } + return nil, internalErr() + } + if !found { + return emptyUserFull(), nil + } + user := r.tgUser(u) + if _, ok := id.(*tg.InputUserSelf); ok { + user = r.tgSelfUser(u) + } + full := tg.UserFull{ + ID: u.ID, + About: u.About, + Settings: tg.PeerSettings{}, + NotifySettings: *tdesktop.NotifySettings(), + } + if r.deps.Channels != nil && u.ID != currentUserID { + common, err := r.deps.Channels.CommonChannels(ctx, currentUserID, domain.CommonChannelsRequest{ + UserID: currentUserID, + TargetUserID: u.ID, + Limit: 1, + CountOnly: true, + }) + if err != nil { + return nil, internalErr() + } + full.CommonChatsCount = common.Count + } + return &tg.UsersUserFull{ + FullUser: full, + Users: []tg.UserClass{user}, + }, nil +} + +func (r *Router) onUsersGetSavedMusic(ctx context.Context, req *tg.UsersGetSavedMusicRequest) (tg.UsersSavedMusicClass, error) { + if req == nil || req.Offset < 0 || req.Limit < 0 || req.Limit > maxSavedMusicLimit { + return nil, limitInvalidErr() + } + if err := r.validateInputUser(ctx, req.ID); err != nil { + return nil, err + } + return &tg.UsersSavedMusic{ + Count: 0, + Documents: []tg.DocumentClass{}, + }, nil +} + +func (r *Router) onUsersGetSavedMusicByID(ctx context.Context, req *tg.UsersGetSavedMusicByIDRequest) (tg.UsersSavedMusicClass, error) { + if req == nil || len(req.Documents) > maxSavedMusicLimit { + return nil, limitInvalidErr() + } + if err := r.validateInputUser(ctx, req.ID); err != nil { + return nil, err + } + return &tg.UsersSavedMusic{ + Count: 0, + Documents: []tg.DocumentClass{}, + }, nil +} + +func emptyUserFull() *tg.UsersUserFull { + return &tg.UsersUserFull{ + FullUser: tg.UserFull{ + Settings: tg.PeerSettings{}, + NotifySettings: *tdesktop.NotifySettings(), + }, + } +} + +func (r *Router) userFromInput(ctx context.Context, currentUserID int64, id tg.InputUserClass) (domain.User, bool, error) { + switch v := id.(type) { + case *tg.InputUserSelf: + u, err := r.deps.Users.Self(ctx, currentUserID) + return u, err == nil, err + case *tg.InputUser: + u, found, err := r.deps.Users.ByID(ctx, currentUserID, v.UserID) + if err != nil || !found { + return domain.User{}, found, err + } + if v.AccessHash != 0 && v.AccessHash != u.AccessHash { + return domain.User{}, false, nil + } + return u, true, nil + default: + return domain.User{}, false, nil + } +} + +func (r *Router) validateInputUser(ctx context.Context, id tg.InputUserClass) error { + if r.deps.Users == nil { + return nil + } + currentUserID, _, err := r.currentUserID(ctx) + if err != nil { + return internalErr() + } + _, found, err := r.userFromInput(ctx, currentUserID, id) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + return userIDInvalidErr() + } + return internalErr() + } + if !found { + return userIDInvalidErr() + } + return nil +} diff --git a/internal/store/account.go b/internal/store/account.go new file mode 100644 index 00000000..f339cebe --- /dev/null +++ b/internal/store/account.go @@ -0,0 +1,19 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// PasswordStore 持久化账号 2FA/SRP 配置。 +type PasswordStore interface { + GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) + Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error +} + +// AccountReactionSettingsStore persists account-level reaction preferences. +type AccountReactionSettingsStore interface { + GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) + SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error +} diff --git a/internal/store/allocator.go b/internal/store/allocator.go new file mode 100644 index 00000000..c15e3f9b --- /dev/null +++ b/internal/store/allocator.go @@ -0,0 +1,26 @@ +package store + +import "context" + +// PtsAllocator 分配用户级 pts。实现必须保证同一 user 内单调递增。 +type PtsAllocator interface { + NextPts(ctx context.Context, userID int64) (int, error) + CurrentPts(ctx context.Context, userID int64) (int, error) +} + +// PtsRangeAllocator 可一次性分配一段连续 pts,返回该段最终 pts。 +// 批量事件(例如 updateDeleteMessages)必须用 pts_count 推进多步时使用它。 +type PtsRangeAllocator interface { + NextPtsN(ctx context.Context, userID int64, count int) (int, error) +} + +// BoxIDAllocator 分配用户视角 message box id。box_id 允许空洞,但不能回退。 +type BoxIDAllocator interface { + NextBoxID(ctx context.Context, userID int64) (int, error) + CurrentBoxID(ctx context.Context, userID int64) (int, error) +} + +// CounterSource 用于 Redis 计数器冷启动时从 PostgreSQL durable log 恢复当前值。 +type CounterSource interface { + Current(ctx context.Context, userID int64) (int, error) +} diff --git a/internal/store/authkey.go b/internal/store/authkey.go new file mode 100644 index 00000000..3d40979f --- /dev/null +++ b/internal/store/authkey.go @@ -0,0 +1,22 @@ +package store + +import "context" + +// AuthKeyData 是一条持久化的 MTProto auth key 记录。 +// +// 不依赖 td 协议类型:连接层在边界做 crypto.AuthKey ↔ AuthKeyData 转换。 +type AuthKeyData struct { + ID [8]byte // auth_key_id(key 的 SHA1 低 64 位) + Value [256]byte // 2048-bit auth key + ServerSalt int64 // 密钥交换产出的初始 server salt + CreatedAt int64 // unix 秒 + // 用户绑定不在此处:auth_key 是协议产物,授权(auth_key↔user + 设备信息)由 authorization 承载(P2)。 +} + +// AuthKeyStore 持久化 auth key。实现见 store/memory(测试替身)、store/postgres。 +type AuthKeyStore interface { + // Save 保存或覆盖一条 auth key 记录。 + Save(ctx context.Context, k AuthKeyData) error + // Get 按 auth_key_id 查询;不存在时 found=false。 + Get(ctx context.Context, id [8]byte) (data AuthKeyData, found bool, err error) +} diff --git a/internal/store/authorization.go b/internal/store/authorization.go new file mode 100644 index 00000000..e14f1f7f --- /dev/null +++ b/internal/store/authorization.go @@ -0,0 +1,15 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// AuthorizationStore 持久化设备授权(auth_key ↔ user 绑定)。实现见 store/memory(测试替身)、store/postgres。 +type AuthorizationStore interface { + Bind(ctx context.Context, a domain.Authorization) error + ByAuthKey(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) + ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) + Delete(ctx context.Context, authKeyID [8]byte) error +} diff --git a/internal/store/channel.go b/internal/store/channel.go new file mode 100644 index 00000000..f73ab249 --- /dev/null +++ b/internal/store/channel.go @@ -0,0 +1,140 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// ChannelStore persists Telegram channels/supergroups and their single-copy messages. +type ChannelStore interface { + CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) + GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) + GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) + SaveChannelDefaultSendAs(ctx context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) + GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) + GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) + InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) + JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) + LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) + EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) + EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) + EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) + EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) + EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) + DeleteChannel(ctx context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) + CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) + UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) + ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) + SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) + SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) + SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) + SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) + SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) + SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) + SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) + SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) + SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) + ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) + GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) + SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) + GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) + ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) + ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) + ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) + ClearRecentMessageReactions(ctx context.Context, userID int64) error + ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) + UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error + CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) + EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) + UpdatePinnedForumTopic(ctx context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) + ReorderPinnedForumTopics(ctx context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) + DeleteForumTopicHistory(ctx context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) + ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) + GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) + SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) + EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) + DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) + DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) + DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) + UpdatePinnedMessage(ctx context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) + ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) + CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) + ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) + ListExportedInvites(ctx context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) + GetExportedInvite(ctx context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) + EditExportedInvite(ctx context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) + DeleteExportedInvite(ctx context.Context, req domain.DeleteChannelInviteRequest) error + DeleteRevokedExportedInvites(ctx context.Context, req domain.DeleteRevokedChannelInvitesRequest) error + ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) + ListInviteImporters(ctx context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) + PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) + HideChatJoinRequest(ctx context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) + HideAllChatJoinRequests(ctx context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) + ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) + GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) + ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) + ListLeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) + ListInactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) + ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) + ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) + SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) + SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, error) + ReorderChannelPinnedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error + SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error) + SetChannelViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) + ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) + EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error + ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) + SearchPublicPosts(ctx context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) + SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) + GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) + ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) + ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) + ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) + ReadChannelMentions(ctx context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) + ListChannelUnreadReactions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) + ReadChannelReactions(ctx context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) + GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) + ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) + ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) + ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) + ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) + ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) + ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) + FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) + MaxChannelPts(ctx context.Context, channelID int64) (int, error) + MaxChannelMessageID(ctx context.Context, channelID int64) (int, error) +} + +// ChannelIDAllocator allocates channel IDs. +type ChannelIDAllocator interface { + NextChannelID(ctx context.Context) (int64, error) + CurrentChannelID(ctx context.Context) (int64, error) +} + +// ChannelPtsAllocator allocates channel-scoped pts. +type ChannelPtsAllocator interface { + NextChannelPts(ctx context.Context, channelID int64) (int, error) + CurrentChannelPts(ctx context.Context, channelID int64) (int, error) +} + +// ChannelPtsRangeAllocator allocates a range of channel pts and returns the final pts. +type ChannelPtsRangeAllocator interface { + NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) +} + +// ChannelMessageIDAllocator allocates channel-scoped message IDs. +type ChannelMessageIDAllocator interface { + NextChannelMessageID(ctx context.Context, channelID int64) (int, error) + CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) +} diff --git a/internal/store/code.go b/internal/store/code.go new file mode 100644 index 00000000..ff5623d2 --- /dev/null +++ b/internal/store/code.go @@ -0,0 +1,20 @@ +package store + +import ( + "context" + "time" +) + +// PhoneCode 是一条登录验证码记录(与某次 sendCode 的 phone_code_hash 关联)。 +type PhoneCode struct { + Phone string + Code string +} + +// CodeStore 暂存登录验证码:phone_code_hash → 手机号 + 验证码,带 TTL。 +// 实现见 store/memory(测试替身)、store/redisstore。 +type CodeStore interface { + Set(ctx context.Context, phoneCodeHash string, code PhoneCode, ttl time.Duration) error + Get(ctx context.Context, phoneCodeHash string) (PhoneCode, bool, error) + Del(ctx context.Context, phoneCodeHash string) error +} diff --git a/internal/store/contact.go b/internal/store/contact.go new file mode 100644 index 00000000..71e06c04 --- /dev/null +++ b/internal/store/contact.go @@ -0,0 +1,17 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// ContactStore 持久化用户通讯录。 +type ContactStore interface { + ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) + Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) + Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) + UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) + UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) + Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) +} diff --git a/internal/store/dialog.go b/internal/store/dialog.go new file mode 100644 index 00000000..3872cf3b --- /dev/null +++ b/internal/store/dialog.go @@ -0,0 +1,32 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// DialogStore 持久化用户会话摘要。 +type DialogStore interface { + ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) + ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) + Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error + SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error + DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) + ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) + ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) + MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) + SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) + ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error + SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) + ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) + SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) + PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) + ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) + GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) + UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error + DeleteFolder(ctx context.Context, userID int64, folderID int) error + ReorderFolders(ctx context.Context, userID int64, order []int) error + SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error + EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error +} diff --git a/internal/store/dispatch_outbox.go b/internal/store/dispatch_outbox.go new file mode 100644 index 00000000..6f71cbdf --- /dev/null +++ b/internal/store/dispatch_outbox.go @@ -0,0 +1,27 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// DispatchOutboxItem 是待投递给在线 session 的 update 任务。 +type DispatchOutboxItem struct { + ID int64 + TargetUserID int64 + Pts int + EventType domain.UpdateEventType + ExcludeAuthKeyID [8]byte + ExcludeSessionID int64 + Attempts int +} + +// DispatchOutboxStore 持久化 transactional outbox。 +type DispatchOutboxStore interface { + ClaimPending(ctx context.Context, limit int) ([]DispatchOutboxItem, error) + MarkDelivered(ctx context.Context, targetUserID, id int64) error + MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error + DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) +} diff --git a/internal/store/doc.go b/internal/store/doc.go new file mode 100644 index 00000000..dbb9c1fe --- /dev/null +++ b/internal/store/doc.go @@ -0,0 +1,13 @@ +// Package store 定义存储接口与协议层 DTO,不含具体实现。 +// +// 布局:主包只放接口(AuthKeyStore / SessionStore / UserStore / AuthorizationStore / +// CodeStore / UpdateStateStore / UpdateEventStore 等)与协议 DTO;三种后端实现各自独立成对称子包: +// - store/memory —— 内存实现,测试替身与本地兜底 +// - store/postgres —— PostgreSQL(pgx + sqlc 生成查询 + golang-migrate 迁移) +// - store/redisstore —— Redis(go-redis) +// +// 类型边界:接口签名分两类—— +// - 协议产物用 store 自有 DTO:AuthKeyData、SessionData、PhoneCode(不依赖 tg.*,也非业务实体); +// - 业务实体直接用 domain:UserStore / AuthorizationStore / MessageStore / UpdateEventStore +// 收发 domain.User / domain.Authorization / domain.Message / domain.UpdateEvent。 +package store diff --git a/internal/store/help.go b/internal/store/help.go new file mode 100644 index 00000000..1684739c --- /dev/null +++ b/internal/store/help.go @@ -0,0 +1,19 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// AppConfigStore 持久化 help.getAppConfig 数据。 +type AppConfigStore interface { + GetAppConfig(ctx context.Context, client string) (domain.AppConfig, bool, error) + UpsertAppConfig(ctx context.Context, cfg domain.AppConfig) error +} + +// CountryStore 持久化 help.getCountriesList 数据。 +type CountryStore interface { + ListCountries(ctx context.Context, langCode string) (domain.CountriesList, error) + UpsertCountries(ctx context.Context, countries []domain.Country) error +} diff --git a/internal/store/langpack.go b/internal/store/langpack.go new file mode 100644 index 00000000..db5c61e0 --- /dev/null +++ b/internal/store/langpack.go @@ -0,0 +1,14 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// LangPackStore 持久化客户端语言包。 +type LangPackStore interface { + GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) + GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) + UpsertPack(ctx context.Context, pack domain.LangPack) error +} diff --git a/internal/store/media.go b/internal/store/media.go new file mode 100644 index 00000000..ef299a0c --- /dev/null +++ b/internal/store/media.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// MediaStore 持久化媒体元数据:上传分片、blob 索引、文档/照片注册表、 +// 贴纸集、可用 reaction、头像历史。blob 字节本身由 blob backend 按 object_key 读写, +// 本接口只管 file_blobs 索引行。 +type MediaStore interface { + // 上传分片(transient:组装成 blob 后即清理)。 + SaveFilePart(ctx context.Context, part domain.UploadPart) error + LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) + DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error + + // blob 索引。 + PutFileBlob(ctx context.Context, blob domain.FileBlob) error + GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) + + // 文档 / 照片注册表。 + PutDocument(ctx context.Context, doc domain.Document) error + GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) + GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) + PutPhoto(ctx context.Context, photo domain.Photo) error + GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) + + // 贴纸集 / 可用 reaction。 + PutStickerSet(ctx context.Context, set domain.StickerSet) error + GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) + GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) + GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) + ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) + CountStickerSets(ctx context.Context) (int, error) + PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error + ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) + CountAvailableReactions(ctx context.Context) (int, error) + + // 头像历史(owner = user/channel;current = active 中 sort_order 最大者)。 + AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error + CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) + CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) + ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (ids []int64, total int, err error) + DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) +} diff --git a/internal/store/memory/channel.go b/internal/store/memory/channel.go new file mode 100644 index 00000000..0be16f9b --- /dev/null +++ b/internal/store/memory/channel.go @@ -0,0 +1,6799 @@ +package memory + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "errors" + "sort" + "strconv" + "strings" + "sync" + "time" + + "telesrv/internal/domain" +) + +const firstMemoryChannelID int64 = 2000000000 + +type channelRandomKey struct { + channelID int64 + userID int64 + randomID int64 +} + +// ChannelStore is an in-memory channel/supergroup store for tests and local development. +type ChannelStore struct { + mu sync.RWMutex + nextID int64 + nextHash int64 + channels map[int64]domain.Channel + members map[int64]map[int64]domain.ChannelMember + dialogs map[int64]map[int64]domain.ChannelDialog + topics map[int64]map[int]domain.ChannelForumTopic + messages map[int64][]domain.ChannelMessage + reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction + top map[int64]map[string]domain.TopMessageReaction + recent map[int64]map[string]domain.RecentMessageReaction + savedTags map[int64]map[string]domain.SavedReactionTag + mentions map[int64]map[int64]map[int]int + msgViews map[int64]map[int]int + msgViewers map[int64]map[int]map[int64]struct{} + events map[int64][]domain.ChannelUpdateEvent + adminLogs map[int64][]domain.ChannelAdminLogEvent + invites map[string]domain.ChannelInvite + importers map[int64]map[int64]domain.ChannelInviteImporter + msgSeq map[int64]int + ptsSeq map[int64]int + logSeq map[int64]int64 + randomToID map[channelRandomKey]int +} + +// NewChannelStore creates an in-memory ChannelStore. +func NewChannelStore() *ChannelStore { + return &ChannelStore{ + nextID: firstMemoryChannelID, + nextHash: 900000000000, + channels: make(map[int64]domain.Channel), + members: make(map[int64]map[int64]domain.ChannelMember), + dialogs: make(map[int64]map[int64]domain.ChannelDialog), + topics: make(map[int64]map[int]domain.ChannelForumTopic), + messages: make(map[int64][]domain.ChannelMessage), + reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction), + top: make(map[int64]map[string]domain.TopMessageReaction), + recent: make(map[int64]map[string]domain.RecentMessageReaction), + savedTags: make(map[int64]map[string]domain.SavedReactionTag), + mentions: make(map[int64]map[int64]map[int]int), + msgViews: make(map[int64]map[int]int), + msgViewers: make(map[int64]map[int]map[int64]struct{}), + events: make(map[int64][]domain.ChannelUpdateEvent), + adminLogs: make(map[int64][]domain.ChannelAdminLogEvent), + invites: make(map[string]domain.ChannelInvite), + importers: make(map[int64]map[int64]domain.ChannelInviteImporter), + msgSeq: make(map[int64]int), + ptsSeq: make(map[int64]int), + logSeq: make(map[int64]int64), + randomToID: make(map[channelRandomKey]int), + } +} + +func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { + if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + + channelID := s.nextChannelIDLocked() + channel := domain.Channel{ + ID: channelID, + AccessHash: s.nextAccessHashLocked(), + CreatorUserID: req.CreatorUserID, + Title: strings.TrimSpace(req.Title), + About: req.About, + Broadcast: req.Broadcast, + Megagroup: req.Megagroup, + Forum: req.Forum, + ForumTabs: req.ForumTabs, + ParticipantsCount: 1, + AdminsCount: 1, + TTLPeriod: req.TTLPeriod, + Date: req.Date, + } + if !channel.Broadcast && !channel.Megagroup { + channel.Broadcast = true + } + creator := domain.ChannelMember{ + ChannelID: channelID, + UserID: req.CreatorUserID, + Role: domain.ChannelRoleCreator, + Status: domain.ChannelMemberActive, + JoinedAt: req.Date, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + PostMessages: true, + EditMessages: true, + DeleteMessages: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + AddAdmins: true, + ManageCall: true, + }, + } + s.channels[channelID] = channel + s.members[channelID] = map[int64]domain.ChannelMember{creator.UserID: creator} + members := []domain.ChannelMember{creator} + for _, userID := range uniqueNonZero(req.MemberUserIDs, req.CreatorUserID) { + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: req.CreatorUserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: req.Date, + } + s.members[channelID][userID] = member + members = append(members, member) + channel.ParticipantsCount++ + } + msg, event := s.appendChannelServiceMessageLocked(channelID, req.CreatorUserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionCreate, + Title: channel.Title, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[channelID] = channel + for _, member := range members { + s.upsertChannelDialogLocked(member.UserID, channel, msg, member.UserID == req.CreatorUserID) + } + return domain.CreateChannelResult{ + Channel: channel, + Members: cloneChannelMembers(members), + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} + +func (s *ChannelStore) GetChannel(_ context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, preview, err := s.channelForViewerLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + dialog := s.dialogForUserLocked(viewerUserID, channel) + if preview { + dialog = previewChannelDialog(viewerUserID, channel, member) + } + return domain.ChannelView{ + Channel: cloneChannel(channel), + Self: member, + Dialog: dialog, + }, nil +} + +func (s *ChannelStore) SaveChannelDefaultSendAs(_ context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + if req.SendAs != nil && req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelView{}, err + } + dialog := s.dialogForUserLocked(req.UserID, channel) + if req.SendAs != nil { + p := *req.SendAs + dialog.DefaultSendAs = &p + } else { + dialog.DefaultSendAs = nil + } + if s.dialogs[req.UserID] == nil { + s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[req.UserID][req.ChannelID] = dialog + member := s.members[req.ChannelID][req.UserID] + return domain.ChannelView{Channel: cloneChannel(channel), Self: member, Dialog: dialog}, nil +} + +func (s *ChannelStore) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, domain.ErrChannelInvalid + } + return cloneChannel(channel), nil +} + +func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, err := s.channelForMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelParticipantList{}, err + } + if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { + limit = domain.MaxChannelParticipantsLimit + } + viewer := s.members[channelID][viewerUserID] + if channel.ParticipantsHidden && !isChannelAdmin(viewer) { + switch filter.Kind { + case domain.ChannelParticipantsAdmins: + case domain.ChannelParticipantsBots: + return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil + default: + return domain.ChannelParticipantList{Channel: channel, Count: channel.ParticipantsCount}, nil + } + } + if (filter.Kind == domain.ChannelParticipantsBanned || filter.Kind == domain.ChannelParticipantsKicked) && !isChannelAdmin(viewer) { + return domain.ChannelParticipantList{Channel: channel}, nil + } + query := strings.ToLower(strings.TrimSpace(filter.Query)) + items := make([]domain.ChannelMember, 0, len(s.members[channelID])) + for _, member := range s.members[channelID] { + if !channelParticipantMatchesFilter(member, filter.Kind, query) { + continue + } + items = append(items, member) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Role != items[j].Role { + return channelRoleOrder(items[i].Role) < channelRoleOrder(items[j].Role) + } + return items[i].UserID < items[j].UserID + }) + count := len(items) + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + if offset >= len(items) { + items = nil + } else { + end := offset + limit + if end > len(items) { + end = len(items) + } + items = items[offset:end] + } + return domain.ChannelParticipantList{ + Channel: channel, + Participants: cloneChannelMembers(items), + Count: count, + }, nil +} + +func (s *ChannelStore) GetParticipant(_ context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { + return domain.ChannelMember{}, err + } + member, ok := s.members[channelID][participantUserID] + if !ok { + return domain.ChannelMember{}, domain.ErrChannelPrivate + } + return member, nil +} + +func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(inviterUserID, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + inviter := s.members[channelID][inviterUserID] + if !canInviteToChannel(channel, inviter) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + requested := uniqueNonZero(userIDs, 0) + inviteOne := len(requested) == 1 + canRestoreKicked := canBanChannelUsers(inviter) + added := make([]int64, 0, len(requested)) + members := make([]domain.ChannelMember, 0, len(requested)) + restoredKicked := 0 + for _, userID := range requested { + if existing, ok := s.members[channelID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + if inviteOne { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + continue + } + if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { + if !canRestoreKicked { + if inviteOne { + return domain.CreateChannelResult{}, domain.ErrUserKicked + } + continue + } + if existing.Status == domain.ChannelMemberKicked { + restoredKicked++ + } + } + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: inviterUserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + } + s.members[channelID][userID] = member + members = append(members, member) + added = append(added, userID) + channel.ParticipantsCount++ + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: inviterUserID, + Date: date, + Type: domain.ChannelAdminLogParticipantInvite, + Participant: ptrChannelMember(member), + }) + } + if restoredKicked > 0 { + channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0) + } + s.channels[channelID] = channel + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if len(added) > 0 && channel.Megagroup { + msg, event = s.appendChannelServiceMessageLocked(channelID, inviterUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatAddUser, + UserIDs: append([]int64(nil), added...), + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[channelID] = channel + } + for _, member := range members { + s.upsertChannelDialogLocked(member.UserID, channel, msg, false) + } + return domain.CreateChannelResult{ + Channel: channel, + Members: cloneChannelMembers(members), + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} + +func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + preJoinTopID := channel.TopMessageID + if existing, ok := s.members[channelID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { + return domain.CreateChannelResult{}, domain.ErrChannelUserBanned + } + } + if channel.JoinRequest { + if err := s.recordPublicJoinRequestLocked(channel, userID, date); err != nil { + return domain.CreateChannelResult{}, err + } + return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + } + if existing, ok := s.members[channelID][userID]; ok { + member = existing + member.Status = domain.ChannelMemberActive + member.LeftAt = 0 + if minID := channelInitialAvailableMinID(channel); minID > member.AvailableMinID { + member.AvailableMinID = minID + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, minID) + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, preJoinTopID) + if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { + member.AvailableMinPts = minPts + } + channel.ParticipantsCount++ + } else { + member.AvailableMinID = channelInitialAvailableMinID(channel) + member.AvailableMinPts = channelInitialAvailableMinPts(channel) + member.ReadInboxMaxID = maxInt(member.AvailableMinID, preJoinTopID) + channel.ParticipantsCount++ + } + if s.members[channelID] == nil { + s.members[channelID] = make(map[int64]domain.ChannelMember) + } + s.members[channelID][userID] = member + s.channels[channelID] = channel + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantJoin, + }) + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatJoined, + UserIDs: []int64{userID}, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[channelID] = channel + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) + if msg.ID != 0 && msg.SenderUserID == userID { + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) + } + s.members[channelID][userID] = member + s.upsertChannelDialogLocked(userID, channel, msg, true) + return domain.CreateChannelResult{ + Channel: channel, + Members: []domain.ChannelMember{member}, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} + +func (s *ChannelStore) LeaveChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + member := s.members[channelID][userID] + member.Status = domain.ChannelMemberLeft + member.LeftAt = date + s.members[channelID][userID] = member + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantLeave, + }) + if channel.ParticipantsCount > 0 { + channel.ParticipantsCount-- + } + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatDelete, + UserIDs: []int64{userID}, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + s.channels[channelID] = channel + return domain.CreateChannelResult{ + Channel: channel, + Members: []domain.ChannelMember{member}, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: append(s.activeMemberIDsLocked(channelID, 0, 0), userID), + }, nil +} + +func (s *ChannelStore) EditChannelTitle(_ context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelTitleResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canChangeChannelInfo(member) { + return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired + } + title := strings.TrimSpace(req.Title) + if channel.Title == title { + return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified + } + prevTitle := channel.Title + channel.Title = title + msg, event := s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionEditTitle, + Title: title, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[req.ChannelID] = channel + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogChangeTitle, + PrevString: prevTitle, + NewString: title, + }) + s.upsertChannelDialogLocked(req.UserID, channel, msg, true) + return domain.EditChannelTitleResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) EditChannelAbout(_ context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.About = req.About + s.channels[req.ChannelID] = channel + return channel, nil +} + +func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + actor := s.members[req.ChannelID][req.UserID] + if !canAddChannelAdmins(actor) { + return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired + } + if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) { + return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden + } + previous, ok := s.members[req.ChannelID][req.MemberID] + if !ok { + previous = domain.ChannelMember{ + ChannelID: req.ChannelID, + UserID: req.MemberID, + InviterUserID: req.UserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: req.Date, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + } + } + if previous.Role == domain.ChannelRoleCreator { + return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator + } + member := previous + member.InviterUserID = req.UserID + member.Status = domain.ChannelMemberActive + member.LeftAt = 0 + if previous.Status != domain.ChannelMemberActive { + if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { + member.AvailableMinPts = minPts + } + } + member.AdminRights = req.AdminRights + member.Rank = req.Rank + if zeroChannelAdminRights(req.AdminRights) { + member.Role = domain.ChannelRoleMember + member.Rank = "" + } else { + member.Role = domain.ChannelRoleAdmin + } + s.members[req.ChannelID][req.MemberID] = member + logType := domain.ChannelAdminLogParticipantPromote + if member.Role != domain.ChannelRoleAdmin { + logType = domain.ChannelAdminLogParticipantDemote + } + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: logType, + PrevParticipant: ptrChannelMember(previous), + NewParticipant: ptrChannelMember(member), + }) + s.refreshChannelCountsLocked(req.ChannelID) + channel = s.channels[req.ChannelID] + event := s.appendParticipantEventLocked(channel, req.UserID, previous, member, req.Date) + channel = s.channels[req.ChannelID] + if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok { + s.upsertChannelDialogLocked(member.UserID, channel, msg, false) + } + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + recipients = append(recipients, req.MemberID) + return domain.EditChannelAdminResult{ + Channel: channel, + Previous: previous, + Participant: member, + Event: event, + Recipients: recipients, + Date: req.Date, + }, nil +} + +func (s *ChannelStore) EditChannelBanned(_ context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { + return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + actor := s.members[req.ChannelID][req.UserID] + if !canBanChannelUsers(actor) { + return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired + } + previous, ok := s.members[req.ChannelID][req.Participant.ID] + if !ok { + previous = domain.ChannelMember{ChannelID: req.ChannelID, UserID: req.Participant.ID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft} + } + if previous.Role == domain.ChannelRoleCreator { + return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator + } + member := previous + member.Role = domain.ChannelRoleMember + member.BannedRights = req.BannedRights + switch { + case req.BannedRights.ViewMessages: + member.InviterUserID = req.UserID + member.Status = domain.ChannelMemberKicked + member.LeftAt = req.Date + case zeroChannelBannedRights(req.BannedRights): + if previous.Status == domain.ChannelMemberActive { + member.Status = domain.ChannelMemberActive + } else { + member.Status = domain.ChannelMemberLeft + } + member.LeftAt = 0 + default: + member.InviterUserID = req.UserID + if previous.Status == domain.ChannelMemberActive { + member.Status = domain.ChannelMemberActive + } else { + member.Status = domain.ChannelMemberBanned + } + } + if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive { + member.JoinedAt = req.Date + } + s.members[req.ChannelID][req.Participant.ID] = member + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: adminLogBanType(previous, member), + PrevParticipant: ptrChannelMember(previous), + NewParticipant: ptrChannelMember(member), + }) + s.refreshChannelCountsLocked(req.ChannelID) + channel = s.channels[req.ChannelID] + event := s.appendParticipantEventLocked(channel, req.UserID, previous, member, req.Date) + channel = s.channels[req.ChannelID] + if member.Status == domain.ChannelMemberActive { + if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok { + s.upsertChannelDialogLocked(member.UserID, channel, msg, false) + } + } + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + recipients = append(recipients, req.Participant.ID) + return domain.EditChannelBannedResult{ + Channel: channel, + Previous: previous, + Participant: member, + Event: event, + Recipients: recipients, + Date: req.Date, + }, nil +} + +func (s *ChannelStore) EditChannelDefaultBannedRights(_ context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + actor := s.members[req.ChannelID][req.UserID] + if !canBanChannelUsers(actor) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if channel.DefaultBannedRights == req.BannedRights { + return domain.Channel{}, domain.ErrChannelNotModified + } + channel.DefaultBannedRights = req.BannedRights + s.channels[req.ChannelID] = channel + return channel, nil +} + +func (s *ChannelStore) DeleteChannel(_ context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.DeleteChannelResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if member.Role != domain.ChannelRoleCreator { + return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired + } + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + channel.Deleted = true + s.channels[req.ChannelID] = channel + return domain.DeleteChannelResult{Channel: channel, Recipients: recipients}, nil +} + +func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64, username string) (bool, error) { + if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" { + return false, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + return false, err + } + usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + for id, channel := range s.channels { + if channel.Deleted || channel.Username == "" { + continue + } + if strings.ToLower(channel.Username) == usernameLower && id != channelID { + return false, nil + } + } + return true, nil +} + +func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[req.ChannelID][req.UserID] + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) + usernameLower := strings.ToLower(username) + if strings.EqualFold(channel.Username, username) { + return domain.Channel{}, domain.ErrChannelNotModified + } + if usernameLower != "" { + for id, existing := range s.channels { + if existing.Deleted || existing.Username == "" { + continue + } + if strings.ToLower(existing.Username) == usernameLower && id != req.ChannelID { + return domain.Channel{}, domain.ErrUsernameOccupied + } + } + } + prevUsername := channel.Username + channel.Username = username + s.channels[req.ChannelID] = channel + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeUsername, + PrevString: prevUsername, + NewString: username, + }) + return channel, nil +} + +func (s *ChannelStore) ListAdminedPublicChannels(_ context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Channel, 0) + for channelID, members := range s.members { + member := members[userID] + if member.Status != domain.ChannelMemberActive || !isChannelAdmin(member) { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || channel.Username == "" { + continue + } + out = append(out, channel) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > domain.MaxAdminedPublicChannels { + out = out[:domain.MaxAdminedPublicChannels] + } + return append([]domain.Channel(nil), out...), nil +} + +func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { + if viewerUserID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if username == "" { + return domain.Channel{}, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + + for _, channel := range s.channels { + if !publicSearchableChannel(channel) { + continue + } + if strings.ToLower(channel.Username) == username { + return cloneChannel(channel), true, nil + } + } + return domain.Channel{}, false, nil +} + +func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { + if viewerUserID == 0 { + return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit { + limit = domain.MaxPublicChannelSearchLimit + } + query = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@"))) + if query == "" { + return domain.PublicChannelSearchResult{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + + type item struct { + channel domain.Channel + joined bool + rank int + } + items := make([]item, 0, limit) + for channelID, channel := range s.channels { + rank, ok := publicChannelSearchRank(channel, query) + if !ok { + continue + } + member, joined := s.members[channelID][viewerUserID] + joined = joined && member.Status == domain.ChannelMemberActive + items = append(items, item{ + channel: cloneChannel(channel), + joined: joined, + rank: rank, + }) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].rank != items[j].rank { + return items[i].rank < items[j].rank + } + if items[i].joined != items[j].joined { + return items[i].joined + } + if items[i].channel.ParticipantsCount != items[j].channel.ParticipantsCount { + return items[i].channel.ParticipantsCount > items[j].channel.ParticipantsCount + } + if items[i].channel.Date != items[j].channel.Date { + return items[i].channel.Date > items[j].channel.Date + } + return items[i].channel.ID > items[j].channel.ID + }) + + out := domain.PublicChannelSearchResult{} + for _, item := range items { + if len(out.MyResults)+len(out.Results) >= limit { + break + } + if item.joined { + out.MyResults = append(out.MyResults, item.channel) + } else { + out.Results = append(out.Results, item.channel) + } + } + return out, nil +} + +func (s *ChannelStore) SetChannelPhoto(_ context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if photo != nil && photo.ID != 0 { + channel.PhotoID = photo.ID + channel.PhotoDCID = photo.DCID + channel.PhotoStripped = domain.StrippedFromSizes(photo.Sizes) + } else { + channel.PhotoID = 0 + channel.PhotoDCID = 0 + channel.PhotoStripped = nil + } + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetSignatures(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.Signatures + channel.Signatures = enabled + s.channels[channelID] = channel + if prev != enabled { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleSignatures, + PrevBool: prev, + NewBool: enabled, + }) + } + return channel, nil +} + +func (s *ChannelStore) SetPreHistoryHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.PreHistoryHidden + channel.PreHistoryHidden = enabled + s.channels[channelID] = channel + if prev != enabled { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogTogglePreHistoryHidden, + PrevBool: prev, + NewBool: enabled, + }) + } + return channel, nil +} + +func (s *ChannelStore) SetParticipantsHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || !canBanChannelUsers(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.ParticipantsHidden = enabled + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetForum(_ context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || channel.Broadcast { + return domain.Channel{}, domain.ErrChannelNotModified + } + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if enabled && channel.LinkedChatID != 0 { + return domain.Channel{}, domain.ErrChatDiscussionUnallowed + } + prevForum := channel.Forum + prevTabs := channel.ForumTabs + channel.Forum = enabled + channel.ForumTabs = enabled && tabs + s.channels[channelID] = channel + if prevForum != channel.Forum || prevTabs != channel.ForumTabs { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleForum, + PrevBool: prevForum, + NewBool: enabled, + }) + } + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetAutotranslation(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.Autotranslation + channel.Autotranslation = enabled + s.channels[channelID] = channel + if prev != enabled { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleAutotranslation, + PrevBool: prev, + NewBool: enabled, + }) + } + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetRestrictedSponsored(_ context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.RestrictedSponsored = restricted + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetPaidMessagesPrice(_ context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 || stars < 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.SendPaidMessagesStars = stars + channel.BroadcastMessagesAllowed = channel.Broadcast && broadcastMessagesAllowed + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetAntiSpam(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.AntiSpam + channel.AntiSpam = enabled + s.channels[channelID] = channel + if prev != enabled { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleAntiSpam, + PrevBool: prev, + NewBool: enabled, + }) + } + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetSlowMode(_ context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { + if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.SlowmodeSeconds + channel.SlowmodeSeconds = seconds + s.channels[channelID] = channel + if prev != seconds { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleSlowMode, + PrevInt: prev, + NewInt: seconds, + }) + } + return channel, nil +} + +func (s *ChannelStore) SetNoForwards(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.NoForwards = enabled + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetJoinToSend(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || !canExportChannelInvite(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.JoinToSend = enabled + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetJoinRequest(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || !canExportChannelInvite(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if enabled && strings.TrimSpace(channel.Username) == "" { + return domain.Channel{}, domain.ErrChatPublicRequired + } + channel.JoinRequest = enabled + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetAvailableReactions(_ context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.ReactionPolicy = copyChannelReactionPolicy(policy) + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetColor(_ context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if forProfile { + channel.ProfileColor = color + } else { + channel.Color = color + } + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetEmojiStatus(_ context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.EmojiStatus = status + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) ListAdminLog(_ context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelAdminLogResult{}, err + } + if !isChannelAdmin(s.members[req.ChannelID][req.UserID]) { + return domain.ChannelAdminLogResult{}, domain.ErrChannelAdminRequired + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelAdminLogLimit { + limit = domain.MaxChannelAdminLogLimit + } + admins := int64Set(req.AdminUserIDs) + query := strings.ToLower(strings.TrimSpace(req.Query)) + out := make([]domain.ChannelAdminLogEvent, 0, limit) + events := s.adminLogs[req.ChannelID] + for i := len(events) - 1; i >= 0 && len(out) < limit; i-- { + event := events[i] + if req.MaxID > 0 && event.ID >= req.MaxID { + continue + } + if req.MinID > 0 && event.ID <= req.MinID { + continue + } + if len(admins) > 0 { + if _, ok := admins[event.UserID]; !ok { + continue + } + } + if !adminLogEventMatchesFilter(event.Type, req.Filter) { + continue + } + if query != "" && !adminLogEventMatchesQuery(event, query) { + continue + } + out = append(out, cloneChannelAdminLogEvent(event)) + } + return domain.ChannelAdminLogResult{Channel: channel, Events: out}, nil +} + +func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canSendChannelMessage(channel, member) { + return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden + } + if req.RandomID != 0 { + if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok { + msg, ok := s.findMessageLocked(req.ChannelID, id) + if ok { + event := s.eventForMessageLocked(req.ChannelID, id) + if event.Message.ID != 0 { + msg = event.Message + } + return domain.SendChannelMessageResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: event, + Duplicate: true, + }, nil + } + } + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { + return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) + } + replyTo, err := s.resolveChannelReplyLocked(req, member, channel) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + var sendAs *domain.Peer + if req.SendAs != nil { + p := *req.SendAs + sendAs = &p + } + pts := s.nextChannelPtsLocked(req.ChannelID) + msgID := s.nextChannelMessageIDLocked(req.ChannelID) + var discussion *domain.SendChannelDiscussionResult + var discussionRef *domain.ChannelDiscussionRef + if channel.Broadcast && channel.LinkedChatID != 0 { + if linked, ok := s.channels[channel.LinkedChatID]; ok && !linked.Deleted && linked.Megagroup { + discussionPts := s.nextChannelPtsLocked(linked.ID) + discussionMsgID := s.nextChannelMessageIDLocked(linked.ID) + discussionRef = &domain.ChannelDiscussionRef{ChannelID: linked.ID, MessageID: discussionMsgID} + discussionMsg := domain.ChannelMessage{ + ChannelID: linked.ID, + ID: discussionMsgID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, + Date: req.Date, + Silent: req.Silent, + NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, + Pts: discussionPts, + } + discussionEvent := domain.ChannelUpdateEvent{ + ChannelID: linked.ID, + Type: domain.ChannelUpdateNewMessage, + Pts: discussionPts, + PtsCount: 1, + Date: req.Date, + Message: cloneChannelMessage(discussionMsg), + } + s.messages[linked.ID] = append(s.messages[linked.ID], discussionMsg) + s.events[linked.ID] = append(s.events[linked.ID], discussionEvent) + linked.TopMessageID = discussionMsgID + linked.Pts = discussionPts + s.channels[linked.ID] = linked + s.addChannelUnreadMentionsLocked(linked.ID, discussionMsg, req.UserID, req.MentionUserIDs) + for userID, member := range s.members[linked.ID] { + if member.Status == domain.ChannelMemberActive { + s.upsertChannelDialogLocked(userID, linked, discussionMsg, false) + } + } + discussion = &domain.SendChannelDiscussionResult{ + Channel: cloneChannel(linked), + Message: cloneChannelMessage(discussionMsg), + Event: cloneChannelEvent(discussionEvent), + Recipients: s.activeMemberIDsLocked(linked.ID, 0, 0), + } + } + } + msg := domain.ChannelMessage{ + ChannelID: req.ChannelID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, + Date: req.Date, + Post: channel.Broadcast, + Silent: req.Silent, + NoForwards: req.NoForwards || channel.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ReplyTo: replyTo, + Forward: cloneMessageForward(req.Forward), + SendAs: sendAs, + Discussion: discussionRef, + Action: cloneChannelMessageAction(req.Action), + Pts: pts, + } + msg.Replies = s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg) + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Message: cloneChannelMessage(msg), + SenderUserID: req.UserID, + } + s.messages[req.ChannelID] = append(s.messages[req.ChannelID], msg) + s.events[req.ChannelID] = append(s.events[req.ChannelID], event) + s.addChannelUnreadMentionsLocked(req.ChannelID, msg, req.UserID, req.MentionUserIDs) + s.updateForumTopicTopMessageLocked(req.ChannelID, msg) + if channel.Broadcast { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogSendMessage, + Message: ptrChannelMessage(msg), + Query: msg.Body, + }) + } + if req.RandomID != 0 { + s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}] = msg.ID + } + channel.TopMessageID = msg.ID + channel.Pts = pts + s.channels[req.ChannelID] = channel + member.SlowmodeLastSendDate = req.Date + s.members[req.ChannelID][req.UserID] = member + for userID, member := range s.members[req.ChannelID] { + if member.Status == domain.ChannelMemberActive { + s.upsertChannelDialogLocked(userID, channel, msg, userID == req.UserID) + } + } + return domain.SendChannelMessageResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Discussion: discussion, + }, nil +} + +func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || strings.TrimSpace(req.Message) == "" { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelMessageResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + idx, ok := s.findMessageIndexLocked(req.ChannelID, req.ID) + if !ok || s.messages[req.ChannelID][idx].Deleted || s.messages[req.ChannelID][idx].Action != nil { + return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid + } + prevMsg := s.messages[req.ChannelID][idx] + msg := prevMsg + if msg.SenderUserID != req.UserID && !canEditChannelMessage(member) { + return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired + } + if msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { + return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified + } + pts := s.nextChannelPtsLocked(req.ChannelID) + msg.Body = req.Message + msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) + msg.EditDate = req.EditDate + msg.Pts = pts + s.messages[req.ChannelID][idx] = msg + channel.Pts = pts + s.channels[req.ChannelID] = channel + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateEditMessage, + Pts: pts, + PtsCount: 1, + Date: req.EditDate, + Message: cloneChannelMessage(msg), + SenderUserID: req.UserID, + } + s.events[req.ChannelID] = append(s.events[req.ChannelID], event) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.EditDate, + Type: domain.ChannelAdminLogEditMessage, + PrevMessage: ptrChannelMessage(prevMsg), + NewMessage: ptrChannelMessage(msg), + Query: msg.Body, + }) + return domain.EditChannelMessageResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) DeleteChannelMessages(_ context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxDeleteMessageIDs { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, req.IDs, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + return domain.DeleteChannelMessagesResult{ + Channel: channel, + Event: cloneChannelEvent(event), + DeletedIDs: append([]int(nil), deleted...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > channel.TopMessageID { + maxID = channel.TopMessageID + } + member := s.members[req.ChannelID][req.UserID] + if !req.ForEveryone { + appliedMinID := maxInt(member.AvailableMinID, maxID) + member.AvailableMinID = appliedMinID + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, appliedMinID) + member.UnreadMark = false + s.members[req.ChannelID][req.UserID] = member + s.deleteChannelUnreadMentionsUpToLocked(req.UserID, req.ChannelID, appliedMinID) + if s.dialogs[req.UserID] == nil { + s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[req.UserID][req.ChannelID] = s.dialogForUserLocked(req.UserID, channel) + return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { + msg := s.messages[req.ChannelID][i] + if msg.Deleted || msg.ID > maxID { + continue + } + ids = append(ids, msg.ID) + if len(ids) >= domain.MaxDeleteHistoryBatch { + break + } + } + deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + offset := 0 + if len(deleted) == domain.MaxDeleteHistoryBatch { + offset = 1 + } + return domain.DeleteChannelHistoryResult{ + Channel: channel, + Event: cloneChannelEvent(event), + DeletedIDs: append([]int(nil), deleted...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Offset: offset, + }, nil +} + +func (s *ChannelStore) DeleteChannelParticipantHistory(_ context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { + msg := s.messages[req.ChannelID][i] + if msg.Deleted || msg.SenderUserID != req.ParticipantUserID { + continue + } + ids = append(ids, msg.ID) + if len(ids) >= domain.MaxDeleteHistoryBatch { + break + } + } + deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + offset := 0 + if len(deleted) == domain.MaxDeleteHistoryBatch { + offset = 1 + } + return domain.DeleteChannelHistoryResult{ + Channel: channel, + Event: cloneChannelEvent(event), + DeletedIDs: append([]int(nil), deleted...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Offset: offset, + }, nil +} + +func (s *ChannelStore) UpdatePinnedMessage(_ context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired + } + msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) + if !ok || msg.Deleted { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid + } + pinnedID := 0 + if req.Pinned { + pinnedID = req.MessageID + } + if channel.PinnedMessageID == pinnedID { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified + } + pts := s.nextChannelPtsLocked(req.ChannelID) + channel.PinnedMessageID = pinnedID + channel.Pts = pts + s.channels[req.ChannelID] = channel + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdatePinnedMessages, + Pts: pts, + PtsCount: 1, + Date: req.Date, + MessageIDs: []int{req.MessageID}, + SenderUserID: req.UserID, + Pinned: req.Pinned, + } + s.events[req.ChannelID] = append(s.events[req.ChannelID], event) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogUpdatePinned, + Message: ptrChannelMessage(msg), + Query: msg.Body, + }) + return domain.UpdateChannelPinnedMessageResult{ + Channel: channel, + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) ExportInvite(_ context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ExportChannelInviteResult{}, domain.ErrChannelAdminRequired + } + if req.LegacyRevokePermanent { + for hash, invite := range s.invites { + if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.UserID && invite.Permanent { + invite.Revoked = true + s.invites[hash] = invite + } + } + } + inviteID, err := randomMemoryPositiveInt64() + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + hash, err := randomMemoryInviteHash() + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + invite := domain.ChannelInvite{ + ChannelID: req.ChannelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: req.UserID, + Title: req.Title, + Permanent: req.ExpireDate == 0 && req.UsageLimit == 0 && !req.RequestNeeded && req.Title == "", + RequestNeeded: req.RequestNeeded, + ExpireDate: req.ExpireDate, + UsageLimit: req.UsageLimit, + Date: req.Date, + } + s.invites[hash] = invite + return domain.ExportChannelInviteResult{Channel: channel, Invite: invite}, nil +} + +func (s *ChannelStore) CheckInvite(_ context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { + if userID == 0 || strings.TrimSpace(hash) == "" { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty + } + s.mu.RLock() + defer s.mu.RUnlock() + invite, ok := s.invites[strings.TrimSpace(hash)] + if !ok || invite.Revoked { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid + } + if invite.ExpireDate > 0 && invite.ExpireDate < date { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashExpired + } + channel, ok := s.channels[invite.ChannelID] + if !ok || channel.Deleted { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid + } + member := s.members[invite.ChannelID][userID] + if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid + } + return domain.CheckChannelInviteResult{ + Channel: channel, + Invite: invite, + Already: member.Status == domain.ChannelMemberActive, + Self: member, + }, nil +} + +func (s *ChannelStore) ImportInvite(_ context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty + } + s.mu.Lock() + defer s.mu.Unlock() + invite, ok := s.invites[strings.TrimSpace(req.Hash)] + if !ok || invite.Revoked { + return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid + } + if invite.ExpireDate > 0 && invite.ExpireDate < req.Date { + return domain.CreateChannelResult{}, domain.ErrInviteHashExpired + } + channel, ok := s.channels[invite.ChannelID] + if !ok || channel.Deleted { + return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid + } + if invite.RequestNeeded { + if err := s.recordPendingInviteRequestLocked(invite, req.UserID, req.Date); err != nil { + return domain.CreateChannelResult{}, err + } + return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent + } + return s.approveInviteImporterLocked(channel, invite, req.UserID, 0, req.Date) +} + +func (s *ChannelStore) ListExportedInvites(_ context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ChannelInviteList{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return domain.ChannelInviteList{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ChannelInviteList{}, domain.ErrChannelAdminRequired + } + all := make([]domain.ChannelInvite, 0) + for _, invite := range s.invites { + if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.AdminUserID && invite.Revoked == req.Revoked { + all = append(all, invite) + } + } + sort.Slice(all, func(i, j int) bool { + if all[i].Date != all[j].Date { + return all[i].Date > all[j].Date + } + return all[i].Hash > all[j].Hash + }) + total := len(all) + start := 0 + if req.OffsetDate > 0 || req.OffsetHash != "" { + start = len(all) + for i, invite := range all { + if invite.Date == req.OffsetDate && invite.Hash == req.OffsetHash { + start = i + 1 + break + } + } + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelInviteListLimit { + limit = domain.MaxChannelInviteListLimit + } + if start > len(all) { + start = len(all) + } + end := start + limit + if end > len(all) { + end = len(all) + } + return domain.ChannelInviteList{Count: total, Invites: cloneChannelInvites(all[start:end])}, nil +} + +func (s *ChannelStore) GetExportedInvite(_ context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.ChannelInvite{}, domain.ErrInviteHashEmpty + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return domain.ChannelInvite{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ChannelInvite{}, domain.ErrChannelAdminRequired + } + return s.inviteByChannelHashLocked(req.ChannelID, req.Hash) +} + +func (s *ChannelStore) EditExportedInvite(_ context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty + } + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return domain.EditChannelInviteResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.EditChannelInviteResult{}, domain.ErrChannelAdminRequired + } + invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) + if err != nil { + return domain.EditChannelInviteResult{}, err + } + if req.Revoked { + if invite.Revoked { + return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing + } + invite.Revoked = true + s.invites[invite.Hash] = invite + if !invite.Permanent { + return domain.EditChannelInviteResult{Invite: invite}, nil + } + newInvite, err := s.newReplacementInviteLocked(invite, req.Date) + if err != nil { + return domain.EditChannelInviteResult{}, err + } + s.invites[newInvite.Hash] = newInvite + return domain.EditChannelInviteResult{Invite: invite, NewInvite: &newInvite}, nil + } + if invite.Permanent && ((req.HasExpireDate && req.ExpireDate > 0) || (req.HasUsageLimit && req.UsageLimit > 0) || (req.HasRequestNeeded && req.RequestNeeded)) { + return domain.EditChannelInviteResult{}, domain.ErrInvitePermanent + } + if req.HasExpireDate { + invite.ExpireDate = req.ExpireDate + } + if req.HasUsageLimit { + invite.UsageLimit = req.UsageLimit + } + if req.HasRequestNeeded { + invite.RequestNeeded = req.RequestNeeded + } + if req.HasTitle { + invite.Title = req.Title + } + invite.Permanent = invite.ExpireDate == 0 && invite.UsageLimit == 0 && !invite.RequestNeeded && invite.Title == "" + s.invites[invite.Hash] = invite + return domain.EditChannelInviteResult{Invite: invite}, nil +} + +func (s *ChannelStore) DeleteExportedInvite(_ context.Context, req domain.DeleteChannelInviteRequest) error { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.ErrInviteHashEmpty + } + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) + if err != nil { + return err + } + delete(s.invites, invite.Hash) + return nil +} + +func (s *ChannelStore) DeleteRevokedExportedInvites(_ context.Context, req domain.DeleteRevokedChannelInvitesRequest) error { + if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { + limit = domain.MaxChannelHideJoinRequests + } + deleted := 0 + for hash, invite := range s.invites { + if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.AdminUserID && invite.Revoked { + delete(s.invites, hash) + deleted++ + if deleted >= limit { + break + } + } + } + return nil +} + +func (s *ChannelStore) ListAdminsWithInvites(_ context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { + if userID == 0 || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + return nil, err + } + member := s.members[channelID][userID] + if !canExportChannelInvite(member) { + return nil, domain.ErrChannelAdminRequired + } + byAdmin := map[int64]*domain.ChannelAdminInviteCount{} + for _, invite := range s.invites { + if invite.ChannelID != channelID { + continue + } + count := byAdmin[invite.AdminUserID] + if count == nil { + count = &domain.ChannelAdminInviteCount{AdminUserID: invite.AdminUserID} + byAdmin[invite.AdminUserID] = count + } + if invite.Revoked { + count.RevokedInvitesCount++ + } else { + count.InvitesCount++ + } + } + out := make([]domain.ChannelAdminInviteCount, 0, len(byAdmin)) + for _, count := range byAdmin { + out = append(out, *count) + } + sort.Slice(out, func(i, j int) bool { return out[i].AdminUserID < out[j].AdminUserID }) + return out, nil +} + +func (s *ChannelStore) ListInviteImporters(_ context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return domain.ChannelInviteImporterList{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ChannelInviteImporterList{}, domain.ErrChannelAdminRequired + } + var inviteID int64 + if req.Hash != "" { + invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) + if err != nil { + return domain.ChannelInviteImporterList{}, err + } + inviteID = invite.InviteID + } + if req.Query != "" { + return domain.ChannelInviteImporterList{}, nil + } + all := make([]domain.ChannelInviteImporter, 0) + for _, importer := range s.importers[req.ChannelID] { + if importer.Requested != req.Requested { + continue + } + if inviteID != 0 && importer.InviteID != inviteID { + continue + } + all = append(all, importer) + } + sort.Slice(all, func(i, j int) bool { + if all[i].Date != all[j].Date { + return all[i].Date > all[j].Date + } + return all[i].UserID > all[j].UserID + }) + total := len(all) + start := 0 + if req.OffsetDate > 0 || req.OffsetUserID != 0 { + start = len(all) + for i, importer := range all { + if importer.Date == req.OffsetDate && importer.UserID == req.OffsetUserID { + start = i + 1 + break + } + } + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelInviteListLimit { + limit = domain.MaxChannelInviteListLimit + } + if start > len(all) { + start = len(all) + } + end := start + limit + if end > len(all) { + end = len(all) + } + return domain.ChannelInviteImporterList{Count: total, Importers: cloneChannelInviteImporters(all[start:end])}, nil +} + +func (s *ChannelStore) PendingJoinRequests(_ context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { + if channelID == 0 { + return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid + } + all := make([]domain.ChannelInviteImporter, 0) + for _, importer := range s.importers[channelID] { + if importer.Requested { + all = append(all, importer) + } + } + sort.Slice(all, func(i, j int) bool { + if all[i].Date != all[j].Date { + return all[i].Date > all[j].Date + } + return all[i].UserID > all[j].UserID + }) + if limit <= 0 || limit > domain.MaxChannelPendingJoinRecentRequesters { + limit = domain.MaxChannelPendingJoinRecentRequesters + } + if len(all) < limit { + limit = len(all) + } + recent := make([]int64, 0, limit) + for _, importer := range all[:limit] { + recent = append(recent, importer.UserID) + } + return domain.ChannelPendingJoinRequests{ + ChannelID: channelID, + Count: len(all), + RecentRequesters: recent, + }, nil +} + +func (s *ChannelStore) HideChatJoinRequest(_ context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + importer, ok := s.importers[req.ChannelID][req.TargetUserID] + if !ok || !importer.Requested { + return domain.CreateChannelResult{}, domain.ErrHideRequesterMissing + } + invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} + if importer.InviteID != 0 { + var err error + invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID) + if err != nil { + return domain.CreateChannelResult{}, err + } + } + if !req.Approved { + s.deletePendingInviteImporterLocked(invite, req.TargetUserID) + return domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, req.TargetUserID, 0)}, nil + } + return s.approveInviteImporterLocked(channel, invite, req.TargetUserID, req.UserID, req.Date) +} + +func (s *ChannelStore) HideAllChatJoinRequests(_ context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + var inviteID int64 + if req.Hash != "" { + invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) + if err != nil { + return domain.CreateChannelResult{}, err + } + inviteID = invite.InviteID + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { + limit = domain.MaxChannelHideJoinRequests + } + targets := make([]domain.ChannelInviteImporter, 0, limit) + for _, importer := range s.importers[req.ChannelID] { + if !importer.Requested { + continue + } + if inviteID != 0 && importer.InviteID != inviteID { + continue + } + targets = append(targets, importer) + if len(targets) >= limit { + break + } + } + var result domain.CreateChannelResult + for _, importer := range targets { + invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} + if importer.InviteID != 0 { + var err error + invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID) + if err != nil { + return domain.CreateChannelResult{}, err + } + } + if !req.Approved { + s.deletePendingInviteImporterLocked(invite, importer.UserID) + result = domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, importer.UserID, 0)} + continue + } + result, err = s.approveInviteImporterLocked(channel, invite, importer.UserID, req.UserID, req.Date) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel = result.Channel + } + if result.Channel.ID == 0 { + result = domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0)} + } + return result, nil +} + +func (s *ChannelStore) approveInviteImporterLocked(channel domain.Channel, invite domain.ChannelInvite, userID, approvedBy int64, date int) (domain.CreateChannelResult, error) { + if invite.InviteID != 0 && invite.UsageLimit > 0 && invite.UsageCount >= invite.UsageLimit { + return domain.CreateChannelResult{}, domain.ErrUsersTooMuch + } + channelID := channel.ID + if channelID == 0 { + channelID = invite.ChannelID + } + if existing, ok := s.members[channelID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid + } + } + preJoinTopID := channel.TopMessageID + minID := channelInitialAvailableMinID(channel) + inviterID := invite.AdminUserID + if inviterID == 0 { + inviterID = approvedBy + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: inviterID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AvailableMinID: minID, + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: maxInt(minID, preJoinTopID), + } + if s.members[channelID] == nil { + s.members[channelID] = make(map[int64]domain.ChannelMember) + } + s.members[channelID][userID] = member + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantJoin, + }) + if importer, ok := s.importers[channelID][userID]; ok && importer.Requested { + if importer.InviteID == invite.InviteID { + if invite.InviteID != 0 && invite.RequestedCount > 0 { + invite.RequestedCount-- + } + } else if importer.InviteID != 0 { + if pendingInvite, err := s.inviteByIDLocked(channelID, importer.InviteID); err == nil && pendingInvite.RequestedCount > 0 { + pendingInvite.RequestedCount-- + s.invites[pendingInvite.Hash] = pendingInvite + } + } + } + if invite.InviteID != 0 && invite.Hash != "" { + invite.UsageCount++ + s.invites[invite.Hash] = invite + } + s.refreshChannelCountsLocked(channelID) + channel = s.channels[channelID] + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatJoined, + UserIDs: []int64{userID}, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[channelID] = channel + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) + if msg.ID != 0 { + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) + } + s.members[channelID][userID] = member + s.upsertChannelDialogLocked(userID, channel, msg, true) + if s.importers[channelID] == nil { + s.importers[channelID] = make(map[int64]domain.ChannelInviteImporter) + } + s.importers[channelID][userID] = domain.ChannelInviteImporter{ + ChannelID: channelID, + InviteID: invite.InviteID, + UserID: userID, + Date: date, + ApprovedBy: approvedBy, + } + return domain.CreateChannelResult{ + Channel: channel, + Members: []domain.ChannelMember{member}, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} + +func (s *ChannelStore) recordPublicJoinRequestLocked(channel domain.Channel, userID int64, date int) error { + if existing, ok := s.members[channel.ID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + return domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.ErrInviteHashInvalid + } + } + if s.importers[channel.ID] == nil { + s.importers[channel.ID] = make(map[int64]domain.ChannelInviteImporter) + } + if existing, ok := s.importers[channel.ID][userID]; ok && existing.Requested { + return domain.ErrInviteRequestSent + } + s.importers[channel.ID][userID] = domain.ChannelInviteImporter{ + ChannelID: channel.ID, + UserID: userID, + Date: date, + Requested: true, + } + return nil +} + +func (s *ChannelStore) recordPendingInviteRequestLocked(invite domain.ChannelInvite, userID int64, date int) error { + if existing, ok := s.members[invite.ChannelID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + return domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.ErrInviteHashInvalid + } + } + if s.importers[invite.ChannelID] == nil { + s.importers[invite.ChannelID] = make(map[int64]domain.ChannelInviteImporter) + } + if existing, ok := s.importers[invite.ChannelID][userID]; ok && existing.Requested { + return domain.ErrInviteRequestSent + } + s.importers[invite.ChannelID][userID] = domain.ChannelInviteImporter{ + ChannelID: invite.ChannelID, + InviteID: invite.InviteID, + UserID: userID, + Date: date, + Requested: true, + } + invite.RequestedCount++ + s.invites[invite.Hash] = invite + return nil +} + +func (s *ChannelStore) deletePendingInviteImporterLocked(invite domain.ChannelInvite, userID int64) { + if existing, ok := s.importers[invite.ChannelID][userID]; ok && existing.Requested { + delete(s.importers[invite.ChannelID], userID) + if invite.InviteID != 0 && invite.Hash != "" && invite.RequestedCount > 0 { + invite.RequestedCount-- + s.invites[invite.Hash] = invite + } + } +} + +func (s *ChannelStore) inviteByChannelHashLocked(channelID int64, hash string) (domain.ChannelInvite, error) { + hash = strings.TrimSpace(hash) + if hash == "" { + return domain.ChannelInvite{}, domain.ErrInviteHashEmpty + } + invite, ok := s.invites[hash] + if !ok || invite.ChannelID != channelID { + return domain.ChannelInvite{}, domain.ErrInviteHashInvalid + } + return invite, nil +} + +func (s *ChannelStore) inviteByIDLocked(channelID, inviteID int64) (domain.ChannelInvite, error) { + for _, invite := range s.invites { + if invite.ChannelID == channelID && invite.InviteID == inviteID { + return invite, nil + } + } + return domain.ChannelInvite{}, domain.ErrInviteHashInvalid +} + +func (s *ChannelStore) newReplacementInviteLocked(old domain.ChannelInvite, date int) (domain.ChannelInvite, error) { + inviteID, err := randomMemoryPositiveInt64() + if err != nil { + return domain.ChannelInvite{}, err + } + hash, err := randomMemoryInviteHash() + if err != nil { + return domain.ChannelInvite{}, err + } + if date == 0 { + date = int(time.Now().Unix()) + } + return domain.ChannelInvite{ + ChannelID: old.ChannelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: old.AdminUserID, + Permanent: old.Permanent, + Date: date, + }, nil +} + +func cloneChannelInvites(in []domain.ChannelInvite) []domain.ChannelInvite { + if len(in) == 0 { + return nil + } + out := make([]domain.ChannelInvite, len(in)) + copy(out, in) + return out +} + +func cloneChannelInviteImporters(in []domain.ChannelInviteImporter) []domain.ChannelInviteImporter { + if len(in) == 0 { + return nil + } + out := make([]domain.ChannelInviteImporter, len(in)) + copy(out, in) + return out +} + +func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { + if viewerUserID == 0 { + return domain.ChannelDialogList{}, nil + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + s.mu.RLock() + defer s.mu.RUnlock() + + channelIDs := make([]int64, 0, len(s.dialogs[viewerUserID])) + seen := make(map[int64]struct{}, len(s.dialogs[viewerUserID])) + for channelID := range s.dialogs[viewerUserID] { + channelIDs = append(channelIDs, channelID) + seen[channelID] = struct{}{} + } + for channelID, members := range s.members { + if _, ok := seen[channelID]; ok { + continue + } + if member, ok := members[viewerUserID]; ok && member.Status == domain.ChannelMemberActive { + channelIDs = append(channelIDs, channelID) + seen[channelID] = struct{}{} + } + } + + items := make([]domain.Dialog, 0, len(channelIDs)) + for _, channelID := range channelIDs { + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + continue + } + if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { + continue + } + item := channelDialogToDialog(s.dialogForUserLocked(viewerUserID, channel)) + if !channelDialogMatchesFilter(item, channel, filter) { + continue + } + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Pinned != items[j].Pinned { + return items[i].Pinned + } + if items[i].PinnedOrder != items[j].PinnedOrder { + return items[i].PinnedOrder > items[j].PinnedOrder + } + if items[i].TopMessageDate != items[j].TopMessageDate { + return items[i].TopMessageDate > items[j].TopMessageDate + } + if items[i].TopMessage != items[j].TopMessage { + return items[i].TopMessage > items[j].TopMessage + } + return items[i].Peer.ID > items[j].Peer.ID + }) + out := domain.ChannelDialogList{Count: len(items)} + for _, dialog := range items { + if len(out.Dialogs) >= limit { + break + } + out.Dialogs = append(out.Dialogs, dialog) + channel := s.channels[dialog.Peer.ID] + out.Channels = append(out.Channels, channel) + if msg, ok := s.findMessageLocked(dialog.Peer.ID, dialog.TopMessage); ok && !msg.Deleted { + out.Messages = append(out.Messages, cloneChannelMessage(msg)) + } + } + return out, nil +} + +func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { + if viewerUserID == 0 || len(channelIDs) == 0 { + return domain.ChannelDialogList{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := domain.ChannelDialogList{} + seen := make(map[int64]struct{}, len(channelIDs)) + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + if _, ok := seen[channelID]; ok { + continue + } + seen[channelID] = struct{}{} + channel, err := s.channelForMemberLocked(viewerUserID, channelID) + if err != nil { + continue + } + dialog := channelDialogToDialog(s.dialogForUserLocked(viewerUserID, channel)) + out.Dialogs = append(out.Dialogs, dialog) + out.Channels = append(out.Channels, channel) + if msg, ok := s.findMessageLocked(channelID, dialog.TopMessage); ok && !msg.Deleted { + out.Messages = append(out.Messages, cloneChannelMessage(msg)) + } + } + out.Count = len(out.Dialogs) + return out, nil +} + +func (s *ChannelStore) ListCommonChannels(_ context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { + if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { + return domain.CommonChannelsResult{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxCommonChannelsLimit { + limit = domain.MaxCommonChannelsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + ids := make([]int64, 0) + for channelID, members := range s.members { + self, selfOK := members[req.UserID] + target, targetOK := members[req.TargetUserID] + if !selfOK || !targetOK || self.Status != domain.ChannelMemberActive || target.Status != domain.ChannelMemberActive { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || !channel.Megagroup || channel.Broadcast { + continue + } + ids = append(ids, channelID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + out := domain.CommonChannelsResult{Count: len(ids)} + if req.CountOnly { + return out, nil + } + for _, channelID := range ids { + if req.MaxID > 0 && channelID <= req.MaxID { + continue + } + out.Channels = append(out.Channels, cloneChannel(s.channels[channelID])) + if len(out.Channels) >= limit { + break + } + } + return out, nil +} + +func (s *ChannelStore) ListLeftChannels(_ context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { + if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { + return domain.LeftChannelsResult{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxLeftChannelsLimit { + limit = domain.MaxLeftChannelsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + all := make([]domain.LeftChannel, 0) + for channelID, members := range s.members { + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberLeft { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { + continue + } + all = append(all, domain.LeftChannel{ + Channel: cloneChannel(channel), + Self: member, + }) + } + sort.Slice(all, func(i, j int) bool { + if all[i].Self.LeftAt != all[j].Self.LeftAt { + return all[i].Self.LeftAt > all[j].Self.LeftAt + } + return all[i].Channel.ID > all[j].Channel.ID + }) + + out := domain.LeftChannelsResult{Count: len(all)} + if offset >= len(all) { + return out, nil + } + end := offset + limit + if end > len(all) { + end = len(all) + } + out.Channels = append(out.Channels, all[offset:end]...) + return out, nil +} + +func (s *ChannelStore) ListInactiveChannels(_ context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { + if userID == 0 { + return domain.ChannelDialogList{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxInactiveChannelsLimit { + limit = domain.MaxInactiveChannelsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + type item struct { + channel domain.Channel + dialog domain.Dialog + } + items := make([]item, 0, limit) + for channelID, members := range s.members { + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberActive { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { + continue + } + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + continue + } + dialog := channelDialogToDialog(s.dialogForUserLocked(userID, channel)) + dialog.TopMessageDate = inactiveChannelDate(dialog, channel, member) + items = append(items, item{channel: cloneChannel(channel), dialog: dialog}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate { + return items[i].dialog.TopMessageDate < items[j].dialog.TopMessageDate + } + if items[i].dialog.TopMessage != items[j].dialog.TopMessage { + return items[i].dialog.TopMessage < items[j].dialog.TopMessage + } + return items[i].channel.ID < items[j].channel.ID + }) + if len(items) > limit { + items = items[:limit] + } + out := domain.ChannelDialogList{Count: len(items)} + for _, item := range items { + out.Dialogs = append(out.Dialogs, item.dialog) + out.Channels = append(out.Channels, item.channel) + } + return out, nil +} + +func (s *ChannelStore) ListChannelRecommendations(_ context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { + if req.UserID == 0 || req.SourceChannelID < 0 { + return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit { + limit = domain.DefaultChannelRecommendationsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := make([]domain.Channel, 0, limit) + for channelID, channel := range s.channels { + if !recommendableChannel(channel) || channelID == req.SourceChannelID { + continue + } + if req.SourceChannelID == 0 { + if member, ok := s.members[channelID][req.UserID]; ok && member.Status == domain.ChannelMemberActive { + continue + } + } + items = append(items, cloneChannel(channel)) + } + sort.Slice(items, func(i, j int) bool { + if items[i].ParticipantsCount != items[j].ParticipantsCount { + return items[i].ParticipantsCount > items[j].ParticipantsCount + } + if items[i].Date != items[j].Date { + return items[i].Date > items[j].Date + } + return items[i].ID > items[j].ID + }) + out := domain.ChannelRecommendationsResult{Count: len(items)} + if len(items) > limit { + items = items[:limit] + } + out.Channels = append(out.Channels, items...) + return out, nil +} + +func (s *ChannelStore) ListDiscussionGroups(_ context.Context, userID int64, limit int) ([]domain.Channel, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit { + limit = domain.MaxDiscussionGroupsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := make([]domain.Channel, 0, limit) + for channelID, channel := range s.channels { + if !validDiscussionGroup(channel) || channel.Deleted { + continue + } + member := s.members[channelID][userID] + if member.Status != domain.ChannelMemberActive || !canManageDiscussionGroup(member) { + continue + } + items = append(items, cloneChannel(channel)) + } + sort.Slice(items, func(i, j int) bool { + return items[i].ID > items[j].ID + }) + if len(items) > limit { + items = items[:limit] + } + return items, nil +} + +func (s *ChannelStore) SetDiscussionGroup(_ context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { + if userID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid + } + if broadcastID == 0 && groupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + s.mu.Lock() + defer s.mu.Unlock() + + changed := make(map[int64]domain.Channel) + markChanged := func(channel domain.Channel) { + if channel.ID != 0 { + changed[channel.ID] = cloneChannel(channel) + } + } + setLinked := func(channelID, linkedID int64) (domain.Channel, bool) { + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, false + } + if channel.LinkedChatID == linkedID { + return channel, true + } + channel.LinkedChatID = linkedID + s.channels[channelID] = channel + markChanged(channel) + return channel, true + } + + if broadcastID == 0 { + group, groupMember, err := s.channelAndMemberLocked(userID, groupID) + if err != nil || !validDiscussionGroup(group) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid + } + if !canManageDiscussionGroup(groupMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + oldBroadcastID := group.LinkedChatID + if oldBroadcastID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + if oldBroadcast, ok := s.channels[oldBroadcastID]; ok && oldBroadcast.LinkedChatID == groupID { + if updated, ok := setLinked(oldBroadcastID, 0); ok { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: updated.ID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(groupID), + NewInt: 0, + }) + } + } + setLinked(groupID, 0) + return discussionGroupUpdateResult(changed), nil + } + + broadcast, broadcastMember, err := s.channelAndMemberLocked(userID, broadcastID) + if err != nil || !broadcast.Broadcast || broadcast.Megagroup { + return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid + } + if !canManageDiscussionBroadcast(broadcastMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + oldGroupID := broadcast.LinkedChatID + if groupID == 0 { + if oldGroupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + updated, _ := setLinked(broadcastID, 0) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: updated.ID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(oldGroupID), + NewInt: 0, + }) + if oldGroup, ok := s.channels[oldGroupID]; ok && oldGroup.LinkedChatID == broadcastID { + setLinked(oldGroupID, 0) + } + return discussionGroupUpdateResult(changed), nil + } + + group, groupMember, err := s.channelAndMemberLocked(userID, groupID) + if err != nil || !validDiscussionGroup(group) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid + } + if group.PreHistoryHidden { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden + } + if !canManageDiscussionGroup(groupMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + if oldGroupID == groupID && group.LinkedChatID == broadcastID { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + oldBroadcastID := group.LinkedChatID + if oldGroupID != 0 && oldGroupID != groupID { + if oldGroup, ok := s.channels[oldGroupID]; ok && oldGroup.LinkedChatID == broadcastID { + setLinked(oldGroupID, 0) + } + } + if oldBroadcastID != 0 && oldBroadcastID != broadcastID { + if oldBroadcast, ok := s.channels[oldBroadcastID]; ok && oldBroadcast.LinkedChatID == groupID { + if updated, ok := setLinked(oldBroadcastID, 0); ok { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: updated.ID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(groupID), + NewInt: 0, + }) + } + } + } + updatedBroadcast, _ := setLinked(broadcastID, groupID) + setLinked(groupID, broadcastID) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: updatedBroadcast.ID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(oldGroupID), + NewInt: int(groupID), + }) + return discussionGroupUpdateResult(changed), nil +} + +func (s *ChannelStore) SetChannelDialogPinned(_ context.Context, userID, channelID int64, pinned bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return false, nil + } + nextOrder := 1 + for _, dialog := range s.dialogs[userID] { + if dialog.Pinned && dialog.PinnedOrder >= nextOrder { + nextOrder = dialog.PinnedOrder + 1 + } + } + dialog := s.dialogForUserLocked(userID, channel) + changed := dialog.Pinned != pinned || (pinned && dialog.PinnedOrder == 0) + dialog.Pinned = pinned + if pinned { + if dialog.PinnedOrder == 0 { + dialog.PinnedOrder = nextOrder + } + } else { + dialog.PinnedOrder = 0 + } + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + return changed, nil +} + +func (s *ChannelStore) ReorderChannelPinnedDialogs(_ context.Context, userID int64, order []domain.Peer, force bool) error { + if userID == 0 { + return nil + } + positions := make(map[int64]int, len(order)) + for i, peer := range order { + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + continue + } + if _, ok := positions[peer.ID]; ok { + continue + } + positions[peer.ID] = len(order) - i + } + s.mu.Lock() + defer s.mu.Unlock() + for channelID, dialog := range s.dialogs[userID] { + if pos, ok := positions[channelID]; ok { + dialog.Pinned = true + dialog.PinnedOrder = pos + s.dialogs[userID][channelID] = dialog + continue + } + if force && dialog.Pinned { + dialog.Pinned = false + dialog.PinnedOrder = 0 + s.dialogs[userID][channelID] = dialog + } + } + return nil +} + +func (s *ChannelStore) SetChannelDialogUnreadMark(_ context.Context, userID, channelID int64, unread bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return false, nil + } + dialog := s.dialogForUserLocked(userID, channel) + changed := dialog.UnreadMark != unread + dialog.UnreadMark = unread + member := s.members[channelID][userID] + member.UnreadMark = unread + s.members[channelID][userID] = member + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + return changed, nil +} + +func (s *ChannelStore) SetChannelViewForumAsMessages(_ context.Context, userID, channelID int64, enabled bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return false, nil + } + dialog := s.dialogForUserLocked(userID, channel) + changed := dialog.ViewForumAsMessages != enabled + dialog.ViewForumAsMessages = enabled + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + return changed, nil +} + +func (s *ChannelStore) ListChannelUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) { + if userID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Peer, 0, len(s.dialogs[userID])) + for channelID, dialog := range s.dialogs[userID] { + if !dialog.UnreadMark { + continue + } + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + continue + } + out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (s *ChannelStore) EditChannelPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + if userID == 0 || len(peers) == 0 { + return nil + } + updates := make(map[int64]int, len(peers)) + for _, item := range peers { + if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { + continue + } + if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { + continue + } + updates[item.Peer.ID] = item.FolderID + } + if len(updates) == 0 { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + for channelID, folderID := range updates { + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + continue + } + dialog := s.dialogForUserLocked(userID, channel) + dialog.FolderID = folderID + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + } + return nil +} + +func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + items := append([]domain.ChannelMessage(nil), s.messages[filter.ChannelID]...) + sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID }) + out := make([]domain.ChannelMessage, 0, limit) + query := strings.ToLower(strings.TrimSpace(filter.Query)) + matched := 0 + for _, msg := range items { + if msg.Deleted { + continue + } + if msg.ID <= member.AvailableMinID { + continue + } + if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { + continue + } + if filter.SenderUserID != 0 && msg.SenderUserID != filter.SenderUserID { + continue + } + if filter.MinDate > 0 && msg.Date <= filter.MinDate { + continue + } + if filter.MaxDate > 0 && msg.Date >= filter.MaxDate { + continue + } + if filter.OffsetID > 0 && msg.ID >= filter.OffsetID { + continue + } + if filter.OffsetID <= 0 && filter.OffsetDate > 0 && msg.Date >= filter.OffsetDate { + continue + } + if filter.MaxID > 0 && msg.ID > filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + matched++ + if len(out) < limit { + out = append(out, cloneChannelMessage(msg)) + } + } + s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) + s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) + return domain.ChannelHistory{ + Channel: channel, + Self: member, + Messages: out, + Count: matched, + }, nil +} + +func (s *ChannelStore) SearchPublicPosts(_ context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { + query := strings.ToLower(strings.TrimSpace(req.Query)) + hashtag := strings.ToLower(strings.TrimSpace(req.Hashtag)) + if (query == "") == (hashtag == "") { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxChannelSearchPostsLimit { + req.Limit = domain.MaxChannelSearchPostsLimit + } + type hit struct { + channel domain.Channel + message domain.ChannelMessage + } + s.mu.RLock() + defer s.mu.RUnlock() + hits := make([]hit, 0, req.Limit+1) + for channelID, channel := range s.channels { + if channel.Deleted || strings.TrimSpace(channel.Username) == "" { + continue + } + for _, msg := range s.messages[channelID] { + if msg.Deleted || strings.TrimSpace(msg.Body) == "" { + continue + } + if !channelSearchPostAfterCursor(msg, req) { + continue + } + body := strings.ToLower(msg.Body) + if query != "" && !strings.Contains(body, query) { + continue + } + if hashtag != "" && !strings.Contains(body, "#"+hashtag) { + continue + } + hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)}) + } + } + sort.Slice(hits, func(i, j int) bool { + a, b := hits[i].message, hits[j].message + if a.Date != b.Date { + return a.Date > b.Date + } + if a.ChannelID != b.ChannelID { + return a.ChannelID > b.ChannelID + } + return a.ID > b.ID + }) + out := domain.ChannelHistory{Count: len(hits)} + if out.Count > req.Limit { + out.Count = req.Limit + 1 + hits = hits[:req.Limit] + } + channelSeen := make(map[int64]struct{}, len(hits)) + for _, h := range hits { + out.Messages = append(out.Messages, h.message) + if _, ok := channelSeen[h.channel.ID]; ok { + continue + } + channelSeen[h.channel.ID] = struct{}{} + out.Channels = append(out.Channels, h.channel) + } + s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages) + return out, nil +} + +func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { + query := strings.ToLower(strings.TrimSpace(req.Query)) + if viewerUserID == 0 || query == "" { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxChannelGlobalSearchLimit { + req.Limit = domain.MaxChannelGlobalSearchLimit + } + type hit struct { + channel domain.Channel + message domain.ChannelMessage + } + s.mu.RLock() + defer s.mu.RUnlock() + hits := make([]hit, 0, req.Limit+1) + for channelID, channel := range s.channels { + if channel.Deleted { + continue + } + if req.BroadcastsOnly && (!channel.Broadcast || channel.Megagroup) { + continue + } + if req.GroupsOnly && !channel.Megagroup { + continue + } + member, ok := s.members[channelID][viewerUserID] + if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { + continue + } + if req.HasFolderID { + dialog, ok := s.dialogs[viewerUserID][channelID] + if !ok || dialog.FolderID != req.FolderID { + continue + } + } + for _, msg := range s.messages[channelID] { + if msg.Deleted || strings.TrimSpace(msg.Body) == "" { + continue + } + if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID { + continue + } + if req.MinDate > 0 && msg.Date <= req.MinDate { + continue + } + if req.MaxDate > 0 && msg.Date >= req.MaxDate { + continue + } + if !channelGlobalSearchAfterCursor(msg, req) { + continue + } + if !strings.Contains(strings.ToLower(msg.Body), query) { + continue + } + hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)}) + } + } + sort.Slice(hits, func(i, j int) bool { + a, b := hits[i].message, hits[j].message + if a.Date != b.Date { + return a.Date > b.Date + } + if a.ChannelID != b.ChannelID { + return a.ChannelID > b.ChannelID + } + return a.ID > b.ID + }) + out := domain.ChannelHistory{Count: len(hits)} + if out.Count > req.Limit { + out.Count = req.Limit + 1 + hits = hits[:req.Limit] + } + channelSeen := make(map[int64]struct{}, len(hits)) + for _, h := range hits { + out.Messages = append(out.Messages, h.message) + if _, ok := channelSeen[h.channel.ID]; ok { + continue + } + channelSeen[h.channel.ID] = struct{}{} + out.Channels = append(out.Channels, h.channel) + } + s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages) + return out, nil +} + +func channelSearchPostAfterCursor(msg domain.ChannelMessage, req domain.ChannelSearchPostsRequest) bool { + if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 { + return true + } + if req.OffsetRate > 0 { + if msg.Date < req.OffsetRate { + return true + } + if msg.Date > req.OffsetRate { + return false + } + } + if req.OffsetChannelID > 0 { + if msg.ChannelID < req.OffsetChannelID { + return true + } + if msg.ChannelID > req.OffsetChannelID { + return false + } + } + if req.OffsetID > 0 { + return msg.ID < req.OffsetID + } + return false +} + +func channelGlobalSearchAfterCursor(msg domain.ChannelMessage, req domain.ChannelGlobalSearchRequest) bool { + if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 { + return true + } + if req.OffsetRate > 0 { + if msg.Date < req.OffsetRate { + return true + } + if msg.Date > req.OffsetRate { + return false + } + } + if req.OffsetChannelID > 0 { + if msg.ChannelID < req.OffsetChannelID { + return true + } + if msg.ChannelID > req.OffsetChannelID { + return false + } + } + if req.OffsetID > 0 { + return msg.ID < req.OffsetID + } + return false +} + +func (s *ChannelStore) GetChannelMessages(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelHistory{}, err + } + if len(ids) == 0 { + return domain.ChannelHistory{Channel: channel, Self: member}, nil + } + if len(ids) > domain.MaxGetMessageIDs { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + wanted := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + messages := make([]domain.ChannelMessage, 0, len(wanted)) + for _, msg := range s.messages[channelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + messages = append(messages, cloneChannelMessage(msg)) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + s.populateChannelMessageRepliesLocked(viewerUserID, channelID, messages) + s.populateChannelMessageReactionsLocked(viewerUserID, channel, messages) + return domain.ChannelHistory{Channel: channel, Self: member, Messages: messages, Count: len(messages)}, nil +} + +func (s *ChannelStore) ReadChannelMessageContents(_ context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if len(req.IDs) == 0 { + return domain.ReadChannelMessageContentsResult{Channel: channel}, nil + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + wanted := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReadChannelMessageContentsResult{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + messages := make([]domain.ChannelMessage, 0, len(wanted)) + for _, msg := range s.messages[req.ChannelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + messages = append(messages, cloneChannelMessage(msg)) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + clearedSet := make(map[int]struct{}) + for _, msg := range messages { + byUser := s.reactions[req.ChannelID][msg.ID] + if len(byUser) == 0 { + continue + } + for reactedUserID, rows := range byUser { + changed := false + for i := range rows { + if rows[i].SenderUserID == req.UserID && rows[i].UserID != req.UserID && rows[i].Unread { + rows[i].Unread = false + changed = true + clearedSet[msg.ID] = struct{}{} + } + } + if changed { + byUser[reactedUserID] = rows + } + } + } + cleared := make([]int, 0, len(clearedSet)) + for id := range clearedSet { + cleared = append(cleared, id) + } + sort.Sort(sort.Reverse(sort.IntSlice(cleared))) + if len(cleared) > 0 { + s.refreshChannelUnreadReactionsDialogLocked(req.UserID, req.ChannelID) + } + s.populateChannelMessageRepliesLocked(req.UserID, req.ChannelID, messages) + s.populateChannelMessageReactionsLocked(req.UserID, channel, messages) + return domain.ReadChannelMessageContentsResult{ + Channel: channel, + Messages: messages, + ClearedUnreadReactionMessageIDs: cleared, + }, nil +} + +func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) == 0 { + return domain.ChannelMessageViewsResult{Views: map[int]int{}}, nil + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + _, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageViewsResult{}, err + } + wanted := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelMessageViewsResult{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + visible := make(map[int]struct{}, len(wanted)) + for _, msg := range s.messages[req.ChannelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + visible[msg.ID] = struct{}{} + } + if s.msgViews[req.ChannelID] == nil { + s.msgViews[req.ChannelID] = make(map[int]int) + } + if s.msgViewers[req.ChannelID] == nil { + s.msgViewers[req.ChannelID] = make(map[int]map[int64]struct{}) + } + for id := range visible { + if req.Increment { + if s.msgViewers[req.ChannelID][id] == nil { + s.msgViewers[req.ChannelID][id] = make(map[int64]struct{}) + } + if _, seen := s.msgViewers[req.ChannelID][id][req.UserID]; !seen { + s.msgViewers[req.ChannelID][id][req.UserID] = struct{}{} + s.msgViews[req.ChannelID][id]++ + } + } + } + out := make(map[int]int, len(visible)) + for id := range visible { + out[id] = s.msgViews[req.ChannelID][id] + } + return domain.ChannelMessageViewsResult{Views: out}, nil +} + +func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + idx, ok := s.findMessageIndexLocked(req.ChannelID, req.MessageID) + if !ok { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + msg := s.messages[req.ChannelID][idx] + if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if s.reactions[req.ChannelID] == nil { + s.reactions[req.ChannelID] = make(map[int]map[int64][]domain.ChannelMessagePeerReaction) + } + if s.reactions[req.ChannelID][req.MessageID] == nil { + s.reactions[req.ChannelID][req.MessageID] = make(map[int64][]domain.ChannelMessagePeerReaction) + } + if len(req.Reactions) == 0 { + delete(s.reactions[req.ChannelID][req.MessageID], req.UserID) + } else { + rows := make([]domain.ChannelMessagePeerReaction, 0, len(req.Reactions)) + for i, reaction := range req.Reactions { + rows = append(rows, domain.ChannelMessagePeerReaction{ + ChannelID: req.ChannelID, + MessageID: req.MessageID, + SenderUserID: msg.SenderUserID, + UserID: req.UserID, + Reaction: reaction, + Big: req.Big, + Unread: msg.SenderUserID != 0 && msg.SenderUserID != req.UserID, + ChosenOrder: i + 1, + Date: req.Date, + }) + } + s.reactions[req.ChannelID][req.MessageID][req.UserID] = rows + if s.top[req.UserID] == nil { + s.top[req.UserID] = make(map[string]domain.TopMessageReaction) + } + for _, reaction := range req.Reactions { + key := messageReactionKey(reaction) + row := s.top[req.UserID][key] + row.UserID = req.UserID + row.Reaction = reaction + row.Count++ + row.Date = req.Date + s.top[req.UserID][key] = row + } + if req.AddToRecent { + if s.recent[req.UserID] == nil { + s.recent[req.UserID] = make(map[string]domain.RecentMessageReaction) + } + for _, reaction := range req.Reactions { + s.recent[req.UserID][messageReactionKey(reaction)] = domain.RecentMessageReaction{ + UserID: req.UserID, + Reaction: reaction, + Date: req.Date, + } + } + } + } + s.refreshChannelUnreadReactionsDialogLocked(msg.SenderUserID, req.ChannelID) + reactions := s.channelMessageReactionsLocked(req.UserID, channel, req.MessageID) + msg = cloneChannelMessage(msg) + msg.Reactions = cloneChannelMessageReactionsPtr(&reactions) + return domain.ChannelMessageReactionsResult{ + Channel: cloneChannel(channel), + Message: msg, + Messages: []domain.ChannelMessage{msg}, + Reactions: cloneChannelMessageReactions(reactions), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) DeleteChannelParticipantReaction(_ context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelAdminRequired + } + msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if s.reactions[req.ChannelID] != nil && s.reactions[req.ChannelID][req.MessageID] != nil { + delete(s.reactions[req.ChannelID][req.MessageID], req.ParticipantUserID) + } + s.refreshChannelUnreadReactionsDialogLocked(msg.SenderUserID, req.ChannelID) + reactions := s.channelMessageReactionsLocked(req.UserID, channel, req.MessageID) + outMsg := cloneChannelMessage(msg) + outMsg.Reactions = cloneChannelMessageReactionsPtr(&reactions) + return domain.ChannelMessageReactionsResult{ + Channel: cloneChannel(channel), + Message: outMsg, + Messages: []domain.ChannelMessage{outMsg}, + Reactions: cloneChannelMessageReactions(reactions), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) DeleteChannelParticipantReactions(_ context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { + req.Limit = domain.MaxDeleteParticipantReactionsBatch + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelAdminRequired + } + type reactionMsg struct { + id int + sender int64 + date int + } + candidates := make([]reactionMsg, 0) + for msgID, byUser := range s.reactions[req.ChannelID] { + rows := byUser[req.ParticipantUserID] + if len(rows) == 0 { + continue + } + msg, ok := s.findMessageLocked(req.ChannelID, msgID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + item := reactionMsg{id: msgID, sender: msg.SenderUserID} + for _, row := range rows { + if row.Date > item.date { + item.date = row.Date + } + } + candidates = append(candidates, item) + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].date != candidates[j].date { + return candidates[i].date > candidates[j].date + } + return candidates[i].id > candidates[j].id + }) + if len(candidates) > req.Limit { + candidates = candidates[:req.Limit] + } + owners := make(map[int64]struct{}) + ids := make([]int, 0, len(candidates)) + for _, item := range candidates { + if s.reactions[req.ChannelID] != nil && s.reactions[req.ChannelID][item.id] != nil { + delete(s.reactions[req.ChannelID][item.id], req.ParticipantUserID) + } + if item.sender != 0 { + owners[item.sender] = struct{}{} + } + ids = append(ids, item.id) + } + for ownerID := range owners { + s.refreshChannelUnreadReactionsDialogLocked(ownerID, req.ChannelID) + } + messages := make([]domain.ChannelMessage, 0, len(ids)) + for _, id := range ids { + msg, ok := s.findMessageLocked(req.ChannelID, id) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + reactions := s.channelMessageReactionsLocked(req.UserID, channel, id) + outMsg := cloneChannelMessage(msg) + outMsg.Reactions = cloneChannelMessageReactionsPtr(&reactions) + messages = append(messages, outMsg) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + return domain.DeleteChannelParticipantReactionsResult{ + Channel: cloneChannel(channel), + Messages: messages, + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Deleted: len(ids), + }, nil +} + +func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + wanted := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + messages := make([]domain.ChannelMessage, 0, len(wanted)) + for _, msg := range s.messages[req.ChannelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + item := cloneChannelMessage(msg) + reactions := s.channelMessageReactionsLocked(req.UserID, channel, msg.ID) + item.Reactions = cloneChannelMessageReactionsPtr(&reactions) + messages = append(messages, item) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + res := domain.ChannelMessageReactionsResult{ + Channel: cloneChannel(channel), + Messages: messages, + } + if len(messages) == 1 { + res.Message = messages[0] + if messages[0].Reactions != nil { + res.Reactions = cloneChannelMessageReactions(*messages[0].Reactions) + } + } + return res, nil +} + +func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { + req.Limit = domain.MaxChannelMessageReactionListLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsList{}, err + } + if channel.Broadcast && !channel.Megagroup { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden + } + msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid + } + rows := s.channelMessageReactionRowsLocked(req.ChannelID, req.MessageID, req.UserID, req.Reaction) + sort.Slice(rows, func(i, j int) bool { + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + if rows[i].UserID != rows[j].UserID { + return rows[i].UserID > rows[j].UserID + } + return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon + }) + total := len(rows) + if req.Offset != "" { + if offset, ok := parseMemoryReactionOffset(req.Offset); ok { + filtered := rows[:0] + for _, row := range rows { + if memoryReactionAfterOffset(row, offset) { + filtered = append(filtered, row) + } + } + rows = filtered + } + } + next := "" + if len(rows) > req.Limit { + rows = rows[:req.Limit] + next = memoryReactionOffset(rows[len(rows)-1]) + } + return domain.ChannelMessageReactionsList{ + Channel: cloneChannel(channel), + Message: cloneChannelMessage(msg), + Count: total, + Reactions: cloneChannelPeerReactions(rows), + NextOffset: next, + }, nil +} + +func (s *ChannelStore) RecordMessageReactionUse(_ context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { + if userID == 0 || len(reactions) == 0 { + return nil + } + if date == 0 { + date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.top[userID] == nil { + s.top[userID] = make(map[string]domain.TopMessageReaction) + } + if addToRecent && s.recent[userID] == nil { + s.recent[userID] = make(map[string]domain.RecentMessageReaction) + } + for _, reaction := range reactions { + if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { + continue + } + key := messageReactionKey(reaction) + row := s.top[userID][key] + row.UserID = userID + row.Reaction = reaction + row.Count++ + row.Date = date + s.top[userID][key] = row + if addToRecent { + s.recent[userID][key] = domain.RecentMessageReaction{ + UserID: userID, + Reaction: reaction, + Date: date, + } + } + } + return nil +} + +func (s *ChannelStore) ListTopMessageReactions(_ context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxTopMessageReactions { + limit = domain.MaxTopMessageReactions + } + s.mu.RLock() + defer s.mu.RUnlock() + rows := make([]domain.TopMessageReaction, 0, len(s.top[userID])) + for _, row := range s.top[userID] { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Count != rows[j].Count { + return rows[i].Count > rows[j].Count + } + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + if rows[i].Reaction.Type != rows[j].Reaction.Type { + return rows[i].Reaction.Type < rows[j].Reaction.Type + } + return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon + }) + if len(rows) > limit { + rows = rows[:limit] + } + out := make([]domain.MessageReaction, 0, len(rows)) + for _, row := range rows { + out = append(out, row.Reaction) + } + return out, nil +} + +func (s *ChannelStore) ListRecentMessageReactions(_ context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxRecentMessageReactions { + limit = domain.MaxRecentMessageReactions + } + s.mu.RLock() + defer s.mu.RUnlock() + rows := make([]domain.RecentMessageReaction, 0, len(s.recent[userID])) + for _, row := range s.recent[userID] { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + if rows[i].Reaction.Type != rows[j].Reaction.Type { + return rows[i].Reaction.Type < rows[j].Reaction.Type + } + return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon + }) + if len(rows) > limit { + rows = rows[:limit] + } + out := make([]domain.MessageReaction, 0, len(rows)) + for _, row := range rows { + out = append(out, row.Reaction) + } + return out, nil +} + +func (s *ChannelStore) ClearRecentMessageReactions(_ context.Context, userID int64) error { + if userID == 0 { + return domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.recent, userID) + return nil +} + +func (s *ChannelStore) ListSavedReactionTags(_ context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.SavedReactionTag{}, nil + } + if limit > domain.MaxSavedReactionTags { + limit = domain.MaxSavedReactionTags + } + s.mu.RLock() + defer s.mu.RUnlock() + rows := make([]domain.SavedReactionTag, 0, len(s.savedTags[userID])) + for _, row := range s.savedTags[userID] { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Count != rows[j].Count { + return rows[i].Count > rows[j].Count + } + if rows[i].Reaction.Type != rows[j].Reaction.Type { + return rows[i].Reaction.Type < rows[j].Reaction.Type + } + return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon + }) + if len(rows) > limit { + rows = rows[:limit] + } + return rows, nil +} + +func (s *ChannelStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error { + if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(tag.Reaction.Emoticon) == "" { + return domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if s.savedTags[tag.UserID] == nil { + s.savedTags[tag.UserID] = make(map[string]domain.SavedReactionTag) + } + tag.Reaction.Emoticon = strings.TrimSpace(tag.Reaction.Emoticon) + if tag.Count < 0 { + tag.Count = 0 + } + s.savedTags[tag.UserID][messageReactionKey(tag.Reaction)] = tag + return nil +} + +func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.RandomID == 0 { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + title := strings.TrimSpace(req.Title) + if title == "" && !req.TitleMissing { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if req.IconColor == 0 { + req.IconColor = domain.DefaultForumTopicIconColor + } + s.mu.Lock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !channel.Forum || channel.Broadcast || !channel.Megagroup { + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + if !canSendChannelMessage(channel, member) { + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden + } + if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok { + if topic, ok := s.topics[req.ChannelID][id]; ok { + msg, _ := s.findMessageLocked(req.ChannelID, id) + event := s.eventForMessageLocked(req.ChannelID, id) + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{ + Channel: cloneChannel(channel), + Topic: cloneChannelForumTopic(topic), + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: recipients, + Duplicate: true, + }, nil + } + } + s.mu.Unlock() + + res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + RandomID: req.RandomID, + SendAs: req.SendAs, + Action: &domain.ChannelMessageAction{ + Type: domain.ChannelActionTopicCreate, + Title: title, + IconColor: req.IconColor, + IconEmojiID: req.IconEmojiID, + TitleMissing: req.TitleMissing, + }, + Date: req.Date, + }) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionTopicCreate { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + + s.mu.Lock() + defer s.mu.Unlock() + channel = s.channels[req.ChannelID] + if s.topics[req.ChannelID] == nil { + s.topics[req.ChannelID] = make(map[int]domain.ChannelForumTopic) + } + topic, ok := s.topics[req.ChannelID][res.Message.ID] + if !ok { + topic = domain.ChannelForumTopic{ + ChannelID: req.ChannelID, + TopicID: res.Message.ID, + CreatorUserID: req.UserID, + Title: title, + IconColor: req.IconColor, + IconEmojiID: req.IconEmojiID, + TitleMissing: req.TitleMissing, + Date: res.Message.Date, + TopMessageID: res.Message.ID, + ReadInboxMaxID: res.Message.ID, + ReadOutboxMaxID: res.Message.ID, + } + s.topics[req.ChannelID][topic.TopicID] = topic + } + return domain.CreateChannelForumTopicResult{ + Channel: cloneChannel(channel), + Topic: cloneChannelForumTopic(topic), + Message: cloneChannelMessage(res.Message), + Event: cloneChannelEvent(res.Event), + Recipients: append([]int64(nil), res.Recipients...), + Duplicate: res.Duplicate, + }, nil +} + +func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + topic, ok := s.topics[req.ChannelID][req.TopicID] + if !channel.Forum { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + if !ok { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, domain.ErrMessageIDInvalid + } + if !canManageForumTopic(channel, member, topic, req.UserID) { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired + } + next := topic + action := domain.ChannelMessageAction{Type: domain.ChannelActionTopicEdit} + changed := false + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if next.Title != title { + next.Title = title + action.Title = title + changed = true + } + } + if req.IconEmojiID != nil && next.IconEmojiID != *req.IconEmojiID { + next.IconEmojiID = *req.IconEmojiID + action.IconEmojiID = *req.IconEmojiID + action.IconEmojiIDSet = true + changed = true + } + if req.Closed != nil && next.Closed != *req.Closed { + next.Closed = *req.Closed + action.Closed = boolPtr(*req.Closed) + changed = true + } + if req.Hidden != nil && next.Hidden != *req.Hidden { + next.Hidden = *req.Hidden + action.Hidden = boolPtr(*req.Hidden) + changed = true + } + s.mu.Unlock() + if !changed { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelNotModified + } + + res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + ReplyTo: &domain.MessageReply{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, + MessageID: req.TopicID, + TopMessageID: req.TopicID, + }, + Action: &action, + Date: req.Date, + }) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + channel = s.channels[req.ChannelID] + if _, ok := s.topics[req.ChannelID][req.TopicID]; !ok { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + next.TopMessageID = maxInt(next.TopMessageID, res.Message.ID) + s.topics[req.ChannelID][req.TopicID] = next + return domain.EditChannelForumTopicResult{ + Channel: cloneChannel(channel), + Topic: cloneChannelForumTopic(next), + Message: cloneChannelMessage(res.Message), + Event: cloneChannelEvent(res.Event), + Recipients: append([]int64(nil), res.Recipients...), + }, nil +} + +func (s *ChannelStore) UpdatePinnedForumTopic(_ context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + topic, ok := s.topics[req.ChannelID][req.TopicID] + if !channel.Forum { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelForumMissing + } + if !ok { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrMessageIDInvalid + } + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelAdminRequired + } + if topic.Pinned == req.Pinned { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelNotModified + } + topic.Pinned = req.Pinned + if req.Pinned && topic.PinnedOrder == 0 { + topic.PinnedOrder = s.nextForumTopicPinnedOrderLocked(req.ChannelID) + } + if !req.Pinned { + topic.PinnedOrder = 0 + } + s.topics[req.ChannelID][req.TopicID] = topic + return domain.UpdateChannelForumTopicPinnedResult{ + Channel: cloneChannel(channel), + Topic: cloneChannelForumTopic(topic), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) ReorderPinnedForumTopics(_ context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReorderChannelPinnedForumTopicsResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !channel.Forum { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelForumMissing + } + if !canPinChannelMessages(channel, member) { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelAdminRequired + } + seen := make(map[int]struct{}, len(req.Order)) + order := make([]int, 0, len(req.Order)) + for _, id := range req.Order { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + topic, ok := s.topics[req.ChannelID][id] + if !ok || !topic.Pinned { + if req.Force { + continue + } + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + seen[id] = struct{}{} + order = append(order, id) + } + for i, id := range order { + topic := s.topics[req.ChannelID][id] + topic.PinnedOrder = len(order) - i + s.topics[req.ChannelID][id] = topic + } + return domain.ReorderChannelPinnedForumTopicsResult{ + Channel: cloneChannel(channel), + Order: append([]int(nil), order...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + topic, ok := s.topics[req.ChannelID][req.TopicID] + if !channel.Forum { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelForumMissing + } + if !ok { + return domain.DeleteChannelHistoryResult{}, domain.ErrMessageIDInvalid + } + if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { + msg := s.messages[req.ChannelID][i] + if msg.Deleted { + continue + } + if msg.ID != req.TopicID && (msg.ReplyTo == nil || msg.ReplyTo.TopMessageID != req.TopicID) { + continue + } + ids = append(ids, msg.ID) + if len(ids) >= domain.MaxDeleteHistoryBatch { + break + } + } + deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + offset := 0 + if s.topicHasVisibleMessagesLocked(req.ChannelID, req.TopicID) { + offset = 1 + } else { + delete(s.topics[req.ChannelID], req.TopicID) + } + return domain.DeleteChannelHistoryResult{ + Channel: cloneChannel(channel), + Event: cloneChannelEvent(event), + DeletedIDs: append([]int(nil), deleted...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Offset: offset, + }, nil +} + +func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + if !channel.Forum { + return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelForumTopicsLimit { + limit = domain.MaxChannelForumTopicsLimit + } + query := strings.TrimSpace(strings.ToLower(filter.Query)) + all := make([]domain.ChannelForumTopic, 0, len(s.topics[filter.ChannelID])) + for _, topic := range s.topics[filter.ChannelID] { + if topic.TopicID <= member.AvailableMinID { + continue + } + if query != "" && !strings.Contains(strings.ToLower(topic.Title), query) { + continue + } + if forumTopicBeforeOrAtOffset(topic, filter) { + continue + } + all = append(all, s.topicWithViewerCountersLocked(viewerUserID, filter.ChannelID, topic, member)) + } + sortForumTopics(all) + count := len(all) + if len(all) > limit { + all = all[:limit] + } + messages := s.forumTopicRootMessagesLocked(filter.ChannelID, all, member.AvailableMinID) + return domain.ChannelForumTopicList{ + Channel: cloneChannel(channel), + Dialog: s.dialogForUserLocked(viewerUserID, channel), + Topics: all, + Messages: messages, + Count: count, + }, nil +} + +func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + if !channel.Forum { + return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing + } + wanted := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelForumTopicList{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + topics := make([]domain.ChannelForumTopic, 0, len(wanted)) + for id := range wanted { + topic, ok := s.topics[channelID][id] + if !ok || topic.TopicID <= member.AvailableMinID { + continue + } + topics = append(topics, s.topicWithViewerCountersLocked(viewerUserID, channelID, topic, member)) + } + sortForumTopics(topics) + messages := s.forumTopicRootMessagesLocked(channelID, topics, member.AvailableMinID) + return domain.ChannelForumTopicList{ + Channel: cloneChannel(channel), + Dialog: s.dialogForUserLocked(viewerUserID, channel), + Topics: topics, + Messages: messages, + Count: len(topics), + }, nil +} + +func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + source, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + root, ok := s.findMessageLocked(filter.ChannelID, filter.RootMessageID) + if !ok || root.Deleted || root.ID <= member.AvailableMinID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + targetChannel := source + targetMember := member + rootID := root.ID + extraChannels := []domain.Channel(nil) + if source.Broadcast { + if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { + return domain.ChannelHistory{Channel: source, Count: 0}, nil + } + linked, ok := s.channels[root.Discussion.ChannelID] + if !ok || linked.Deleted { + return domain.ChannelHistory{Channel: source, Count: 0}, nil + } + targetChannel = linked + rootID = root.Discussion.MessageID + if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok { + targetMember = linkedMember + } else { + targetMember = domain.ChannelMember{} + } + extraChannels = append(extraChannels, source) + } + if targetRoot, ok := s.findMessageLocked(targetChannel.ID, rootID); !ok || targetRoot.Deleted { + return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Count: 0}, nil + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelRepliesLimit { + limit = domain.MaxChannelRepliesLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + base := make([]domain.ChannelMessage, 0, limit) + for _, msg := range s.messages[targetChannel.ID] { + if msg.Deleted || msg.ID <= targetMember.AvailableMinID { + continue + } + if !channelReplyBelongsToRoot(msg, targetChannel.ID, rootID) { + continue + } + if filter.MaxID > 0 && msg.ID >= filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + if filter.OffsetDate > 0 && msg.Date == 0 { + continue + } + base = append(base, msg) + } + sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) + page := pageChannelMessageHistory(base, filter, limit) + out := make([]domain.ChannelMessage, 0, len(page)) + for _, msg := range page { + out = append(out, cloneChannelMessage(msg)) + } + s.populateChannelMessageRepliesLocked(viewerUserID, targetChannel.ID, out) + s.populateChannelMessageReactionsLocked(viewerUserID, targetChannel, out) + topics := []domain.ChannelForumTopic(nil) + if targetChannel.Forum { + if topic, ok := s.topics[targetChannel.ID][rootID]; ok && !topic.Hidden { + topic = s.topicWithViewerCountersLocked(viewerUserID, targetChannel.ID, topic, targetMember) + topics = append(topics, cloneChannelForumTopic(topic)) + } + } + return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Topics: topics, Messages: out, Count: len(base)}, nil +} + +func (s *ChannelStore) ListChannelUnreadMentions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit { + limit = domain.MaxChannelUnreadMentionsLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + base := make([]domain.ChannelMessage, 0, limit) + for msgID, topID := range s.mentions[viewerUserID][filter.ChannelID] { + if filter.TopMsgID > 0 && topID != filter.TopMsgID { + continue + } + msg, ok := s.findMessageLocked(filter.ChannelID, msgID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + if filter.MaxID > 0 && msg.ID >= filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + base = append(base, msg) + } + sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) + page := pageChannelMessageHistory(base, domain.ChannelRepliesFilter{ + OffsetID: filter.OffsetID, + OffsetDate: filter.OffsetDate, + AddOffset: filter.AddOffset, + Limit: limit, + MaxID: filter.MaxID, + MinID: filter.MinID, + }, limit) + out := make([]domain.ChannelMessage, 0, len(page)) + for _, msg := range page { + out = append(out, cloneChannelMessage(msg)) + } + s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) + s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) + return domain.ChannelHistory{Channel: channel, Messages: out, Count: len(base)}, nil +} + +func (s *ChannelStore) ReadChannelMentions(_ context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelMentionsResult{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch { + limit = domain.MaxChannelReadMentionsBatch + } + msgIDs := make([]int, 0, limit) + for msgID, topID := range s.mentions[req.UserID][req.ChannelID] { + if req.TopMsgID > 0 && topID != req.TopMsgID { + continue + } + msgIDs = append(msgIDs, msgID) + } + sort.Sort(sort.Reverse(sort.IntSlice(msgIDs))) + if len(msgIDs) > limit { + msgIDs = msgIDs[:limit] + } + for _, msgID := range msgIDs { + delete(s.mentions[req.UserID][req.ChannelID], msgID) + } + remaining := s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, req.TopMsgID) + if dialogs := s.dialogs[req.UserID]; dialogs != nil { + dialog := dialogs[req.ChannelID] + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, 0) + dialog.UserID = req.UserID + dialog.ChannelID = req.ChannelID + dialogs[req.ChannelID] = dialog + } + offset := 0 + if remaining > 0 { + offset = 1 + } + return domain.ReadChannelMentionsResult{ + Channel: channel, + Cleared: len(msgIDs), + Remaining: remaining, + Offset: offset, + ChannelPts: channel.Pts, + }, nil +} + +func (s *ChannelStore) ListChannelUnreadReactions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { + limit = domain.MaxChannelUnreadReactionsLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + base := make([]domain.ChannelMessage, 0, limit) + for msgID, byUser := range s.reactions[filter.ChannelID] { + msg, ok := s.findMessageLocked(filter.ChannelID, msgID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + if filter.TopMsgID > 0 && msg.ID != filter.TopMsgID && channelMentionTopID(msg) != filter.TopMsgID { + continue + } + if filter.MaxID > 0 && msg.ID >= filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + if !channelMessageHasUnreadReactionForUser(byUser, viewerUserID) { + continue + } + base = append(base, msg) + } + sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) + page := pageChannelMessageHistory(base, domain.ChannelRepliesFilter{ + OffsetID: filter.OffsetID, + AddOffset: filter.AddOffset, + Limit: limit, + MaxID: filter.MaxID, + MinID: filter.MinID, + }, limit) + out := make([]domain.ChannelMessage, 0, len(page)) + for _, msg := range page { + out = append(out, cloneChannelMessage(msg)) + } + s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) + s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) + return domain.ChannelHistory{Channel: channel, Messages: out, Count: len(base)}, nil +} + +func (s *ChannelStore) ReadChannelReactions(_ context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelReactionsResult{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadReactionsBatch { + limit = domain.MaxChannelReadReactionsBatch + } + msgIDs := make([]int, 0, limit) + for msgID, byUser := range s.reactions[req.ChannelID] { + msg, ok := s.findMessageLocked(req.ChannelID, msgID) + if !ok || msg.Deleted { + continue + } + if req.TopMsgID > 0 && msg.ID != req.TopMsgID && channelMentionTopID(msg) != req.TopMsgID { + continue + } + if channelMessageHasUnreadReactionForUser(byUser, req.UserID) { + msgIDs = append(msgIDs, msgID) + } + } + sort.Sort(sort.Reverse(sort.IntSlice(msgIDs))) + if len(msgIDs) > limit { + msgIDs = msgIDs[:limit] + } + for _, msgID := range msgIDs { + for reactedUserID, rows := range s.reactions[req.ChannelID][msgID] { + changed := false + for i := range rows { + if rows[i].SenderUserID == req.UserID && rows[i].Unread { + rows[i].Unread = false + changed = true + } + } + if changed { + s.reactions[req.ChannelID][msgID][reactedUserID] = rows + } + } + } + remaining := s.countChannelUnreadReactionsLocked(req.UserID, req.ChannelID, req.TopMsgID) + s.refreshChannelUnreadReactionsDialogLocked(req.UserID, req.ChannelID) + offset := 0 + if remaining > 0 { + offset = 1 + } + return domain.ReadChannelReactionsResult{ + Channel: channel, + Cleared: len(msgIDs), + Remaining: remaining, + Offset: offset, + ChannelPts: channel.Pts, + }, nil +} + +func (s *ChannelStore) GetDiscussionMessage(_ context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { + s.mu.RLock() + defer s.mu.RUnlock() + source, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelDiscussionMessage{}, err + } + msg, ok := s.findMessageLocked(channelID, msgID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid + } + result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}} + targetChannel := source + targetMsg := msg + targetMember := member + if source.Broadcast { + if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 { + return result, nil + } + linked, ok := s.channels[msg.Discussion.ChannelID] + if !ok || linked.Deleted { + return result, nil + } + linkedMsg, ok := s.findMessageLocked(linked.ID, msg.Discussion.MessageID) + if !ok || linkedMsg.Deleted { + return result, nil + } + targetChannel = linked + targetMsg = linkedMsg + if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok { + targetMember = linkedMember + } else { + targetMember = domain.ChannelMember{} + } + result.DiscussionChannel = linked + result.Channels = []domain.Channel{source, linked} + } + items := []domain.ChannelMessage{cloneChannelMessage(targetMsg)} + s.populateChannelMessageRepliesLocked(viewerUserID, targetChannel.ID, items) + s.populateChannelMessageReactionsLocked(viewerUserID, targetChannel, items) + if stats := s.channelMessageRepliesLocked(viewerUserID, targetChannel.ID, targetMsg); stats != nil { + result.MaxID = stats.MaxID + } + result.ReadInboxMaxID = targetMember.ReadInboxMaxID + result.ReadOutboxMaxID = targetMember.ReadOutboxMaxID + result.UnreadCount = s.channelThreadUnreadCountLocked(viewerUserID, targetChannel.ID, targetMsg.ID, targetMember.ReadInboxMaxID) + result.Messages = items + return result, nil +} + +func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > channel.TopMessageID { + maxID = channel.TopMessageID + } + member := s.members[req.ChannelID][req.UserID] + previous := member.ReadInboxMaxID + changed := maxID > member.ReadInboxMaxID + var outboxUpdates []domain.ChannelReadOutboxUpdate + if changed { + member.ReadInboxMaxID = maxID + member.ReadInboxDate = req.Date + member.UnreadMark = false + s.members[req.ChannelID][req.UserID] = member + outboxUpdates = s.advanceChannelReadOutboxLocked(req.ChannelID, req.UserID, previous, maxID) + } + dialog := s.dialogForUserLocked(req.UserID, channel) + dialog.ReadInboxMaxID = member.ReadInboxMaxID + dialog.UnreadCount = s.channelUnreadCountLocked(req.UserID, channel.ID, member.ReadInboxMaxID, dialog.TopMessageID) + dialog.UnreadMark = false + if s.dialogs[req.UserID] == nil { + s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[req.UserID][req.ChannelID] = dialog + return domain.ReadChannelHistoryResult{ + ChannelID: req.ChannelID, + MaxID: maxID, + StillUnreadCount: dialog.UnreadCount, + Changed: changed, + Pts: channel.Pts, + Dialog: dialog, + OutboxUpdates: outboxUpdates, + }, nil +} + +func (s *ChannelStore) advanceChannelReadOutboxLocked(channelID, readerUserID int64, previous, maxID int) []domain.ChannelReadOutboxUpdate { + if maxID <= previous { + return nil + } + lowerID := previous + if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages { + lowerID = maxID - domain.MaxChannelReadOutboxScanMessages + } + bySender := make(map[int64]int, domain.MaxChannelReadOutboxFanout) + messages := s.messages[channelID] + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.ID <= lowerID { + break + } + if msg.ID > maxID || msg.Deleted || msg.SenderUserID == 0 || msg.SenderUserID == readerUserID { + continue + } + if _, ok := bySender[msg.SenderUserID]; ok { + continue + } + bySender[msg.SenderUserID] = msg.ID + if len(bySender) >= domain.MaxChannelReadOutboxFanout { + break + } + } + if len(bySender) == 0 { + return nil + } + senderIDs := make([]int64, 0, len(bySender)) + for userID := range bySender { + senderIDs = append(senderIDs, userID) + } + sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] }) + channel := s.channels[channelID] + out := make([]domain.ChannelReadOutboxUpdate, 0, len(senderIDs)) + for _, userID := range senderIDs { + maxForSender := bySender[userID] + member, ok := s.members[channelID][userID] + if !ok || member.Status != domain.ChannelMemberActive || maxForSender <= member.ReadOutboxMaxID { + continue + } + member.ReadOutboxMaxID = maxForSender + s.members[channelID][userID] = member + dialog := s.dialogForUserLocked(userID, channel) + if dialog.ReadOutboxMaxID < maxForSender { + dialog.ReadOutboxMaxID = maxForSender + } + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + out = append(out, domain.ChannelReadOutboxUpdate{UserID: userID, MaxID: maxForSender}) + } + return out +} + +func (s *ChannelStore) ListMessageReadParticipants(_ context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + msg, found := s.findMessageLocked(req.ChannelID, req.MessageID) + if !found || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid + } + result := domain.ChannelReadParticipantsResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + } + if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants { + return result, nil + } + now := req.Date + if now > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= now { + return result, nil + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadParticipants { + limit = domain.MaxChannelReadParticipants + } + for _, reader := range s.members[req.ChannelID] { + if reader.UserID == req.UserID || reader.Status != domain.ChannelMemberActive || reader.BannedRights.ViewMessages { + continue + } + if reader.ReadInboxDate <= 0 { + continue + } + if reader.AvailableMinID >= req.MessageID || reader.ReadInboxMaxID < req.MessageID { + continue + } + result.Participants = append(result.Participants, domain.ChannelReadParticipant{ + UserID: reader.UserID, + Date: reader.ReadInboxDate, + }) + if len(result.Participants) >= limit { + break + } + } + sort.Slice(result.Participants, func(i, j int) bool { + if result.Participants[i].Date == result.Participants[j].Date { + return result.Participants[i].UserID < result.Participants[j].UserID + } + return result.Participants[i].Date < result.Participants[j].Date + }) + return result, nil +} + +func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, preview, err := s.channelForViewerLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelDifference{}, err + } + if req.Pts < 0 || req.Pts > channel.Pts { + return domain.ChannelDifference{}, domain.ErrPersistentTimestamp + } + if !preview && member.AvailableMinPts > req.Pts { + req.Pts = minInt(member.AvailableMinPts, channel.Pts) + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { + limit = domain.MaxChannelDifferenceLimit + } + dialog := s.dialogForUserLocked(req.UserID, channel) + if preview { + dialog = previewChannelDialog(req.UserID, channel, member) + } + if channel.Pts-req.Pts > limit { + messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages) + for i := len(s.messages[req.ChannelID]) - 1; i >= 0 && len(messages) < domain.MaxChannelDifferenceTooLongMessages; i-- { + msg := s.messages[req.ChannelID][i] + if msg.Deleted { + continue + } + if msg.ID <= member.AvailableMinID { + continue + } + messages = append(messages, cloneChannelMessage(msg)) + } + return domain.ChannelDifference{ + Channel: channel, + Self: member, + NewMessages: messages, + Pts: channel.Pts, + Final: true, + TooLong: true, + Timeout: 30, + Dialog: dialog, + }, nil + } + events := make([]domain.ChannelUpdateEvent, 0, limit) + lastPts := req.Pts + for _, event := range s.events[req.ChannelID] { + if event.Pts <= req.Pts { + continue + } + lastPts = event.Pts + visible, ok := domain.FilterChannelUpdateEventForAvailableMinID(cloneChannelEvent(event), member.AvailableMinID) + if !ok { + continue + } + if preview && visible.Type == domain.ChannelUpdateParticipant { + continue + } + events = append(events, visible) + } + if len(events) == 0 { + return domain.ChannelDifference{ + Channel: channel, + Self: member, + Pts: maxInt(lastPts, req.Pts), + Final: true, + Timeout: 30, + Dialog: dialog, + }, nil + } + diff := domain.ChannelDifference{ + Channel: channel, + Self: member, + Events: events, + Pts: lastPts, + Final: lastPts >= channel.Pts, + Timeout: 30, + Dialog: dialog, + } + for _, event := range events { + switch event.Type { + case domain.ChannelUpdateNewMessage: + diff.NewMessages = append(diff.NewMessages, cloneChannelMessage(event.Message)) + default: + diff.OtherUpdates = append(diff.OtherUpdates, cloneChannelEvent(event)) + } + } + return diff, nil +} + +func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if userID == 0 || afterChannelID < 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + out := make([]int64, 0, limit) + for channelID, members := range s.members { + if channelID <= afterChannelID { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + continue + } + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberActive { + continue + } + out = append(out, channelID) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (s *ChannelStore) ListActiveChannelMemberIDs(_ context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { + return nil, err + } + return s.activeMemberIDsLocked(channelID, 0, limit), nil +} + +func (s *ChannelStore) ListChannelInviteAdminMemberIDs(_ context.Context, channelID int64, limit int) ([]int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[channelID] + if channelID == 0 || !ok || channel.Deleted { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + members := s.members[channelID] + out := make([]int64, 0, minInt(len(members), limit)) + for _, member := range members { + if member.Status != domain.ChannelMemberActive { + continue + } + if member.Role == domain.ChannelRoleCreator { + out = append(out, member.UserID) + if len(out) >= limit { + break + } + continue + } + if member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo) { + out = append(out, member.UserID) + if len(out) >= limit { + break + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out, nil +} + +func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if channelID == 0 || len(userIDs) == 0 { + return nil, nil + } + members := s.members[channelID] + if len(members) == 0 { + return nil, nil + } + out := make([]int64, 0, len(userIDs)) + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberActive { + continue + } + out = append(out, userID) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out, nil +} + +func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ptsSeq[channelID], nil +} + +func (s *ChannelStore) MaxChannelMessageID(_ context.Context, channelID int64) (int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.msgSeq[channelID], nil +} + +func (s *ChannelStore) nextChannelIDLocked() int64 { + id := s.nextID + s.nextID++ + return id +} + +func (s *ChannelStore) nextAccessHashLocked() int64 { + hash := s.nextHash + s.nextHash += 17 + return hash +} + +func (s *ChannelStore) nextChannelMessageIDLocked(channelID int64) int { + s.msgSeq[channelID]++ + return s.msgSeq[channelID] +} + +func (s *ChannelStore) nextChannelPtsLocked(channelID int64) int { + s.ptsSeq[channelID]++ + return s.ptsSeq[channelID] +} + +func (s *ChannelStore) nextChannelPtsNLocked(channelID int64, count int) int { + if count <= 0 { + return s.ptsSeq[channelID] + } + s.ptsSeq[channelID] += count + return s.ptsSeq[channelID] +} + +func (s *ChannelStore) appendParticipantEventLocked(channel domain.Channel, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent { + pts := s.nextChannelPtsLocked(channel.ID) + channel.Pts = pts + s.channels[channel.ID] = channel + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateParticipant, + Pts: pts, + PtsCount: 1, + Date: date, + SenderUserID: actorUserID, + UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), + Previous: previous, + Participant: participant, + } + s.events[channel.ID] = append(s.events[channel.ID], event) + return cloneChannelEvent(event) +} + +func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent) { + channel := s.channels[channelID] + pts := s.nextChannelPtsLocked(channelID) + msg := domain.ChannelMessage{ + ChannelID: channelID, + ID: s.nextChannelMessageIDLocked(channelID), + SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: date, + Post: channel.Broadcast, + Action: &action, + Pts: pts, + } + event := domain.ChannelUpdateEvent{ + ChannelID: channelID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: date, + Message: cloneChannelMessage(msg), + SenderUserID: senderUserID, + UserIDs: append([]int64(nil), action.UserIDs...), + } + s.messages[channelID] = append(s.messages[channelID], msg) + s.events[channelID] = append(s.events[channelID], event) + return msg, event +} + +func (s *ChannelStore) channelForMemberLocked(userID, channelID int64) (domain.Channel, error) { + channel, _, err := s.channelAndMemberLocked(userID, channelID) + return channel, err +} + +func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) { + channel, member, err := s.channelAndMemberLocked(userID, channelID) + if err == nil { + return channel, member, false, nil + } + if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelInvalid + } + existing, found := s.members[channelID][userID] + if found && (existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages) { + return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelUserBanned + } + if !publicPreviewableChannel(channel) { + return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate + } + return channel, publicPreviewMember(channel, userID, existing, found), true, nil +} + +func (s *ChannelStore) channelAndMemberLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, error) { + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelInvalid + } + member, ok := s.members[channelID][userID] + if !ok || member.Status == domain.ChannelMemberLeft { + return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelPrivate + } + if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages { + return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelUserBanned + } + return channel, member, nil +} + +func (s *ChannelStore) upsertChannelDialogLocked(userID int64, channel domain.Channel, top domain.ChannelMessage, selfAction bool) { + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + dialog := s.dialogs[userID][channel.ID] + dialog.UserID = userID + dialog.ChannelID = channel.ID + dialog.TopMessageID = s.visibleTopMessageIDLocked(userID, channel) + if top.ID != 0 { + dialog.TopMessageDate = top.Date + } + member := s.members[channel.ID][userID] + if member.ReadInboxMaxID > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = member.ReadInboxMaxID + } + if selfAction { + if channel.TopMessageID > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = channel.TopMessageID + } + dialog.ReadOutboxMaxID = channel.TopMessageID + } + dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID) + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0) + dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0) + s.dialogs[userID][channel.ID] = dialog +} + +func (s *ChannelStore) dialogForUserLocked(userID int64, channel domain.Channel) domain.ChannelDialog { + dialog := s.dialogs[userID][channel.ID] + dialog.UserID = userID + dialog.ChannelID = channel.ID + member := s.members[channel.ID][userID] + dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member) + if member.ReadInboxMaxID > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = member.ReadInboxMaxID + } + dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID) + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0) + dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0) + return dialog +} + +func (s *ChannelStore) findMessageLocked(channelID int64, id int) (domain.ChannelMessage, bool) { + for _, msg := range s.messages[channelID] { + if msg.ID == id { + return msg, true + } + } + return domain.ChannelMessage{}, false +} + +func (s *ChannelStore) findMessageIndexLocked(channelID int64, id int) (int, bool) { + for i, msg := range s.messages[channelID] { + if msg.ID == id { + return i, true + } + } + return 0, false +} + +func (s *ChannelStore) populateChannelMessageRepliesLocked(viewerUserID, channelID int64, messages []domain.ChannelMessage) { + for i := range messages { + messages[i].Replies = s.channelMessageRepliesLocked(viewerUserID, channelID, messages[i]) + } +} + +func (s *ChannelStore) channelMessageRepliesLocked(viewerUserID, channelID int64, msg domain.ChannelMessage) *domain.ChannelMessageReplies { + targetChannelID := channelID + rootID := msg.ID + stats := domain.ChannelMessageReplies{} + if msg.Discussion != nil && msg.Discussion.ChannelID != 0 && msg.Discussion.MessageID != 0 { + targetChannelID = msg.Discussion.ChannelID + rootID = msg.Discussion.MessageID + stats.Comments = true + stats.ChannelID = msg.Discussion.ChannelID + } else if channel, ok := s.channels[channelID]; ok && channel.Broadcast && channel.LinkedChatID != 0 && msg.Post { + stats.Comments = true + stats.ChannelID = channel.LinkedChatID + } + if rootID <= 0 { + return nil + } + if member, ok := s.members[targetChannelID][viewerUserID]; ok { + stats.ReadMaxID = member.ReadInboxMaxID + } + seenRecent := map[domain.Peer]struct{}{} + for i := len(s.messages[targetChannelID]) - 1; i >= 0; i-- { + reply := s.messages[targetChannelID][i] + if reply.Deleted || !channelReplyBelongsToRoot(reply, targetChannelID, rootID) { + continue + } + stats.Replies++ + if stats.MaxID == 0 || reply.ID > stats.MaxID { + stats.MaxID = reply.ID + stats.RepliesPts = reply.Pts + } + if len(stats.RecentRepliers) < 3 { + peer := reply.From + if peer.ID == 0 && reply.SenderUserID != 0 { + peer = domain.Peer{Type: domain.PeerTypeUser, ID: reply.SenderUserID} + } + if peer.ID != 0 { + if _, ok := seenRecent[peer]; !ok { + seenRecent[peer] = struct{}{} + stats.RecentRepliers = append(stats.RecentRepliers, peer) + } + } + } + } + if stats.Comments && stats.RepliesPts == 0 { + if root, ok := s.findMessageLocked(targetChannelID, rootID); ok { + stats.RepliesPts = root.Pts + } + } + if !stats.Comments && stats.Replies == 0 { + return nil + } + return &stats +} + +func (s *ChannelStore) channelThreadUnreadCountLocked(viewerUserID, channelID int64, rootID, readMaxID int) int { + unread := 0 + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= readMaxID || msg.SenderUserID == viewerUserID { + continue + } + if channelReplyBelongsToRoot(msg, channelID, rootID) { + unread++ + } + } + return unread +} + +func (s *ChannelStore) channelUnreadCountLocked(viewerUserID, channelID int64, readMaxID, topID int) int { + if viewerUserID == 0 || channelID == 0 || topID <= readMaxID { + return 0 + } + unread := 0 + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= readMaxID || msg.ID > topID || msg.SenderUserID == viewerUserID { + continue + } + unread++ + } + return unread +} + +func (s *ChannelStore) addChannelUnreadMentionsLocked(channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) { + if len(userIDs) == 0 || msg.ID == 0 { + return + } + seen := make(map[int64]struct{}, len(userIDs)) + written := 0 + topID := channelMentionTopID(msg) + for _, userID := range userIDs { + if userID == 0 || userID == senderUserID { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + member, ok := s.members[channelID][userID] + if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { + continue + } + if msg.ID <= member.AvailableMinID || msg.ID <= member.ReadInboxMaxID { + continue + } + if s.mentions[userID] == nil { + s.mentions[userID] = make(map[int64]map[int]int) + } + if s.mentions[userID][channelID] == nil { + s.mentions[userID][channelID] = make(map[int]int) + } + s.mentions[userID][channelID][msg.ID] = topID + written++ + if written == domain.MaxChannelMentionRecipients { + return + } + } +} + +func (s *ChannelStore) countChannelUnreadMentionsLocked(userID, channelID int64, topMsgID int) int { + count := 0 + for _, mentionTopID := range s.mentions[userID][channelID] { + if topMsgID == 0 || mentionTopID == topMsgID { + count++ + } + } + return count +} + +func (s *ChannelStore) countChannelUnreadReactionsLocked(userID, channelID int64, topMsgID int) int { + count := 0 + availableMinID := 0 + if member, ok := s.members[channelID][userID]; ok { + availableMinID = member.AvailableMinID + } + for msgID, byUser := range s.reactions[channelID] { + msg, ok := s.findMessageLocked(channelID, msgID) + if !ok || msg.Deleted || msg.ID <= availableMinID { + continue + } + if topMsgID > 0 && msg.ID != topMsgID && channelMentionTopID(msg) != topMsgID { + continue + } + if channelMessageHasUnreadReactionForUser(byUser, userID) { + count++ + } + } + return count +} + +func channelMessageHasUnreadReactionForUser(byUser map[int64][]domain.ChannelMessagePeerReaction, userID int64) bool { + for _, rows := range byUser { + for _, row := range rows { + if row.SenderUserID == userID && row.UserID != userID && row.Unread { + return true + } + } + } + return false +} + +func (s *ChannelStore) refreshChannelUnreadReactionsDialogLocked(userID, channelID int64) { + if userID == 0 || channelID == 0 { + return + } + channel, ok := s.channels[channelID] + if !ok { + return + } + member, ok := s.members[channelID][userID] + if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { + return + } + dialog := s.dialogForUserLocked(userID, channel) + dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channelID, 0) + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog +} + +func (s *ChannelStore) deleteChannelUnreadMentionsLocked(channelID int64, ids []int) { + if len(ids) == 0 { + return + } + set := make(map[int]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + for userID, byChannel := range s.mentions { + mentions := byChannel[channelID] + if len(mentions) == 0 { + continue + } + for id := range set { + delete(mentions, id) + } + if len(mentions) == 0 { + delete(byChannel, channelID) + } + if len(byChannel) == 0 { + delete(s.mentions, userID) + } + } +} + +func (s *ChannelStore) deleteChannelUnreadMentionsUpToLocked(userID, channelID int64, maxID int) { + if maxID <= 0 || len(s.mentions[userID][channelID]) == 0 { + return + } + for id := range s.mentions[userID][channelID] { + if id <= maxID { + delete(s.mentions[userID][channelID], id) + } + } + if len(s.mentions[userID][channelID]) == 0 { + delete(s.mentions[userID], channelID) + } + if len(s.mentions[userID]) == 0 { + delete(s.mentions, userID) + } +} + +func channelMentionTopID(msg domain.ChannelMessage) int { + if msg.ReplyTo == nil { + return 0 + } + if msg.ReplyTo.TopMessageID > 0 { + return msg.ReplyTo.TopMessageID + } + return msg.ReplyTo.MessageID +} + +func channelReplyBelongsToRoot(msg domain.ChannelMessage, channelID int64, rootID int) bool { + if msg.ReplyTo == nil || rootID <= 0 { + return false + } + if msg.ReplyTo.Peer.ID != 0 && msg.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) { + return false + } + return msg.ReplyTo.TopMessageID == rootID || (msg.ReplyTo.TopMessageID == 0 && msg.ReplyTo.MessageID == rootID) +} + +func pageChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int) []domain.ChannelMessage { + if limit <= 0 || len(base) == 0 { + return nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return forwardChannelMessageHistory(base, filter, limit) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + page := make([]domain.ChannelMessage, 0, limit) + page = append(page, forwardChannelMessageHistory(base, filter, forwardLimit)...) + page = append(page, backwardChannelMessageHistory(base, filter, backwardLimit, true)...) + sort.SliceStable(page, func(i, j int) bool { return channelMessageLess(page[i], page[j]) }) + return page + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + candidates := backwardChannelMessageHistory(base, filter, limit+start, false) + if start >= len(candidates) { + return nil + } + return candidates[start:] + } +} + +func backwardChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int, includeOffset bool) []domain.ChannelMessage { + if limit <= 0 { + return nil + } + out := make([]domain.ChannelMessage, 0, limit) + for _, msg := range base { + if !channelMessageBeforeHistoryOffset(msg, filter, includeOffset) { + continue + } + out = append(out, msg) + if len(out) == limit { + break + } + } + return out +} + +func forwardChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int) []domain.ChannelMessage { + if limit <= 0 { + return nil + } + out := make([]domain.ChannelMessage, 0, limit) + for i := len(base) - 1; i >= 0; i-- { + msg := base[i] + if !channelMessageAfterHistoryOffset(msg, filter) { + continue + } + out = append(out, msg) + if len(out) == limit { + break + } + } + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out +} + +func channelMessageBeforeHistoryOffset(msg domain.ChannelMessage, filter domain.ChannelRepliesFilter, includeOffset bool) bool { + if filter.OffsetDate > 0 { + if includeOffset { + return msg.Date <= filter.OffsetDate + } + return msg.Date < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return true + } + if includeOffset { + return msg.ID <= filter.OffsetID + } + return msg.ID < filter.OffsetID +} + +func channelMessageAfterHistoryOffset(msg domain.ChannelMessage, filter domain.ChannelRepliesFilter) bool { + if filter.OffsetDate > 0 { + return msg.Date >= filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + return msg.ID > filter.OffsetID +} + +func channelMessageLess(a, b domain.ChannelMessage) bool { + if a.Date != b.Date { + return a.Date > b.Date + } + return a.ID > b.ID +} + +func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { + if req.ReplyTo == nil { + return nil, nil + } + if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { + return nil, err + } + peer := req.ReplyTo.Peer + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} + if peer.ID == 0 { + peer = channelPeer + } + if peer != channelPeer { + return nil, domain.ErrReplyMessageIDInvalid + } + if req.ReplyTo.MessageID == 0 { + if req.ReplyTo.TopMessageID <= 0 || !channel.Forum { + return nil, domain.ErrReplyMessageIDInvalid + } + topic, ok := s.topics[req.ChannelID][req.ReplyTo.TopMessageID] + if !ok || topic.Hidden { + return nil, domain.ErrReplyMessageIDInvalid + } + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + return nil, domain.ErrChannelWriteForbidden + } + reply := cloneMessageReply(req.ReplyTo) + reply.MessageID = 0 + reply.Peer = channelPeer + reply.TopMessageID = topic.TopicID + reply.ForumTopic = true + return reply, nil + } + target, ok := s.findMessageLocked(req.ChannelID, req.ReplyTo.MessageID) + if !ok || target.Deleted || target.ID <= member.AvailableMinID { + return nil, domain.ErrReplyMessageIDInvalid + } + reply := cloneMessageReply(req.ReplyTo) + reply.MessageID = target.ID + reply.Peer = channelPeer + reply.TopMessageID = target.ID + if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 { + reply.TopMessageID = target.ReplyTo.TopMessageID + } + if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID { + return nil, domain.ErrReplyMessageIDInvalid + } + if channel.Forum && reply.TopMessageID > 0 { + if topic, ok := s.topics[req.ChannelID][reply.TopMessageID]; ok && !topic.Hidden { + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + return nil, domain.ErrChannelWriteForbidden + } + reply.ForumTopic = true + } + } + return reply, nil +} + +func (s *ChannelStore) visibleTopMessageIDLocked(userID int64, channel domain.Channel) int { + return s.visibleTopMessageIDForMemberLocked(channel, s.members[channel.ID][userID]) +} + +func (s *ChannelStore) visibleTopMessageIDForMemberLocked(channel domain.Channel, member domain.ChannelMember) int { + for i := len(s.messages[channel.ID]) - 1; i >= 0; i-- { + msg := s.messages[channel.ID][i] + if !msg.Deleted && msg.ID > member.AvailableMinID { + return msg.ID + } + } + return 0 +} + +func (s *ChannelStore) deleteChannelMessagesLocked(channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) { + if len(ids) == 0 { + return nil, domain.ChannelUpdateEvent{}, channel, nil + } + seen := make(map[int]struct{}, len(ids)) + deleted := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + idx, ok := s.findMessageIndexLocked(channel.ID, id) + if !ok || s.messages[channel.ID][idx].Deleted { + continue + } + msg := s.messages[channel.ID][idx] + if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) { + return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired + } + msg.Deleted = true + s.messages[channel.ID][idx] = msg + deleted = append(deleted, id) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channel.ID, + UserID: actorUserID, + Date: date, + Type: domain.ChannelAdminLogDeleteMessage, + Message: ptrChannelMessage(msg), + Query: msg.Body, + }) + } + if len(deleted) == 0 { + return nil, domain.ChannelUpdateEvent{}, channel, nil + } + s.deleteChannelUnreadMentionsLocked(channel.ID, deleted) + pts := s.nextChannelPtsNLocked(channel.ID, len(deleted)) + channel.Pts = pts + channel.TopMessageID = s.topNonDeletedMessageIDLocked(channel.ID) + s.channels[channel.ID] = channel + for userID, member := range s.members[channel.ID] { + if member.Status != domain.ChannelMemberActive { + continue + } + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + dialog := s.dialogForUserLocked(userID, channel) + s.dialogs[userID][channel.ID] = dialog + } + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateDeleteMessages, + Pts: pts, + PtsCount: len(deleted), + Date: date, + MessageIDs: append([]int(nil), deleted...), + SenderUserID: actorUserID, + } + s.events[channel.ID] = append(s.events[channel.ID], event) + return deleted, event, channel, nil +} + +func (s *ChannelStore) topNonDeletedMessageIDLocked(channelID int64) int { + for i := len(s.messages[channelID]) - 1; i >= 0; i-- { + if !s.messages[channelID][i].Deleted { + return s.messages[channelID][i].ID + } + } + return 0 +} + +func (s *ChannelStore) eventForMessageLocked(channelID int64, id int) domain.ChannelUpdateEvent { + for _, event := range s.events[channelID] { + if event.Message.ID == id { + return cloneChannelEvent(event) + } + } + return domain.ChannelUpdateEvent{} +} + +func (s *ChannelStore) appendChannelAdminLogLocked(event domain.ChannelAdminLogEvent) { + if event.ChannelID == 0 || event.UserID == 0 || event.Type == "" { + return + } + s.logSeq[event.ChannelID]++ + event.ID = s.logSeq[event.ChannelID] + event.Query = adminLogSearchText(event) + s.adminLogs[event.ChannelID] = append(s.adminLogs[event.ChannelID], cloneChannelAdminLogEvent(event)) +} + +func (s *ChannelStore) activeMemberIDsLocked(channelID, excludeUserID int64, limit int) []int64 { + members := s.members[channelID] + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + capacity := limit + if len(members) < capacity { + capacity = len(members) + } + out := make([]int64, 0, capacity) + for userID, member := range members { + if userID == excludeUserID || member.Status != domain.ChannelMemberActive { + continue + } + out = append(out, userID) + if len(out) >= limit { + break + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func channelDialogToDialog(dialog domain.ChannelDialog) domain.Dialog { + return domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, + FolderID: dialog.FolderID, + TopMessage: dialog.TopMessageID, + TopMessageDate: dialog.TopMessageDate, + ReadInboxMaxID: dialog.ReadInboxMaxID, + ReadOutboxMaxID: dialog.ReadOutboxMaxID, + UnreadCount: dialog.UnreadCount, + UnreadMentions: dialog.UnreadMentions, + UnreadReactions: dialog.UnreadReactions, + Pinned: dialog.Pinned, + PinnedOrder: dialog.PinnedOrder, + UnreadMark: dialog.UnreadMark, + ViewForumAsMessages: dialog.ViewForumAsMessages, + } +} + +func inactiveChannelDate(dialog domain.Dialog, channel domain.Channel, member domain.ChannelMember) int { + if dialog.TopMessageDate > 0 { + return dialog.TopMessageDate + } + date := channel.Date + if member.JoinedAt > date { + date = member.JoinedAt + } + return date +} + +func recommendableChannel(channel domain.Channel) bool { + return !channel.Deleted && + channel.Broadcast && + !channel.Megagroup && + strings.TrimSpace(channel.Username) != "" +} + +func publicSearchableChannel(channel domain.Channel) bool { + return !channel.Deleted && + (channel.Broadcast || channel.Megagroup) && + strings.TrimSpace(channel.Username) != "" +} + +func publicPreviewableChannel(channel domain.Channel) bool { + return publicSearchableChannel(channel) +} + +func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember { + member := domain.ChannelMember{ + ChannelID: channel.ID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberLeft, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + ReadOutboxMaxID: channel.TopMessageID, + } + if found { + member.InviterUserID = existing.InviterUserID + member.JoinedAt = existing.JoinedAt + member.LeftAt = existing.LeftAt + member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID) + member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts) + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID) + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID) + } + return member +} + +func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { + topMessageID := channel.TopMessageID + if topMessageID <= member.AvailableMinID { + topMessageID = 0 + } + return domain.ChannelDialog{ + UserID: userID, + ChannelID: channel.ID, + TopMessageID: topMessageID, + TopMessageDate: channel.Date, + ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), + ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), + } +} + +func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) { + if !publicSearchableChannel(channel) { + return 0, false + } + username := strings.ToLower(strings.TrimSpace(channel.Username)) + title := strings.ToLower(strings.TrimSpace(channel.Title)) + switch { + case username == queryLower: + return 0, true + case strings.HasPrefix(username, queryLower): + return 1, true + case strings.Contains(username, queryLower): + return 2, true + case strings.HasPrefix(title, queryLower): + return 3, true + case strings.Contains(title, queryLower): + return 4, true + default: + return 0, false + } +} + +func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool { + if filter.HasFolderID { + if filter.FolderID < domain.DialogCustomFolderMinID { + if dialog.FolderID != filter.FolderID { + return false + } + } else if filter.Folder == nil { + return false + } + } + if filter.PinnedOnly && !dialog.Pinned { + return false + } + if filter.ExcludePinned && dialog.Pinned { + return false + } + if !channelDialogAfterOffset(dialog, filter) { + return false + } + if filter.Folder == nil { + return true + } + folder := filter.Folder + if peerInFolderList(dialog.Peer, folder.ExcludePeers) { + return false + } + if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark { + return false + } + if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID { + return false + } + if peerInFolderList(dialog.Peer, folder.PinnedPeers) || peerInFolderList(dialog.Peer, folder.IncludePeers) { + return true + } + if channel.Megagroup && folder.Groups { + return true + } + if channel.Broadcast && folder.Broadcasts { + return true + } + return !folder.Groups && !folder.Broadcasts && len(folder.IncludePeers) == 0 +} + +func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { + if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { + if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer { + return false + } + return true + } + if filter.OffsetDate > 0 { + if dialog.TopMessageDate != filter.OffsetDate { + return dialog.TopMessageDate < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + if dialog.TopMessage != filter.OffsetID { + return dialog.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { + return dialog.Peer.ID < filter.OffsetPeer.ID + } + return false + } + if dialog.TopMessage != filter.OffsetID { + return dialog.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { + return dialog.Peer.ID < filter.OffsetPeer.ID + } + return false +} + +func peerInFolderList(peer domain.Peer, items []domain.DialogFolderPeer) bool { + for _, item := range items { + if item.Peer == peer { + return true + } + } + return false +} + +func canPostToBroadcast(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) +} + +func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool { + if channel.Broadcast { + return canPostToBroadcast(member) + } + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return true + } + return !channel.DefaultBannedRights.SendMessages && !member.BannedRights.SendMessages +} + +func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool { + if member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) { + return true + } + return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers +} + +func isChannelAdmin(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin +} + +func channelParticipantMatchesFilter(member domain.ChannelMember, kind domain.ChannelParticipantsFilterKind, query string) bool { + if query != "" && !strings.Contains(strconv.FormatInt(member.UserID, 10), query) { + return false + } + switch kind { + case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions, domain.ChannelParticipantsSearch: + return member.Status == domain.ChannelMemberActive + case domain.ChannelParticipantsAdmins: + return member.Status == domain.ChannelMemberActive && isChannelAdmin(member) + case domain.ChannelParticipantsKicked: + return member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages + case domain.ChannelParticipantsBanned: + return member.Status != domain.ChannelMemberKicked && !zeroChannelBannedRights(member.BannedRights) + case domain.ChannelParticipantsBots: + return false + default: + return member.Status == domain.ChannelMemberActive + } +} + +func channelRoleOrder(role domain.ChannelMemberRole) int { + switch role { + case domain.ChannelRoleCreator: + return 0 + case domain.ChannelRoleAdmin: + return 1 + default: + return 2 + } +} + +func canChangeChannelInfo(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) +} + +func canManageDiscussionBroadcast(member domain.ChannelMember) bool { + return canChangeChannelInfo(member) +} + +func canManageDiscussionGroup(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) +} + +func validDiscussionGroup(channel domain.Channel) bool { + return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted +} + +func canAddChannelAdmins(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins) +} + +func canBanChannelUsers(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers) +} + +func canExportChannelInvite(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) +} + +func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool { + if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) { + return true + } + return channel.Megagroup && !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages +} + +func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { + if topic.CreatorUserID == userID { + return true + } + return canPinChannelMessages(channel, member) +} + +func canEditChannelMessage(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) +} + +func canDeleteAnyChannelMessage(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages) +} + +func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int { + if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return 0 + } + next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds + if now >= next { + return 0 + } + return next - now +} + +func boolPtr(v bool) *bool { + return &v +} + +func channelInitialAvailableMinID(channel domain.Channel) int { + if channel.PreHistoryHidden { + return channel.TopMessageID + } + return 0 +} + +func channelInitialAvailableMinPts(channel domain.Channel) int { + return channel.Pts +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { + return rights == domain.ChannelAdminRights{} +} + +func adminRightsSubset(want, have domain.ChannelAdminRights) bool { + return (!want.ChangeInfo || have.ChangeInfo) && + (!want.PostMessages || have.PostMessages) && + (!want.EditMessages || have.EditMessages) && + (!want.DeleteMessages || have.DeleteMessages) && + (!want.BanUsers || have.BanUsers) && + (!want.InviteUsers || have.InviteUsers) && + (!want.PinMessages || have.PinMessages) && + (!want.AddAdmins || have.AddAdmins) && + (!want.ManageCall || have.ManageCall) && + (!want.Anonymous || have.Anonymous) +} + +func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { + return rights == domain.ChannelBannedRights{} +} + +func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType { + if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages { + return domain.ChannelAdminLogParticipantKick + } + if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages { + return domain.ChannelAdminLogParticipantUnkick + } + if !zeroChannelBannedRights(next.BannedRights) { + return domain.ChannelAdminLogParticipantBan + } + return domain.ChannelAdminLogParticipantUnban +} + +func adminLogEventMatchesFilter(typ domain.ChannelAdminLogEventType, filter domain.ChannelAdminLogFilter) bool { + if filter.Empty() { + return true + } + switch typ { + case domain.ChannelAdminLogParticipantJoin: + return filter.Join + case domain.ChannelAdminLogParticipantLeave: + return filter.Leave + case domain.ChannelAdminLogParticipantInvite: + return filter.Invite || filter.Invites + case domain.ChannelAdminLogParticipantBan: + return filter.Ban + case domain.ChannelAdminLogParticipantUnban: + return filter.Unban + case domain.ChannelAdminLogParticipantKick: + return filter.Kick + case domain.ChannelAdminLogParticipantUnkick: + return filter.Unkick + case domain.ChannelAdminLogParticipantPromote: + return filter.Promote + case domain.ChannelAdminLogParticipantDemote: + return filter.Demote + case domain.ChannelAdminLogChangeTitle, domain.ChannelAdminLogChangeUsername, domain.ChannelAdminLogChangeLinkedChat, domain.ChannelAdminLogToggleSlowMode: + return filter.Info + case domain.ChannelAdminLogToggleSignatures, domain.ChannelAdminLogTogglePreHistoryHidden, domain.ChannelAdminLogToggleAntiSpam, domain.ChannelAdminLogToggleAutotranslation: + return filter.Settings + case domain.ChannelAdminLogToggleForum: + return filter.Settings || filter.Forums + case domain.ChannelAdminLogUpdatePinned: + return filter.Pinned + case domain.ChannelAdminLogEditMessage: + return filter.Edit + case domain.ChannelAdminLogDeleteMessage: + return filter.Delete + case domain.ChannelAdminLogSendMessage: + return filter.Send + default: + return false + } +} + +func adminLogEventMatchesQuery(event domain.ChannelAdminLogEvent, query string) bool { + if strings.Contains(strings.ToLower(event.PrevString), query) || + strings.Contains(strings.ToLower(event.NewString), query) || + strings.Contains(event.Query, query) { + return true + } + for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { + if msg != nil && strings.Contains(strings.ToLower(msg.Body), query) { + return true + } + } + return false +} + +func adminLogSearchText(event domain.ChannelAdminLogEvent) string { + parts := []string{ + event.Query, + event.PrevString, + event.NewString, + } + for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { + if msg != nil { + parts = append(parts, msg.Body) + } + } + return strings.ToLower(strings.TrimSpace(strings.Join(parts, " "))) +} + +func int64Set(items []int64) map[int64]struct{} { + if len(items) == 0 { + return nil + } + out := make(map[int64]struct{}, len(items)) + for _, item := range items { + if item != 0 { + out[item] = struct{}{} + } + } + return out +} + +func (s *ChannelStore) refreshChannelCountsLocked(channelID int64) { + channel := s.channels[channelID] + var participants, admins, kicked, banned int + for _, member := range s.members[channelID] { + if member.Status == domain.ChannelMemberKicked { + kicked++ + } + if member.Status != domain.ChannelMemberActive { + continue + } + participants++ + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + admins++ + } + if !zeroChannelBannedRights(member.BannedRights) { + banned++ + } + } + channel.ParticipantsCount = participants + channel.AdminsCount = admins + channel.KickedCount = kicked + channel.BannedCount = banned + s.channels[channelID] = channel +} + +func sameMessageEntities(a, b []domain.MessageEntity) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func diffFinal(returned, all []domain.ChannelUpdateEvent) bool { + if len(returned) == 0 { + return true + } + return returned[len(returned)-1].Pts >= all[len(all)-1].Pts +} + +func uniqueNonZero(ids []int64, exclude int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 || id == exclude { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func randomMemoryPositiveInt64() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, err + } + return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil +} + +func randomMemoryInviteHash() (string, error) { + var b [18]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func uniqueNonZeroInt64s(items ...int64) []int64 { + seen := make(map[int64]struct{}, len(items)) + out := make([]int64, 0, len(items)) + for _, item := range items { + if item == 0 { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} + +func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember { + return append([]domain.ChannelMember(nil), in...) +} + +func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult { + ids := make([]int64, 0, len(changed)) + for id := range changed { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))} + for _, id := range ids { + out.Channels = append(out.Channels, cloneChannel(changed[id])) + } + return out +} + +func cloneChannel(in domain.Channel) domain.Channel { + in.ReactionPolicy = copyChannelReactionPolicy(in.ReactionPolicy) + return in +} + +func copyChannelReactionPolicy(in domain.ChannelReactionPolicy) domain.ChannelReactionPolicy { + in.Emoticons = append([]string(nil), in.Emoticons...) + in.CustomEmojiIDs = append([]int64(nil), in.CustomEmojiIDs...) + return in +} + +func cloneChannelEvent(in domain.ChannelUpdateEvent) domain.ChannelUpdateEvent { + in.Message = cloneChannelMessage(in.Message) + in.MessageIDs = append([]int(nil), in.MessageIDs...) + in.UserIDs = append([]int64(nil), in.UserIDs...) + return in +} + +func cloneChannelForumTopic(in domain.ChannelForumTopic) domain.ChannelForumTopic { + return in +} + +func (s *ChannelStore) topicWithViewerCountersLocked(viewerUserID, channelID int64, topic domain.ChannelForumTopic, member domain.ChannelMember) domain.ChannelForumTopic { + out := cloneChannelForumTopic(topic) + out.UnreadCount = s.channelThreadUnreadCountLocked(viewerUserID, channelID, topic.TopicID, member.ReadInboxMaxID) + out.UnreadMentionsCount = s.countChannelUnreadMentionsLocked(viewerUserID, channelID, topic.TopicID) + out.UnreadReactionsCount = s.countChannelUnreadReactionsLocked(viewerUserID, channelID, topic.TopicID) + return out +} + +func (s *ChannelStore) updateForumTopicTopMessageLocked(channelID int64, msg domain.ChannelMessage) { + if msg.ReplyTo == nil || !msg.ReplyTo.ForumTopic || msg.ReplyTo.TopMessageID <= 0 { + return + } + topic, ok := s.topics[channelID][msg.ReplyTo.TopMessageID] + if !ok { + return + } + topic.TopMessageID = msg.ID + topic.Date = msg.Date + s.topics[channelID][topic.TopicID] = topic +} + +func sortForumTopics(topics []domain.ChannelForumTopic) { + sort.Slice(topics, func(i, j int) bool { + a, b := topics[i], topics[j] + if a.Pinned != b.Pinned { + return a.Pinned + } + if a.PinnedOrder != b.PinnedOrder { + return a.PinnedOrder > b.PinnedOrder + } + if a.Date != b.Date { + return a.Date > b.Date + } + return a.TopicID > b.TopicID + }) +} + +func forumTopicBeforeOrAtOffset(topic domain.ChannelForumTopic, filter domain.ChannelForumTopicFilter) bool { + if filter.OffsetDate == 0 && filter.OffsetID == 0 && filter.OffsetTopic == 0 { + return false + } + offsetID := filter.OffsetTopic + if offsetID == 0 { + offsetID = filter.OffsetID + } + if filter.OffsetDate != 0 { + if topic.Date < filter.OffsetDate { + return false + } + if topic.Date > filter.OffsetDate { + return true + } + } + if offsetID == 0 { + return false + } + return topic.TopicID >= offsetID +} + +func (s *ChannelStore) forumTopicRootMessagesLocked(channelID int64, topics []domain.ChannelForumTopic, availableMinID int) []domain.ChannelMessage { + if len(topics) == 0 { + return nil + } + wanted := make(map[int]struct{}, len(topics)) + for _, topic := range topics { + if topic.TopMessageID > 0 { + wanted[topic.TopMessageID] = struct{}{} + } + } + messages := make([]domain.ChannelMessage, 0, len(wanted)) + for _, msg := range s.messages[channelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= availableMinID { + continue + } + messages = append(messages, cloneChannelMessage(msg)) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + return messages +} + +func (s *ChannelStore) populateChannelMessageReactionsLocked(viewerUserID int64, channel domain.Channel, messages []domain.ChannelMessage) { + if len(messages) == 0 || channel.ID == 0 { + return + } + for i := range messages { + if messages[i].ChannelID != channel.ID || messages[i].ID <= 0 { + continue + } + reactions := s.channelMessageReactionsLocked(viewerUserID, channel, messages[i].ID) + if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { + continue + } + messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) + } +} + +func (s *ChannelStore) populateChannelMessagesReactionsLocked(viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) { + if len(messages) == 0 { + return + } + channelsByID := make(map[int64]domain.Channel, len(channels)) + for _, ch := range channels { + if ch.ID != 0 { + channelsByID[ch.ID] = ch + } + } + for i := range messages { + ch := channelsByID[messages[i].ChannelID] + if ch.ID == 0 { + ch = s.channels[messages[i].ChannelID] + } + if ch.ID == 0 { + continue + } + reactions := s.channelMessageReactionsLocked(viewerUserID, ch, messages[i].ID) + if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { + continue + } + messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) + } +} + +type memoryReactionCursor struct { + date int + userID int64 + emoticon string +} + +func (s *ChannelStore) channelMessageReactionsLocked(viewerUserID int64, channel domain.Channel, messageID int) domain.ChannelMessageReactions { + rows := s.channelMessageReactionRowsLocked(channel.ID, messageID, viewerUserID, nil) + out := domain.ChannelMessageReactions{ + CanSeeList: !channel.Broadcast || channel.Megagroup, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } + if len(rows) == 0 { + return out + } + type aggregate struct { + reaction domain.MessageReaction + count int + chosenOrder int + latestDate int + } + aggregates := make(map[string]*aggregate) + for _, row := range rows { + key := string(row.Reaction.Type) + "\x00" + row.Reaction.Emoticon + item := aggregates[key] + if item == nil { + item = &aggregate{reaction: row.Reaction} + aggregates[key] = item + } + item.count++ + if row.My && row.ChosenOrder > 0 { + item.chosenOrder = row.ChosenOrder + } + if row.Date > item.latestDate { + item.latestDate = row.Date + } + } + items := make([]aggregate, 0, len(aggregates)) + for _, item := range aggregates { + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].count != items[j].count { + return items[i].count > items[j].count + } + if items[i].latestDate != items[j].latestDate { + return items[i].latestDate > items[j].latestDate + } + return items[i].reaction.Emoticon < items[j].reaction.Emoticon + }) + for _, item := range items { + out.Results = append(out.Results, domain.ChannelMessageReactionCount{ + Reaction: item.reaction, + Count: item.count, + ChosenOrder: item.chosenOrder, + }) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + if rows[i].UserID != rows[j].UserID { + return rows[i].UserID > rows[j].UserID + } + return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon + }) + if len(rows) > domain.MaxChannelMessageReactionRecent { + rows = rows[:domain.MaxChannelMessageReactionRecent] + } + out.Recent = cloneChannelPeerReactions(rows) + return out +} + +func (s *ChannelStore) channelMessageReactionRowsLocked(channelID int64, messageID int, viewerUserID int64, filter *domain.MessageReaction) []domain.ChannelMessagePeerReaction { + byMessage := s.reactions[channelID] + if byMessage == nil { + return nil + } + byUser := byMessage[messageID] + if byUser == nil { + return nil + } + rows := make([]domain.ChannelMessagePeerReaction, 0, len(byUser)) + for _, userRows := range byUser { + for _, row := range userRows { + if filter != nil && (row.Reaction.Type != filter.Type || row.Reaction.Emoticon != filter.Emoticon) { + continue + } + row.My = row.UserID == viewerUserID + rows = append(rows, row) + } + } + return rows +} + +func memoryReactionOffset(row domain.ChannelMessagePeerReaction) string { + return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + row.Reaction.Emoticon +} + +func messageReactionKey(reaction domain.MessageReaction) string { + return string(reaction.Type) + "\x00" + reaction.Emoticon +} + +func parseMemoryReactionOffset(offset string) (memoryReactionCursor, bool) { + parts := strings.SplitN(offset, ":", 3) + if len(parts) != 3 { + return memoryReactionCursor{}, false + } + date, err := strconv.Atoi(parts[0]) + if err != nil || date < 0 { + return memoryReactionCursor{}, false + } + userID, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || userID < 0 { + return memoryReactionCursor{}, false + } + return memoryReactionCursor{date: date, userID: userID, emoticon: parts[2]}, true +} + +func memoryReactionAfterOffset(row domain.ChannelMessagePeerReaction, cursor memoryReactionCursor) bool { + if row.Date != cursor.date { + return row.Date < cursor.date + } + if row.UserID != cursor.userID { + return row.UserID < cursor.userID + } + return row.Reaction.Emoticon > cursor.emoticon +} + +func (s *ChannelStore) nextForumTopicPinnedOrderLocked(channelID int64) int { + next := 1 + for _, topic := range s.topics[channelID] { + if topic.PinnedOrder >= next { + next = topic.PinnedOrder + 1 + } + } + return next +} + +func (s *ChannelStore) topicHasVisibleMessagesLocked(channelID int64, topicID int) bool { + for _, msg := range s.messages[channelID] { + if msg.Deleted { + continue + } + if msg.ID == topicID || (msg.ReplyTo != nil && msg.ReplyTo.TopMessageID == topicID) { + return true + } + } + return false +} + +func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage { + in.Entities = append([]domain.MessageEntity(nil), in.Entities...) + in.ReplyTo = cloneMessageReply(in.ReplyTo) + in.Forward = cloneMessageForward(in.Forward) + in.Discussion = cloneChannelDiscussionRef(in.Discussion) + in.Replies = cloneChannelMessageReplies(in.Replies) + in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) + if in.SendAs != nil { + p := *in.SendAs + in.SendAs = &p + } + if in.Action != nil { + in.Action = cloneChannelMessageAction(in.Action) + } + return in +} + +func cloneChannelMessageAction(in *domain.ChannelMessageAction) *domain.ChannelMessageAction { + if in == nil { + return nil + } + out := *in + out.UserIDs = append([]int64(nil), in.UserIDs...) + if in.Closed != nil { + v := *in.Closed + out.Closed = &v + } + if in.Hidden != nil { + v := *in.Hidden + out.Hidden = &v + } + return &out +} + +func cloneChannelDiscussionRef(in *domain.ChannelDiscussionRef) *domain.ChannelDiscussionRef { + if in == nil { + return nil + } + out := *in + return &out +} + +func cloneChannelMessageReplies(in *domain.ChannelMessageReplies) *domain.ChannelMessageReplies { + if in == nil { + return nil + } + out := *in + out.RecentRepliers = append([]domain.Peer(nil), in.RecentRepliers...) + return &out +} + +func cloneChannelMessageReactionsPtr(in *domain.ChannelMessageReactions) *domain.ChannelMessageReactions { + if in == nil { + return nil + } + out := cloneChannelMessageReactions(*in) + return &out +} + +func cloneChannelMessageReactions(in domain.ChannelMessageReactions) domain.ChannelMessageReactions { + in.Results = append([]domain.ChannelMessageReactionCount(nil), in.Results...) + in.Recent = cloneChannelPeerReactions(in.Recent) + return in +} + +func cloneChannelPeerReactions(in []domain.ChannelMessagePeerReaction) []domain.ChannelMessagePeerReaction { + if len(in) == 0 { + return nil + } + return append([]domain.ChannelMessagePeerReaction(nil), in...) +} + +func ptrChannelMember(in domain.ChannelMember) *domain.ChannelMember { + out := in + return &out +} + +func ptrChannelMessage(in domain.ChannelMessage) *domain.ChannelMessage { + out := cloneChannelMessage(in) + return &out +} + +func cloneChannelAdminLogEvent(in domain.ChannelAdminLogEvent) domain.ChannelAdminLogEvent { + if in.PrevParticipant != nil { + in.PrevParticipant = ptrChannelMember(*in.PrevParticipant) + } + if in.NewParticipant != nil { + in.NewParticipant = ptrChannelMember(*in.NewParticipant) + } + if in.Participant != nil { + in.Participant = ptrChannelMember(*in.Participant) + } + if in.Message != nil { + in.Message = ptrChannelMessage(*in.Message) + } + if in.PrevMessage != nil { + in.PrevMessage = ptrChannelMessage(*in.PrevMessage) + } + if in.NewMessage != nil { + in.NewMessage = ptrChannelMessage(*in.NewMessage) + } + return in +} diff --git a/internal/store/memory/channel_test.go b/internal/store/memory/channel_test.go new file mode 100644 index 00000000..7e2fae39 --- /dev/null +++ b/internal/store/memory/channel_test.go @@ -0,0 +1,714 @@ +package memory + +import ( + "context" + "errors" + "reflect" + "testing" + + "telesrv/internal/domain" +) + +func TestChannelRealtimeRecipientsAreCapped(t *testing.T) { + store := NewChannelStore() + memberIDs := make([]int64, domain.MaxChannelRealtimeFanout+25) + for i := range memberIDs { + memberIDs[i] = int64(10_000 + i) + } + + created, err := store.CreateChannel(context.Background(), domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "large realtime cap", + Megagroup: true, + MemberUserIDs: memberIDs, + Date: 1_700_000_100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if got := len(created.Recipients); got != domain.MaxChannelRealtimeFanout { + t.Fatalf("create recipients = %d, want capped %d", got, domain.MaxChannelRealtimeFanout) + } + + recipients, err := store.ListActiveChannelMemberIDs(context.Background(), 1, created.Channel.ID, 0) + if err != nil { + t.Fatalf("list active members: %v", err) + } + if got := len(recipients); got != domain.MaxChannelRealtimeFanout { + t.Fatalf("listed active members = %d, want capped %d", got, domain.MaxChannelRealtimeFanout) + } +} + +func TestPendingJoinRequestsSummaryAndInviteAdmins(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "pending join requests", + Megagroup: true, + MemberUserIDs: []int64{2, 3, 4}, + Date: 1_700_000_150, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID := created.Channel.ID + if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: 1, + ChannelID: channelID, + MemberID: 2, + AdminRights: domain.ChannelAdminRights{ + InviteUsers: true, + }, + Date: 1_700_000_151, + }); err != nil { + t.Fatalf("promote invite admin: %v", err) + } + if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: 1, + ChannelID: channelID, + MemberID: 4, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 1_700_000_152, + }); err != nil { + t.Fatalf("promote change-info admin: %v", err) + } + invite, err := store.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: 1, + ChannelID: channelID, + Title: "approval", + RequestNeeded: true, + Date: 1_700_000_153, + }) + if err != nil { + t.Fatalf("export invite: %v", err) + } + for i := 0; i < domain.MaxChannelPendingJoinRecentRequesters+2; i++ { + _, err := store.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: int64(10 + i), + Hash: invite.Invite.Hash, + Date: 1_700_000_160 + i, + }) + if !errors.Is(err, domain.ErrInviteRequestSent) { + t.Fatalf("import pending %d err = %v, want ErrInviteRequestSent", i, err) + } + } + pending, err := store.PendingJoinRequests(ctx, channelID, 99) + if err != nil { + t.Fatalf("pending join requests: %v", err) + } + if pending.Count != domain.MaxChannelPendingJoinRecentRequesters+2 || len(pending.RecentRequesters) != domain.MaxChannelPendingJoinRecentRequesters { + t.Fatalf("pending summary = %+v, want bounded recent with full count", pending) + } + if pending.RecentRequesters[0] != 16 || pending.RecentRequesters[len(pending.RecentRequesters)-1] != 12 { + t.Fatalf("recent requesters = %+v, want newest first", pending.RecentRequesters) + } + admins, err := store.ListChannelInviteAdminMemberIDs(ctx, channelID, 0) + if err != nil { + t.Fatalf("invite admins: %v", err) + } + want := []int64{1, 2, 4} + if !reflect.DeepEqual(admins, want) { + t.Fatalf("invite admins = %+v, want %+v", admins, want) + } +} + +func TestCommonChannelsOnlySharedMegagroups(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + first, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "common one", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_170, + }) + if err != nil { + t.Fatalf("create first common channel: %v", err) + } + second, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "common two", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_171, + }) + if err != nil { + t.Fatalf("create second common channel: %v", err) + } + if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "broadcast excluded", + Broadcast: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_172, + }); err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + left, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "left excluded", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_173, + }) + if err != nil { + t.Fatalf("create left channel: %v", err) + } + if _, err := store.LeaveChannel(ctx, left.Channel.ID, 2, 1_700_000_174); err != nil { + t.Fatalf("leave channel: %v", err) + } + if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "not shared", + Megagroup: true, + MemberUserIDs: []int64{3}, + Date: 1_700_000_175, + }); err != nil { + t.Fatalf("create non-shared channel: %v", err) + } + + page, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: 1, + TargetUserID: 2, + Limit: 10, + }) + if err != nil { + t.Fatalf("list common channels: %v", err) + } + if page.Count != 2 || len(page.Channels) != 2 || page.Channels[0].ID != first.Channel.ID || page.Channels[1].ID != second.Channel.ID { + t.Fatalf("common channels = %+v, want two shared megagroups in id order", page) + } + + next, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: 1, + TargetUserID: 2, + MaxID: first.Channel.ID, + Limit: 1, + }) + if err != nil { + t.Fatalf("list common channels after max id: %v", err) + } + if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].ID != second.Channel.ID { + t.Fatalf("paged common channels = %+v, want second channel with full count", next) + } + + countOnly, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: 1, + TargetUserID: 2, + CountOnly: true, + }) + if err != nil { + t.Fatalf("count common channels: %v", err) + } + if countOnly.Count != 2 || len(countOnly.Channels) != 0 { + t.Fatalf("count-only common channels = %+v, want count without channels", countOnly) + } +} + +func TestLeftChannelsReturnsPagedLeftMemberships(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + older, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "older left", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_180, + }) + if err != nil { + t.Fatalf("create older channel: %v", err) + } + newer, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "newer left broadcast", + Broadcast: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_181, + }) + if err != nil { + t.Fatalf("create newer channel: %v", err) + } + if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "active excluded", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_182, + }); err != nil { + t.Fatalf("create active channel: %v", err) + } + if _, err := store.LeaveChannel(ctx, older.Channel.ID, 2, 1_700_000_183); err != nil { + t.Fatalf("leave older channel: %v", err) + } + if _, err := store.LeaveChannel(ctx, newer.Channel.ID, 2, 1_700_000_184); err != nil { + t.Fatalf("leave newer channel: %v", err) + } + + page, err := store.ListLeftChannels(ctx, 2, 0, 1) + if err != nil { + t.Fatalf("list left channels: %v", err) + } + if page.Count != 2 || len(page.Channels) != 1 || page.Channels[0].Channel.ID != newer.Channel.ID || page.Channels[0].Self.Status != domain.ChannelMemberLeft { + t.Fatalf("first left page = %+v, want newest left channel and full count", page) + } + next, err := store.ListLeftChannels(ctx, 2, 1, 1) + if err != nil { + t.Fatalf("list next left channels: %v", err) + } + if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].Channel.ID != older.Channel.ID { + t.Fatalf("second left page = %+v, want older left channel", next) + } + empty, err := store.ListLeftChannels(ctx, 2, 2, 1) + if err != nil { + t.Fatalf("list empty left page: %v", err) + } + if empty.Count != 2 || len(empty.Channels) != 0 { + t.Fatalf("empty left page = %+v, want full count and no chats", empty) + } + if _, err := store.ListLeftChannels(ctx, 2, domain.MaxLeftChannelsOffset+1, 1); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("huge offset err = %v, want ErrChannelInvalid", err) + } +} + +func TestDiscussionGroupLinksAreBidirectionalAndReplaceOldLinks(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "broadcast", + Broadcast: true, + Date: 1_700_000_190, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + firstGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "first group", + Megagroup: true, + Date: 1_700_000_191, + }) + if err != nil { + t.Fatalf("create first group: %v", err) + } + secondGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "second group", + Megagroup: true, + Date: 1_700_000_192, + }) + if err != nil { + t.Fatalf("create second group: %v", err) + } + if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "broadcast excluded", + Broadcast: true, + Date: 1_700_000_193, + }); err != nil { + t.Fatalf("create excluded broadcast: %v", err) + } + + candidates, err := store.ListDiscussionGroups(ctx, 1, 10) + if err != nil { + t.Fatalf("list discussion groups: %v", err) + } + if len(candidates) != 2 || candidates[0].ID != secondGroup.Channel.ID || candidates[1].ID != firstGroup.Channel.ID { + t.Fatalf("discussion candidates = %+v, want creator megagroups newest id first", candidates) + } + + linked, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID) + if err != nil { + t.Fatalf("link first group: %v", err) + } + if len(linked.Channels) != 2 { + t.Fatalf("linked changed channels = %+v, want broadcast and group", linked.Channels) + } + gotBroadcast, err := store.GetChannelByID(ctx, broadcast.Channel.ID) + if err != nil { + t.Fatalf("get linked broadcast: %v", err) + } + gotFirst, err := store.GetChannelByID(ctx, firstGroup.Channel.ID) + if err != nil { + t.Fatalf("get linked first group: %v", err) + } + if gotBroadcast.LinkedChatID != firstGroup.Channel.ID || gotFirst.LinkedChatID != broadcast.Channel.ID { + t.Fatalf("first link = broadcast %+v group %+v, want bidirectional ids", gotBroadcast, gotFirst) + } + + replaced, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, secondGroup.Channel.ID) + if err != nil { + t.Fatalf("replace discussion group: %v", err) + } + if len(replaced.Channels) != 3 { + t.Fatalf("replace changed channels = %+v, want broadcast, old group, new group", replaced.Channels) + } + gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID) + gotFirst, _ = store.GetChannelByID(ctx, firstGroup.Channel.ID) + gotSecond, err := store.GetChannelByID(ctx, secondGroup.Channel.ID) + if err != nil { + t.Fatalf("get linked second group: %v", err) + } + if gotBroadcast.LinkedChatID != secondGroup.Channel.ID || gotSecond.LinkedChatID != broadcast.Channel.ID || gotFirst.LinkedChatID != 0 { + t.Fatalf("replace link = broadcast %d first %d second %d, want old cleared and new bidirectional", + gotBroadcast.LinkedChatID, gotFirst.LinkedChatID, gotSecond.LinkedChatID) + } + + unlinked, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID) + if err != nil { + t.Fatalf("unlink from group side: %v", err) + } + if len(unlinked.Channels) != 2 { + t.Fatalf("unlink changed channels = %+v, want broadcast and group", unlinked.Channels) + } + gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID) + gotSecond, _ = store.GetChannelByID(ctx, secondGroup.Channel.ID) + if gotBroadcast.LinkedChatID != 0 || gotSecond.LinkedChatID != 0 { + t.Fatalf("unlink = broadcast %d second %d, want both cleared", gotBroadcast.LinkedChatID, gotSecond.LinkedChatID) + } + if _, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID); !errors.Is(err, domain.ErrLinkNotModified) { + t.Fatalf("repeat unlink err = %v, want ErrLinkNotModified", err) + } + if _, err := store.SetPreHistoryHidden(ctx, 1, firstGroup.Channel.ID, true); err != nil { + t.Fatalf("hide first group prehistory: %v", err) + } + if _, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID); !errors.Is(err, domain.ErrMegagroupPrehistoryHidden) { + t.Fatalf("hidden group link err = %v, want ErrMegagroupPrehistoryHidden", err) + } +} + +func TestChannelDeleteHistoryCapsHugeMaxID(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "bounded delete history", + Megagroup: true, + Date: 1_700_000_200, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + + totalMessages := domain.MaxDeleteHistoryBatch + 2 + for i := 0; i < totalMessages; i++ { + if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: int64(10_000 + i), + Message: "bulk", + Date: 1_700_000_201 + i, + }); err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + } + + first, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: int(^uint(0) >> 1), + ForEveryone: true, + Date: 1_700_001_300, + }) + if err != nil { + t.Fatalf("delete first batch: %v", err) + } + if first.Offset != 1 || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch || first.Event.PtsCount != domain.MaxDeleteHistoryBatch { + t.Fatalf("first batch = %+v, want capped page with offset", first) + } + + second, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: int(^uint(0) >> 1), + ForEveryone: true, + Date: 1_700_001_301, + }) + if err != nil { + t.Fatalf("delete second batch: %v", err) + } + if second.Offset != 0 || len(second.DeletedIDs) != 3 || second.Event.PtsCount != 3 { + t.Fatalf("second batch = %+v, want final bounded page", second) + } +} + +func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "monotonic local clear", + Megagroup: true, + Date: 1_700_000_250, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + first, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 30_001, + Message: "first visible", + Date: 1_700_000_251, + }) + if err != nil { + t.Fatalf("send first message: %v", err) + } + second, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 30_002, + Message: "second visible", + Date: 1_700_000_252, + }) + if err != nil { + t.Fatalf("send second message: %v", err) + } + + high, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: second.Message.ID, + Date: 1_700_000_253, + }) + if err != nil { + t.Fatalf("clear high watermark: %v", err) + } + if high.AvailableMinID != second.Message.ID { + t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID) + } + + stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: first.Message.ID, + Date: 1_700_000_254, + }) + if err != nil { + t.Fatalf("clear stale low watermark: %v", err) + } + if stale.AvailableMinID != second.Message.ID { + t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID) + } + + history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("list history: %v", err) + } + if len(history.Messages) != 0 { + t.Fatalf("history after stale clear = %+v, want no visible messages", history.Messages) + } + dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get channel dialog: %v", err) + } + if len(dialogs.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs) + } + if dialogs.Dialogs[0].TopMessage != 0 || dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID || dialogs.Dialogs[0].UnreadCount != 0 { + t.Fatalf("dialog after stale clear = %+v, want top=0 read=%d unread=0", dialogs.Dialogs[0], second.Message.ID) + } +} + +func TestChannelListDialogsDerivesRecipientTopWithoutWriteFanout(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "single copy dialog top", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_300, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: 2, + ChannelID: created.Channel.ID, + MaxID: created.Message.ID, + Date: 1_700_000_301, + }); err != nil { + t.Fatalf("read initial service message: %v", err) + } + + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 88, + Message: "visible without write fanout", + Date: 1_700_000_302, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + list, err := store.ListChannelDialogs(ctx, 2, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list recipient channel dialogs: %v", err) + } + if len(list.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want one channel dialog", list.Dialogs) + } + dialog := list.Dialogs[0] + if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date || dialog.UnreadCount != 1 { + t.Fatalf("recipient dialog = %+v, want top sent message and unread=1", dialog) + } + if len(list.Messages) != 1 || list.Messages[0].ID != sent.Message.ID { + t.Fatalf("dialog messages = %+v, want sent top message", list.Messages) + } +} + +func TestChannelUnreadExcludesOwnOutgoing(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "own outgoing unread", + Megagroup: true, + Date: 1_700_000_360, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 36_001, + Message: "own outgoing only", + Date: 1_700_000_361, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + store.mu.Lock() + member := store.members[created.Channel.ID][1] + member.ReadInboxMaxID = sent.Message.ID - 1 + store.members[created.Channel.ID][1] = member + dialog := store.dialogs[1][created.Channel.ID] + dialog.ReadInboxMaxID = sent.Message.ID - 1 + dialog.UnreadCount = 99 + store.dialogs[1][created.Channel.ID] = dialog + store.mu.Unlock() + + dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 0 { + t.Fatalf("dialogs = %+v, want own outgoing excluded from unread", dialogs.Dialogs) + } + read, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 1_700_000_362, + }) + if err != nil { + t.Fatalf("read channel history: %v", err) + } + if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 { + t.Fatalf("read result = %+v, want no own-outgoing unread", read) + } +} + +func TestChannelReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "visible unread reaction", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_400, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 40_001, + Message: "react to this", + Date: 1_700_000_401, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: 2, + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Reactions: []domain.MessageReaction{{ + Type: domain.MessageReactionEmoji, + Emoticon: "\U0001f525", + }}, + Date: 1_700_000_402, + }); err != nil { + t.Fatalf("set channel reaction: %v", err) + } + dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get owner channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 1 { + t.Fatalf("owner dialogs = %+v, want one unread reaction", dialogs.Dialogs) + } + unread, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{ + ChannelID: created.Channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread reactions: %v", err) + } + if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID { + t.Fatalf("unread reactions = %+v, want sent message", unread.Messages) + } + if unread.Messages[0].Reactions == nil || !hasUnreadChannelReaction(*unread.Messages[0].Reactions) { + t.Fatalf("unread message reactions = %+v, want unread recent reaction", unread.Messages[0].Reactions) + } + + read, err := store.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + IDs: []int{sent.Message.ID}, + }) + if err != nil { + t.Fatalf("read channel message contents: %v", err) + } + if !reflect.DeepEqual(read.ClearedUnreadReactionMessageIDs, []int{sent.Message.ID}) { + t.Fatalf("cleared reaction ids = %+v, want [%d]", read.ClearedUnreadReactionMessageIDs, sent.Message.ID) + } + if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || hasUnreadChannelReaction(*read.Messages[0].Reactions) { + t.Fatalf("read messages = %+v, want reaction returned as read", read.Messages) + } + unreadAfter, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{ + ChannelID: created.Channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread reactions after read contents: %v", err) + } + if len(unreadAfter.Messages) != 0 { + t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter.Messages) + } + dialogsAfter, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get dialogs after read contents: %v", err) + } + if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 { + t.Fatalf("dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs) + } +} + +func hasUnreadChannelReaction(reactions domain.ChannelMessageReactions) bool { + for _, recent := range reactions.Recent { + if recent.Unread { + return true + } + } + return false +} diff --git a/internal/store/memory/dialog_test.go b/internal/store/memory/dialog_test.go new file mode 100644 index 00000000..72629375 --- /dev/null +++ b/internal/store/memory/dialog_test.go @@ -0,0 +1,229 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestDialogStoreFiltersAndPaginates(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + list := domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000, Pinned: true}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, TopMessage: 8, TopMessageDate: 800}, + }, + Messages: []domain.Message{ + {ID: 10, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Body: "pinned"}, + {ID: 9, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, Body: "first"}, + {ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, Body: "second"}, + }, + } + if err := store.SaveList(ctx, userID, list); err != nil { + t.Fatalf("SaveList: %v", err) + } + + first, err := store.ListByUser(ctx, userID, domain.DialogFilter{ExcludePinned: true, Limit: 1}) + if err != nil { + t.Fatalf("ListByUser first page: %v", err) + } + if first.Count != 2 || len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != 2 || len(first.Messages) != 1 || first.Messages[0].ID != 9 { + t.Fatalf("first page = %+v, want peer 2 with count 2 and top message", first) + } + + next, err := store.ListByUser(ctx, userID, domain.DialogFilter{ + ExcludePinned: true, + OffsetDate: first.Dialogs[0].TopMessageDate, + OffsetID: first.Dialogs[0].TopMessage, + HasOffsetPeer: true, + OffsetPeer: first.Dialogs[0].Peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListByUser next page: %v", err) + } + if next.Count != 2 || len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != 3 || len(next.Messages) != 1 || next.Messages[0].ID != 8 { + t.Fatalf("next page = %+v, want peer 3 with count 2 and top message", next) + } +} + +func TestDialogStoreFoldersAndCustomFilters(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + contactPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1} + archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2} + strangerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 3} + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: contactPeer, TopMessage: 10, TopMessageDate: 1000}, + {Peer: archivedPeer, TopMessage: 9, TopMessageDate: 900, FolderID: domain.DialogArchiveFolderID}, + {Peer: strangerPeer, TopMessage: 8, TopMessageDate: 800, UnreadCount: 1}, + }, + Users: []domain.User{ + {ID: contactPeer.ID, Contact: true}, + {ID: archivedPeer.ID, Contact: true}, + {ID: strangerPeer.ID}, + }, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + main, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser main: %v", err) + } + if len(main.Dialogs) != 2 || main.Dialogs[0].Peer != contactPeer || main.Dialogs[1].Peer != strangerPeer { + t.Fatalf("main dialogs = %+v, want non-archived dialogs", main.Dialogs) + } + archive, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser archive: %v", err) + } + if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer != archivedPeer { + t.Fatalf("archive dialogs = %+v, want archived peer", archive.Dialogs) + } + + folder := domain.DialogFolder{ + ID: 2, + Contacts: true, + ExcludeArchived: true, + IncludePeers: []domain.DialogFolderPeer{{Peer: strangerPeer}}, + } + if err := store.UpsertFolder(ctx, userID, folder); err != nil { + t.Fatalf("UpsertFolder: %v", err) + } + custom, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: 2, Folder: &folder, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser custom: %v", err) + } + if len(custom.Dialogs) != 2 || custom.Dialogs[0].Peer != contactPeer || custom.Dialogs[1].Peer != strangerPeer { + t.Fatalf("custom dialogs = %+v, want contact plus explicit stranger excluding archived", custom.Dialogs) + } +} + +func TestUserStoreStartsAtTimestampBase(t *testing.T) { + ctx := context.Background() + store := NewUserStore() + u, err := store.Create(ctx, domain.User{ + AccessHash: 1, + Phone: "15550000001", + FirstName: "Test", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if u.ID != domain.UserIDSequenceBase { + t.Fatalf("user id = %d, want base %d", u.ID, domain.UserIDSequenceBase) + } +} + +func TestDialogStoreOffsetDateOnlyKeepsEnterpriseCountAndHash(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, TopMessage: 8, TopMessageDate: 800}, + }, + Messages: []domain.Message{ + {ID: 10, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Body: "first"}, + {ID: 9, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, Body: "second"}, + {ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, Body: "third"}, + }, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + all, err := store.ListByUser(ctx, userID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser all: %v", err) + } + page, err := store.ListByUser(ctx, userID, domain.DialogFilter{OffsetDate: 900, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser offset date: %v", err) + } + if page.Count != 3 || page.Hash != all.Hash { + t.Fatalf("page summary = count %d hash %d, want full count/hash %d/%d", page.Count, page.Hash, all.Count, all.Hash) + } + if len(page.Dialogs) != 1 || page.Dialogs[0].Peer.ID != 3 || len(page.Messages) != 1 || page.Messages[0].ID != 8 { + t.Fatalf("page = %+v, want only dialog after offset date", page) + } +} + +func TestDialogStoreEmptyPageKeepsEnterpriseCountAndHash(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900}, + }, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + all, err := store.ListByUser(ctx, userID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser all: %v", err) + } + empty, err := store.ListByUser(ctx, userID, domain.DialogFilter{ + OffsetDate: 900, + OffsetID: 9, + HasOffsetPeer: true, + OffsetPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListByUser empty page: %v", err) + } + if empty.Count != 2 || empty.Hash != all.Hash || len(empty.Dialogs) != 0 { + t.Fatalf("empty page = %+v, want no page rows but full count/hash", empty) + } +} + +func TestDialogStoreListByPeersReturnsExistingAndPlaceholders(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + official := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} + missing := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: official, TopMessage: 10, TopMessageDate: 1000, UnreadCount: 1}, + }, + Messages: []domain.Message{ + {ID: 10, Peer: official, From: official, Body: "login"}, + }, + Users: []domain.User{domain.OfficialSystemUser()}, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + got, err := store.ListByPeers(ctx, userID, []domain.Peer{official, missing, official}) + if err != nil { + t.Fatalf("ListByPeers: %v", err) + } + if got.Count != 2 || len(got.Dialogs) != 2 { + t.Fatalf("dialogs = %+v, want existing official and missing placeholder", got) + } + if got.Dialogs[0].Peer != official || got.Dialogs[0].TopMessage != 10 { + t.Fatalf("first dialog = %+v, want official top message", got.Dialogs[0]) + } + if got.Dialogs[1].Peer != missing || got.Dialogs[1].TopMessage != 0 { + t.Fatalf("second dialog = %+v, want missing placeholder", got.Dialogs[1]) + } + if len(got.Messages) != 1 || got.Messages[0].ID != 10 { + t.Fatalf("messages = %+v, want only official top message", got.Messages) + } + if len(got.Users) != 1 || got.Users[0].ID != domain.OfficialSystemUserID { + t.Fatalf("users = %+v, want official user", got.Users) + } +} diff --git a/internal/store/memory/memory.go b/internal/store/memory/memory.go new file mode 100644 index 00000000..28860be1 --- /dev/null +++ b/internal/store/memory/memory.go @@ -0,0 +1,2883 @@ +// Package memory 提供 store 各接口的内存实现,用作测试替身与本地开发兜底。 +// +// 与 store/postgres、store/redisstore 对称:store 主包只定义接口与 DTO, +// 三种后端实现各自独立成包。 +package memory + +import ( + "context" + "encoding/binary" + "fmt" + "hash" + "hash/fnv" + "sort" + "strings" + "sync" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// AuthKeyStore 是 store.AuthKeyStore 的内存实现。 +type AuthKeyStore struct { + mu sync.RWMutex + keys map[[8]byte]store.AuthKeyData +} + +// NewAuthKeyStore 创建内存 AuthKeyStore。 +func NewAuthKeyStore() *AuthKeyStore { + return &AuthKeyStore{keys: make(map[[8]byte]store.AuthKeyData)} +} + +func (s *AuthKeyStore) Save(_ context.Context, k store.AuthKeyData) error { + s.mu.Lock() + s.keys[k.ID] = k + s.mu.Unlock() + return nil +} + +func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bool, error) { + s.mu.RLock() + k, ok := s.keys[id] + s.mu.RUnlock() + return k, ok, nil +} + +// SessionStore 是 store.SessionStore 的内存实现。 +type SessionStore struct { + mu sync.RWMutex + sessions map[int64]store.SessionData +} + +// NewSessionStore 创建内存 SessionStore。 +func NewSessionStore() *SessionStore { + return &SessionStore{sessions: make(map[int64]store.SessionData)} +} + +func (s *SessionStore) Save(_ context.Context, d store.SessionData) error { + s.mu.Lock() + s.sessions[d.ID] = d + s.mu.Unlock() + return nil +} + +func (s *SessionStore) Get(_ context.Context, id int64) (store.SessionData, bool, error) { + s.mu.RLock() + d, ok := s.sessions[id] + s.mu.RUnlock() + return d, ok, nil +} + +func (s *SessionStore) Delete(_ context.Context, id int64) error { + s.mu.Lock() + delete(s.sessions, id) + s.mu.Unlock() + return nil +} + +// UpdateStateStore 是 store.UpdateStateStore 的内存实现。 +type UpdateStateStore struct { + mu sync.RWMutex + states map[updateStateKey]domain.UpdateState +} + +// UpdateEventStore 是 store.UpdateEventStore 的内存实现。 +type UpdateEventStore struct { + mu sync.RWMutex + events map[int64][]domain.UpdateEvent +} + +type updateStateKey struct { + authKeyID [8]byte + userID int64 +} + +// NewUpdateEventStore 创建内存 UpdateEventStore。 +func NewUpdateEventStore() *UpdateEventStore { + return &UpdateEventStore{events: make(map[int64][]domain.UpdateEvent)} +} + +func (s *UpdateEventStore) Append(_ context.Context, userID int64, event domain.UpdateEvent) error { + event.UserID = userID + event.Message = cloneMessage(event.Message) + event.MessageIDs = append([]int(nil), event.MessageIDs...) + event.Peers = append([]domain.Peer(nil), event.Peers...) + event.Users = append([]domain.User(nil), event.Users...) + event.Channels = append([]domain.Channel(nil), event.Channels...) + s.mu.Lock() + s.events[userID] = append(s.events[userID], event) + s.mu.Unlock() + return nil +} + +func (s *UpdateEventStore) ListAfter(_ context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) { + s.mu.RLock() + items := append([]domain.UpdateEvent(nil), s.events[userID]...) + s.mu.RUnlock() + out := make([]domain.UpdateEvent, 0, len(items)) + for _, event := range items { + if event.Pts <= pts { + continue + } + event.Message = cloneMessage(event.Message) + event.MessageIDs = append([]int(nil), event.MessageIDs...) + event.Peers = append([]domain.Peer(nil), event.Peers...) + event.Users = append([]domain.User(nil), event.Users...) + event.Channels = append([]domain.Channel(nil), event.Channels...) + out = append(out, event) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func (s *UpdateEventStore) Current(_ context.Context, userID int64) (int, error) { + s.mu.RLock() + items := s.events[userID] + s.mu.RUnlock() + max := 0 + for _, event := range items { + if event.Pts > max { + max = event.Pts + } + } + return max, nil +} + +// MaxContiguousPts 返回从 1 起无空洞的最大 pts(内存版按 pts_count 连续扫描)。 +func (s *UpdateEventStore) MaxContiguousPts(_ context.Context, userID int64) (int, error) { + s.mu.RLock() + nextByStart := make(map[int]int, len(s.events[userID])) + for _, event := range s.events[userID] { + count := event.PtsCount + if count <= 0 { + count = 1 + } + nextByStart[event.Pts-count] = event.Pts + } + s.mu.RUnlock() + contiguous := 0 + for { + next, ok := nextByStart[contiguous] + if !ok { + break + } + contiguous = next + } + return contiguous, nil +} + +func (s *UpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) { + return s.MaxContiguousPts(ctx, userID) +} + +// NewUpdateStateStore 创建内存 UpdateStateStore。 +func NewUpdateStateStore() *UpdateStateStore { + return &UpdateStateStore{states: make(map[updateStateKey]domain.UpdateState)} +} + +func (s *UpdateStateStore) Get(_ context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) { + s.mu.RLock() + st, ok := s.states[updateStateKey{authKeyID: id, userID: userID}] + s.mu.RUnlock() + return st, ok, nil +} + +func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error { + s.mu.Lock() + s.states[updateStateKey{authKeyID: id, userID: userID}] = st + s.mu.Unlock() + return nil +} + +func (s *UpdateStateStore) Delete(_ context.Context, id [8]byte, userID int64) error { + s.mu.Lock() + delete(s.states, updateStateKey{authKeyID: id, userID: userID}) + s.mu.Unlock() + return nil +} + +func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error { + s.mu.Lock() + for k := range s.states { + if k.authKeyID == id { + delete(s.states, k) + } + } + s.mu.Unlock() + return nil +} + +// TempAuthKeyBindingStore 是 store.TempAuthKeyBindingStore 的内存实现。 +type TempAuthKeyBindingStore struct { + mu sync.RWMutex + m map[[8]byte]domain.TempAuthKeyBinding +} + +// NewTempAuthKeyBindingStore 创建内存 TempAuthKeyBindingStore。 +func NewTempAuthKeyBindingStore() *TempAuthKeyBindingStore { + return &TempAuthKeyBindingStore{m: make(map[[8]byte]domain.TempAuthKeyBinding)} +} + +func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBinding) error { + b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...) + s.mu.Lock() + s.m[b.TempAuthKeyID] = b + s.mu.Unlock() + return nil +} + +func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) { + s.mu.RLock() + b, ok := s.m[tempAuthKeyID] + s.mu.RUnlock() + if !ok { + return domain.TempAuthKeyBinding{}, false, nil + } + b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...) + return b, true, nil +} + +// ContactStore 是 store.ContactStore 的内存实现。 +type ContactStore struct { + mu sync.RWMutex + m map[int64]domain.ContactList +} + +// NewContactStore 创建内存 ContactStore。 +func NewContactStore() *ContactStore { + return &ContactStore{m: make(map[int64]domain.ContactList)} +} + +func (s *ContactStore) ListByUser(_ context.Context, userID int64) (domain.ContactList, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + list.Contacts = cloneContacts(list.Contacts) + list.Hash = contactListHash(list.Contacts) + return list, nil +} + +func (s *ContactStore) Get(_ context.Context, userID, contactUserID int64) (domain.Contact, bool, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + for _, contact := range list.Contacts { + if contact.User.ID == contactUserID { + return cloneContact(contact), true, nil + } + } + return domain.Contact{}, false, nil +} + +func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { + contact := domain.Contact{ + User: domain.User{ + ID: input.ContactUserID, + Phone: input.Phone, + FirstName: input.FirstName, + LastName: input.LastName, + Contact: true, + }, + FirstName: input.FirstName, + LastName: input.LastName, + Phone: input.Phone, + Note: input.Note, + NoteEntities: append([]domain.MessageEntity(nil), input.NoteEntities...), + } + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + reverse := s.m[input.ContactUserID] + for i := range reverse.Contacts { + if reverse.Contacts[i].User.ID == userID { + reverse.Contacts[i].Mutual = true + reverse.Contacts[i].User.Mutual = true + contact.Mutual = true + contact.User.Mutual = true + s.m[input.ContactUserID] = reverse + break + } + } + for i, existing := range list.Contacts { + if existing.User.ID != input.ContactUserID { + continue + } + contact.User.AccessHash = existing.User.AccessHash + contact.User.Username = existing.User.Username + contact.User.CountryCode = existing.User.CountryCode + contact.User.Verified = existing.User.Verified + contact.User.Support = existing.User.Support + if contact.Phone == "" { + contact.User.Phone = existing.User.Phone + } + if contact.FirstName == "" { + contact.User.FirstName = existing.User.FirstName + } + if contact.LastName == "" { + contact.User.LastName = existing.User.LastName + } + list.Contacts[i] = contact + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return cloneContact(contact), nil + } + list.Contacts = append(list.Contacts, contact) + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return cloneContact(contact), nil +} + +func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) { + if len(inputs) == 0 { + return nil, nil + } + out := make([]domain.Contact, 0, len(inputs)) + for _, input := range inputs { + contact, err := s.Upsert(ctx, userID, input) + if err != nil { + return nil, err + } + out = append(out, contact) + } + return out, nil +} + +func (s *ContactStore) UpdateNote(_ context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Contacts { + if list.Contacts[i].User.ID != contactUserID { + continue + } + list.Contacts[i].Note = note + list.Contacts[i].NoteEntities = append([]domain.MessageEntity(nil), entities...) + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return cloneContact(list.Contacts[i]), true, nil + } + return domain.Contact{}, false, nil +} + +func (s *ContactStore) Delete(_ context.Context, userID int64, contactUserIDs []int64) (int, error) { + remove := make(map[int64]struct{}, len(contactUserIDs)) + for _, id := range contactUserIDs { + if id != 0 { + remove[id] = struct{}{} + } + } + if len(remove) == 0 { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + out := list.Contacts[:0] + deleted := 0 + for _, contact := range list.Contacts { + if _, ok := remove[contact.User.ID]; ok { + deleted++ + if reverse := s.m[contact.User.ID]; len(reverse.Contacts) > 0 { + for i := range reverse.Contacts { + if reverse.Contacts[i].User.ID == userID { + reverse.Contacts[i].Mutual = false + reverse.Contacts[i].User.Mutual = false + } + } + reverse.Hash = contactListHash(reverse.Contacts) + s.m[contact.User.ID] = reverse + } + continue + } + out = append(out, contact) + } + list.Contacts = out + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return deleted, nil +} + +// SaveList 保存一份用户通讯录,供测试和本地替身使用。 +func (s *ContactStore) SaveList(_ context.Context, userID int64, list domain.ContactList) error { + list.Contacts = cloneContacts(list.Contacts) + list.Hash = contactListHash(list.Contacts) + s.mu.Lock() + s.m[userID] = list + s.mu.Unlock() + return nil +} + +// DialogStore 是 store.DialogStore 的内存实现。 +type DialogStore struct { + mu sync.RWMutex + m map[int64]domain.DialogList + drafts map[int64]map[dialogDraftKey]domain.DialogDraft + folders map[int64]map[int]domain.DialogFolder + folderOrder map[int64][]int + folderTags map[int64]bool +} + +type dialogDraftKey struct { + peerType domain.PeerType + peerID int64 + topMessageID int +} + +// NewDialogStore 创建内存 DialogStore。 +func NewDialogStore() *DialogStore { + return &DialogStore{ + m: make(map[int64]domain.DialogList), + drafts: make(map[int64]map[dialogDraftKey]domain.DialogDraft), + folders: make(map[int64]map[int]domain.DialogFolder), + folderOrder: make(map[int64][]int), + folderTags: make(map[int64]bool), + } +} + +func (s *DialogStore) ListByUser(_ context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + list.Dialogs = cloneDialogs(list.Dialogs) + list.Messages = cloneMessages(list.Messages) + list.Users = append([]domain.User(nil), list.Users...) + return filterDialogList(list, filter), nil +} + +func (s *DialogStore) ListByPeers(_ context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + list.Dialogs = cloneDialogs(list.Dialogs) + list.Messages = cloneMessages(list.Messages) + list.Users = append([]domain.User(nil), list.Users...) + + byPeer := make(map[domain.Peer]domain.Dialog, len(list.Dialogs)) + for _, dialog := range list.Dialogs { + byPeer[dialog.Peer] = dialog + } + out := domain.DialogList{ + Dialogs: make([]domain.Dialog, 0, len(peers)), + Users: make([]domain.User, 0, len(peers)), + } + seenPeers := make(map[domain.Peer]struct{}, len(peers)) + seenUsers := map[int64]struct{}{} + for _, peer := range peers { + if _, ok := seenPeers[peer]; ok { + continue + } + seenPeers[peer] = struct{}{} + dialog := byPeer[peer] + if dialog.Peer.ID == 0 { + dialog.Peer = peer + } + out.Dialogs = append(out.Dialogs, dialog) + if peer.Type == domain.PeerTypeUser { + if user, ok := findDialogUser(list.Users, peer.ID); ok { + appendDialogUser(&out, seenUsers, user) + } else if peer.ID == domain.OfficialSystemUserID { + appendDialogUser(&out, seenUsers, domain.OfficialSystemUser()) + } + } + } + out.Messages = keepDialogMessages(list.Messages, out.Dialogs) + out.Count = len(out.Dialogs) + out.Hash = dialogListHash(out.Dialogs) + return out, nil +} + +// SaveList 保存一份用户会话列表,供测试和本地替身使用。 +func (s *DialogStore) SaveList(_ context.Context, userID int64, list domain.DialogList) error { + list.Dialogs = cloneDialogs(list.Dialogs) + list.Messages = cloneMessages(list.Messages) + list.Users = append([]domain.User(nil), list.Users...) + s.mu.Lock() + s.m[userID] = list + s.mu.Unlock() + return nil +} + +func (s *DialogStore) Upsert(_ context.Context, userID int64, dialog domain.Dialog) error { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i, existing := range list.Dialogs { + if existing.Peer == dialog.Peer { + if dialog.FolderID == domain.DialogMainFolderID && existing.FolderID != domain.DialogMainFolderID { + dialog.FolderID = existing.FolderID + } + list.Dialogs[i] = dialog + s.m[userID] = list + return nil + } + } + list.Dialogs = append(list.Dialogs, dialog) + s.m[userID] = list + return nil +} + +func (s *DialogStore) SaveDraft(_ context.Context, userID int64, draft domain.DialogDraft) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.drafts[userID] == nil { + s.drafts[userID] = make(map[dialogDraftKey]domain.DialogDraft) + } + s.drafts[userID][draftKey(draft.Peer, draft.TopMessageID)] = cloneDialogDraft(draft) + return nil +} + +func (s *DialogStore) DeleteDraft(_ context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + items := s.drafts[userID] + if len(items) == 0 { + return false, nil + } + key := draftKey(peer, topMessageID) + if _, ok := items[key]; !ok { + return false, nil + } + delete(items, key) + return true, nil +} + +func (s *DialogStore) ListDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + s.mu.RLock() + items := s.drafts[userID] + out := make([]domain.DialogDraft, 0, len(items)) + for _, draft := range items { + out = append(out, cloneDialogDraft(draft)) + } + s.mu.RUnlock() + sortDialogDrafts(out) + if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { + limit = domain.MaxDialogDraftsPerUser + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (s *DialogStore) ClearDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { + limit = domain.MaxDialogDraftsPerUser + } + s.mu.Lock() + defer s.mu.Unlock() + items := s.drafts[userID] + if len(items) == 0 { + return nil, nil + } + out := make([]domain.DialogDraft, 0, len(items)) + for _, draft := range items { + out = append(out, cloneDialogDraft(draft)) + } + sortDialogDrafts(out) + if len(out) > limit { + out = out[:limit] + } + for _, draft := range out { + delete(items, draftKey(draft.Peer, draft.TopMessageID)) + } + return out, nil +} + +func (s *DialogStore) MarkRead(_ context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) { + result := domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID} + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i, dialog := range list.Dialogs { + if dialog.Peer != peer { + continue + } + readMax := maxID + if readMax <= 0 { + readMax = dialog.TopMessage + } + result.MaxID = readMax + result.Changed = dialog.UnreadCount > 0 || readMax > dialog.ReadInboxMaxID + if readMax > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = readMax + } + dialog.UnreadCount = 0 + dialog.UnreadMentions = 0 + dialog.UnreadReactions = 0 + dialog.UnreadMark = false + result.StillUnreadCount = dialog.UnreadCount + list.Dialogs[i] = dialog + s.m[userID] = list + return result, nil + } + return result, nil +} + +func (s *DialogStore) SetPinned(_ context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + nextOrder := 1 + for _, dialog := range list.Dialogs { + if dialog.Pinned && dialog.PinnedOrder >= nextOrder { + nextOrder = dialog.PinnedOrder + 1 + } + } + for i := range list.Dialogs { + if list.Dialogs[i].Peer != peer { + continue + } + list.Dialogs[i].Pinned = pinned + if pinned { + if list.Dialogs[i].PinnedOrder == 0 { + list.Dialogs[i].PinnedOrder = nextOrder + } + } else { + list.Dialogs[i].PinnedOrder = 0 + } + s.m[userID] = list + return true, nil + } + return false, nil +} + +func (s *DialogStore) ReorderPinned(_ context.Context, userID int64, order []domain.Peer, force bool) error { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + positions := make(map[domain.Peer]int, len(order)) + for i, peer := range order { + if peer.Type == "" || peer.ID == 0 { + continue + } + if _, ok := positions[peer]; ok { + continue + } + positions[peer] = len(order) - i + } + for i := range list.Dialogs { + pos, ok := positions[list.Dialogs[i].Peer] + if ok { + list.Dialogs[i].Pinned = true + list.Dialogs[i].PinnedOrder = pos + continue + } + if force && list.Dialogs[i].Pinned { + list.Dialogs[i].Pinned = false + list.Dialogs[i].PinnedOrder = 0 + } + } + s.m[userID] = list + return nil +} + +func (s *DialogStore) SetUnreadMark(_ context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Dialogs { + if list.Dialogs[i].Peer != peer { + continue + } + list.Dialogs[i].UnreadMark = unread + s.m[userID] = list + return true, nil + } + return false, nil +} + +func (s *DialogStore) ListUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + out := make([]domain.Peer, 0, len(list.Dialogs)) + for _, dialog := range list.Dialogs { + if dialog.UnreadMark { + out = append(out, dialog.Peer) + } + } + return out, nil +} + +func (s *DialogStore) SetPeerSettingsBarHidden(_ context.Context, userID int64, peer domain.Peer) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Dialogs { + if list.Dialogs[i].Peer != peer { + continue + } + list.Dialogs[i].PeerSettingsBarHidden = true + s.m[userID] = list + return true, nil + } + return false, nil +} + +func (s *DialogStore) PeerSettingsBarHidden(_ context.Context, userID int64, peer domain.Peer) (bool, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + for _, dialog := range list.Dialogs { + if dialog.Peer == peer { + return dialog.PeerSettingsBarHidden, nil + } + } + return false, nil +} + +func (s *DialogStore) ListFolders(_ context.Context, userID int64) (domain.DialogFolderList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + byID := s.folders[userID] + order := append([]int(nil), s.folderOrder[userID]...) + seen := make(map[int]struct{}, len(byID)) + out := domain.DialogFolderList{ + TagsEnabled: s.folderTags[userID], + Folders: make([]domain.DialogFolder, 0, len(byID)), + } + for _, id := range order { + folder, ok := byID[id] + if !ok { + continue + } + seen[id] = struct{}{} + out.Folders = append(out.Folders, cloneDialogFolder(folder)) + } + remaining := make([]int, 0, len(byID)) + for id := range byID { + if _, ok := seen[id]; !ok { + remaining = append(remaining, id) + } + } + sort.Ints(remaining) + for _, id := range remaining { + out.Folders = append(out.Folders, cloneDialogFolder(byID[id])) + } + return out, nil +} + +func (s *DialogStore) GetFolder(_ context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + folder, ok := s.folders[userID][folderID] + if !ok { + return domain.DialogFolder{}, false, nil + } + return cloneDialogFolder(folder), true, nil +} + +func (s *DialogStore) UpsertFolder(_ context.Context, userID int64, folder domain.DialogFolder) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.folders[userID] == nil { + s.folders[userID] = make(map[int]domain.DialogFolder) + } + s.folders[userID][folder.ID] = cloneDialogFolder(folder) + if !containsInt(s.folderOrder[userID], folder.ID) { + s.folderOrder[userID] = append(s.folderOrder[userID], folder.ID) + } + return nil +} + +func (s *DialogStore) DeleteFolder(_ context.Context, userID int64, folderID int) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.folders[userID], folderID) + s.folderOrder[userID] = removeInt(s.folderOrder[userID], folderID) + return nil +} + +func (s *DialogStore) ReorderFolders(_ context.Context, userID int64, order []int) error { + s.mu.Lock() + defer s.mu.Unlock() + byID := s.folders[userID] + seen := make(map[int]struct{}, len(order)) + next := make([]int, 0, len(byID)) + for _, id := range order { + if id < domain.DialogCustomFolderMinID { + continue + } + if _, ok := byID[id]; !ok { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + next = append(next, id) + } + remaining := make([]int, 0, len(byID)) + for id := range byID { + if _, ok := seen[id]; !ok { + remaining = append(remaining, id) + } + } + sort.Ints(remaining) + next = append(next, remaining...) + s.folderOrder[userID] = next + return nil +} + +func (s *DialogStore) SetFolderTagsEnabled(_ context.Context, userID int64, enabled bool) error { + s.mu.Lock() + s.folderTags[userID] = enabled + s.mu.Unlock() + return nil +} + +func (s *DialogStore) EditPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + updates := make(map[domain.Peer]int, len(peers)) + for _, item := range peers { + if item.Peer.Type == "" || item.Peer.ID == 0 { + continue + } + updates[item.Peer] = item.FolderID + } + for i := range list.Dialogs { + if folderID, ok := updates[list.Dialogs[i].Peer]; ok { + list.Dialogs[i].FolderID = folderID + } + } + s.m[userID] = list + return nil +} + +// MessageStore 是 store.MessageStore 的内存实现。 +type MessageStore struct { + mu sync.RWMutex + m map[int64][]domain.Message + nextUID int64 + nextBox map[int64]int + nextPts map[int64]int + readOutboxDates map[readOutboxDateKey]int + privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction + dialogs *DialogStore +} + +type readOutboxDateKey struct { + ownerUserID int64 + peerID int64 + msgID int +} + +// NewMessageStore 创建内存 MessageStore。 +func NewMessageStore(dialogs ...*DialogStore) *MessageStore { + s := &MessageStore{ + m: make(map[int64][]domain.Message), + nextUID: 1, + nextBox: make(map[int64]int), + nextPts: make(map[int64]int), + readOutboxDates: make(map[readOutboxDateKey]int), + privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction), + } + if len(dialogs) > 0 { + s.dialogs = dialogs[0] + } + return s +} + +func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) { + s.mu.Lock() + defer s.mu.Unlock() + msg.ID = s.nextBoxIDLocked(msg.OwnerUserID) + msg.UID = s.nextUID + s.nextUID++ + msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + s.m[msg.OwnerUserID] = append(s.m[msg.OwnerUserID], msg) + if s.dialogs != nil { + s.dialogs.mu.Lock() + list := s.dialogs.m[msg.OwnerUserID] + list.Messages = append(list.Messages, msg) + if msg.Peer.Type == domain.PeerTypeUser && msg.Peer.ID == domain.OfficialSystemUserID && !hasUser(list.Users, domain.OfficialSystemUserID) { + list.Users = append(list.Users, domain.OfficialSystemUser()) + } + s.dialogs.m[msg.OwnerUserID] = list + s.dialogs.mu.Unlock() + } + return msg, nil +} + +func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, msg := range s.m[req.SenderUserID] { + if msg.RandomID != 0 && msg.RandomID == req.RandomID { + recipient := domain.Message{} + if req.SenderUserID != req.RecipientUserID { + for _, peerMsg := range s.m[req.RecipientUserID] { + if peerMsg.UID == msg.UID { + recipient = peerMsg + break + } + } + } else { + recipient = msg + } + return domain.SendPrivateTextResult{ + SenderMessage: cloneMessage(msg), + RecipientMessage: cloneMessage(recipient), + SenderEvent: newMessageEvent(msg), + RecipientEvent: newMessageEvent(recipient), + Duplicate: true, + }, nil + } + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + senderReply, recipientReply, err := s.resolveMemoryReplyLocked(req) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + uid := s.nextUID + s.nextUID++ + sender := domain.Message{ + ID: s.nextBoxIDLocked(req.SenderUserID), + UID: uid, + RandomID: req.RandomID, + OwnerUserID: req.SenderUserID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, + Date: req.Date, + Out: true, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ReplyTo: cloneMessageReply(senderReply), + Forward: cloneMessageForward(req.Forward), + Pts: s.nextPtsLocked(req.SenderUserID), + } + recipient := sender + if req.SenderUserID != req.RecipientUserID { + recipient.ID = s.nextBoxIDLocked(req.RecipientUserID) + recipient.OwnerUserID = req.RecipientUserID + recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + recipient.Out = false + recipient.ReplyTo = cloneMessageReply(recipientReply) + recipient.Pts = s.nextPtsLocked(req.RecipientUserID) + } + s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender) + if req.SenderUserID != req.RecipientUserID { + s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient) + } + if s.dialogs != nil { + s.upsertMemoryDialogsLocked(sender, recipient) + } + return domain.SendPrivateTextResult{ + SenderMessage: cloneMessage(sender), + RecipientMessage: cloneMessage(recipient), + SenderEvent: newMessageEvent(sender), + RecipientEvent: newMessageEvent(recipient), + }, nil +} + +func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { + if req.ReplyTo == nil { + return nil, nil, nil + } + if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { + return nil, nil, err + } + peer := req.ReplyTo.Peer + if peer.ID == 0 { + peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} + } + if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + var target domain.Message + for _, msg := range s.m[req.SenderUserID] { + if msg.Peer == peer && msg.ID == req.ReplyTo.MessageID { + target = msg + break + } + } + if target.ID == 0 { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + senderReply := cloneMessageReply(req.ReplyTo) + senderReply.MessageID = target.ID + senderReply.Peer = peer + if req.SenderUserID == req.RecipientUserID { + return senderReply, cloneMessageReply(senderReply), nil + } + for _, msg := range s.m[req.RecipientUserID] { + if msg.UID == target.UID { + recipientReply := cloneMessageReply(senderReply) + recipientReply.MessageID = msg.ID + recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + return senderReply, recipientReply, nil + } + } + return senderReply, nil, nil +} + +func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + res := domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.ToUserID == 0 || req.FromPeer.Type != domain.PeerTypeUser || req.FromPeer.ID == 0 { + return res, domain.ErrMessageIDInvalid + } + if len(req.MessageIDs) == 0 || len(req.MessageIDs) != len(req.RandomIDs) { + return res, domain.ErrMessageIDInvalid + } + if len(req.MessageIDs) > domain.MaxForwardMessageIDs { + return res, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.RLock() + sources := make([]domain.Message, 0, len(req.MessageIDs)) + for _, id := range req.MessageIDs { + if id <= 0 || id > domain.MaxMessageBoxID { + s.mu.RUnlock() + return res, domain.ErrMessageIDInvalid + } + var source domain.Message + for _, msg := range s.m[req.OwnerUserID] { + if msg.Peer == req.FromPeer && msg.ID == id { + source = cloneMessage(msg) + break + } + } + if source.ID == 0 { + s.mu.RUnlock() + return res, domain.ErrMessageIDInvalid + } + if source.NoForwards { + s.mu.RUnlock() + return res, domain.ErrChatForwardsRestricted + } + sources = append(sources, source) + } + s.mu.RUnlock() + + res.SenderMessages = make([]domain.Message, 0, len(sources)) + res.RecipientMessages = make([]domain.Message, 0, len(sources)) + res.SenderEvents = make([]domain.UpdateEvent, 0, len(sources)) + res.RecipientEvents = make([]domain.UpdateEvent, 0, len(sources)) + res.Duplicates = make([]bool, 0, len(sources)) + for i, source := range sources { + if req.RandomIDs[i] == 0 { + return res, domain.ErrMessageIDInvalid + } + var forward *domain.MessageForward + if !req.DropAuthor { + forward = cloneMessageForward(source.Forward) + if forward == nil { + forward = &domain.MessageForward{From: source.From, Date: source.Date} + } + } + sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: req.OwnerUserID, + RecipientUserID: req.ToUserID, + RandomID: req.RandomIDs[i], + Message: source.Body, + Entities: append([]domain.MessageEntity(nil), source.Entities...), + Silent: req.Silent, + NoForwards: req.NoForwards, + ReplyTo: req.ReplyTo, + Forward: forward, + Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, + OriginSessionID: req.OriginSessionID, + }) + if err != nil { + return res, err + } + res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) + res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) + res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) + res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) + res.Duplicates = append(res.Duplicates, sent.Duplicate) + } + return res, nil +} + +func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (domain.MessageList, error) { + if userID == 0 || len(ids) == 0 { + return domain.MessageList{}, nil + } + s.mu.RLock() + byID := make(map[int]domain.Message, len(s.m[userID])) + for _, msg := range s.m[userID] { + item := cloneMessage(msg) + reactions := s.privateMessageReactionsLocked(item.UID, item.OwnerUserID) + if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { + item.Reactions = cloneChannelMessageReactionsPtr(&reactions) + } + byID[msg.ID] = item + } + s.mu.RUnlock() + out := domain.MessageList{Messages: make([]domain.Message, 0, len(ids))} + for _, id := range ids { + if msg, ok := byID[id]; ok { + out.Messages = append(out.Messages, msg) + } + } + out.Users = usersForMessages(out.Messages) + out.Hash = messageListHash(out.Messages) + return out, nil +} + +func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + s.mu.RLock() + messages := cloneMessages(s.m[userID]) + for i := range messages { + reactions := s.privateMessageReactionsLocked(messages[i].UID, messages[i].OwnerUserID) + if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { + messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) + } + } + s.mu.RUnlock() + return filterMessageList(messages, filter), nil +} + +func (s *MessageStore) ReadHistory(_ context.Context, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { + res := domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID} + if req.OwnerUserID == 0 || req.Peer.ID == 0 { + return res, nil + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.dialogs == nil { + return res, nil + } + s.dialogs.mu.Lock() + defer s.dialogs.mu.Unlock() + list := s.dialogs.m[req.OwnerUserID] + for i, dialog := range list.Dialogs { + if dialog.Peer != req.Peer { + continue + } + readMax := req.MaxID + if readMax <= 0 { + readMax = dialog.TopMessage + } + if readMax > domain.MaxMessageBoxID { + readMax = domain.MaxMessageBoxID + } + oldRead := dialog.ReadInboxMaxID + res.MaxID = readMax + res.Changed = dialog.UnreadCount > 0 || readMax > oldRead + if !res.Changed { + return res, nil + } + var latestIncoming domain.Message + unread := 0 + for _, msg := range s.m[req.OwnerUserID] { + if msg.Peer != req.Peer || msg.Out { + continue + } + if msg.ID > readMax { + unread++ + continue + } + if msg.ID > oldRead && msg.ID > latestIncoming.ID { + latestIncoming = msg + } + } + if readMax > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = readMax + } + dialog.UnreadCount = unread + dialog.UnreadMentions = 0 + dialog.UnreadReactions = 0 + dialog.UnreadMark = false + res.StillUnreadCount = unread + pts := s.nextPtsLocked(req.OwnerUserID) + res.InboxEvent = domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventReadHistoryInbox, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Peer: req.Peer, + MaxID: readMax, + StillUnreadCount: unread, + } + list.Dialogs[i] = dialog + s.dialogs.m[req.OwnerUserID] = list + + if latestIncoming.ID != 0 && latestIncoming.From.ID != 0 && latestIncoming.From.ID != req.OwnerUserID { + senderUserID := latestIncoming.From.ID + senderBoxID := 0 + for _, msg := range s.m[senderUserID] { + if msg.UID == latestIncoming.UID && msg.Out { + senderBoxID = msg.ID + break + } + } + if senderBoxID > 0 { + senderList := s.dialogs.m[senderUserID] + for j, senderDialog := range senderList.Dialogs { + if senderDialog.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) { + continue + } + if senderBoxID <= senderDialog.ReadOutboxMaxID { + break + } + oldOutbox := senderDialog.ReadOutboxMaxID + senderDialog.ReadOutboxMaxID = senderBoxID + senderList.Dialogs[j] = senderDialog + s.dialogs.m[senderUserID] = senderList + for _, msg := range s.m[senderUserID] { + if msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && msg.Out && msg.ID > oldOutbox && msg.ID <= senderBoxID { + s.readOutboxDates[readOutboxDateKey{ownerUserID: senderUserID, peerID: req.OwnerUserID, msgID: msg.ID}] = req.Date + } + } + outPts := s.nextPtsLocked(senderUserID) + res.OutboxChanged = true + res.OutboxUserID = senderUserID + res.OutboxEvent = domain.UpdateEvent{ + UserID: senderUserID, + Type: domain.UpdateEventReadHistoryOutbox, + Pts: outPts, + PtsCount: 1, + Date: req.Date, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + MaxID: senderBoxID, + } + break + } + } + } + return res, nil + } + return res, nil +} + +func (s *MessageStore) ReadMessageContents(_ context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { + res := domain.ReadMessageContentsResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("read message contents: missing owner user id") + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return res, domain.ErrMessageIDInvalid + } + wanted := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + if len(wanted) == 0 { + return res, nil + } + s.mu.RLock() + for _, msg := range s.m[req.OwnerUserID] { + if _, ok := wanted[msg.ID]; ok { + res.MessageIDs = append(res.MessageIDs, msg.ID) + } + } + s.mu.RUnlock() + sort.Ints(res.MessageIDs) + return res, nil +} + +func (s *MessageStore) GetOutboxReadDate(_ context.Context, req domain.OutboxReadDateRequest) (int, error) { + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return 0, domain.ErrMessageIDInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + found := false + for _, msg := range s.m[req.OwnerUserID] { + if msg.ID == req.ID && msg.Peer == req.Peer && msg.Out { + found = true + break + } + } + if !found { + return 0, domain.ErrMessageIDInvalid + } + date := s.readOutboxDates[readOutboxDateKey{ownerUserID: req.OwnerUserID, peerID: req.Peer.ID, msgID: req.ID}] + if date == 0 { + return 0, domain.ErrMessageNotReadYet + } + return date, nil +} + +func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if req.UserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + var target domain.Message + for _, msg := range s.m[req.UserID] { + if msg.ID == req.MessageID && msg.Peer == req.Peer { + target = msg + break + } + } + if target.ID == 0 || target.UID == 0 { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if _, ok := s.privateReactions[target.UID]; !ok { + s.privateReactions[target.UID] = make(map[int64][]domain.ChannelMessagePeerReaction) + } + rows := make([]domain.ChannelMessagePeerReaction, 0, len(req.Reactions)) + for i, reaction := range req.Reactions { + if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + rows = append(rows, domain.ChannelMessagePeerReaction{ + UserID: req.UserID, + Reaction: reaction, + Big: req.Big, + My: true, + ChosenOrder: i + 1, + Date: req.Date, + }) + } + if len(rows) == 0 { + delete(s.privateReactions[target.UID], req.UserID) + } else { + s.privateReactions[target.UID][req.UserID] = rows + } + return s.privateReactionResultLocked(target.UID), nil +} + +func (s *MessageStore) GetMessageReactions(_ context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + ids := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + ids[id] = struct{}{} + } + s.mu.RLock() + defer s.mu.RUnlock() + out := domain.PrivateMessageReactionsResult{} + for _, msg := range s.m[req.OwnerUserID] { + if msg.Peer != req.Peer { + continue + } + if _, ok := ids[msg.ID]; !ok { + continue + } + item := cloneMessage(msg) + reactions := s.privateMessageReactionsLocked(msg.UID, msg.OwnerUserID) + item.Reactions = cloneChannelMessageReactionsPtr(&reactions) + out.Messages = append(out.Messages, item) + if len(out.Reactions.Results) == 0 && len(out.Reactions.Recent) == 0 { + out.Reactions = reactions + } + } + return out, nil +} + +func (s *MessageStore) privateReactionResultLocked(uid int64) domain.PrivateMessageReactionsResult { + out := domain.PrivateMessageReactionsResult{} + for _, messages := range s.m { + for _, msg := range messages { + if msg.UID != uid { + continue + } + item := cloneMessage(msg) + reactions := s.privateMessageReactionsLocked(uid, msg.OwnerUserID) + item.Reactions = cloneChannelMessageReactionsPtr(&reactions) + out.Messages = append(out.Messages, item) + if len(out.Reactions.Results) == 0 && len(out.Reactions.Recent) == 0 { + out.Reactions = reactions + } + } + } + return out +} + +func (s *MessageStore) privateMessageReactionsLocked(uid, viewerUserID int64) domain.ChannelMessageReactions { + byUser := s.privateReactions[uid] + out := domain.ChannelMessageReactions{CanSeeList: true} + if len(byUser) == 0 { + return out + } + counts := make(map[string]int) + recent := make([]domain.ChannelMessagePeerReaction, 0, len(byUser)) + for userID, rows := range byUser { + for _, row := range rows { + key := string(row.Reaction.Type) + "\x00" + row.Reaction.Emoticon + index, ok := counts[key] + if !ok { + out.Results = append(out.Results, domain.ChannelMessageReactionCount{Reaction: row.Reaction}) + index = len(out.Results) - 1 + counts[key] = index + } + out.Results[index].Count++ + if userID == viewerUserID && (out.Results[index].ChosenOrder == 0 || row.ChosenOrder < out.Results[index].ChosenOrder) { + out.Results[index].ChosenOrder = row.ChosenOrder + } + item := row + item.UserID = userID + item.My = userID == viewerUserID + recent = append(recent, item) + } + } + sort.Slice(out.Results, func(i, j int) bool { + if out.Results[i].Count != out.Results[j].Count { + return out.Results[i].Count > out.Results[j].Count + } + return out.Results[i].Reaction.Emoticon < out.Results[j].Reaction.Emoticon + }) + sort.Slice(recent, func(i, j int) bool { + if recent[i].Date != recent[j].Date { + return recent[i].Date > recent[j].Date + } + return recent[i].UserID < recent[j].UserID + }) + if len(recent) > domain.MaxChannelMessageReactionRecent { + recent = recent[:domain.MaxChannelMessageReactionRecent] + } + out.Recent = recent + return out +} + +func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error) { + res := domain.EditMessageResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + if req.EditDate == 0 { + req.EditDate = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + targetIndex := -1 + var target domain.Message + for i, msg := range s.m[req.OwnerUserID] { + if msg.ID == req.ID && msg.Peer == req.Peer { + targetIndex = i + target = msg + break + } + } + if targetIndex < 0 { + return res, domain.ErrMessageIDInvalid + } + if !target.Out || target.From.ID != req.OwnerUserID { + return res, domain.ErrMessageAuthorRequired + } + if target.Body == req.Message && equalMessageEntities(target.Entities, req.Entities) { + return res, domain.ErrMessageNotModified + } + for userID, messages := range s.m { + for i, msg := range messages { + if msg.UID == target.UID && msg.From.ID == req.OwnerUserID { + msg.Body = req.Message + msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) + msg.EditDate = req.EditDate + msg.Pts = s.nextPtsLocked(userID) + s.m[userID][i] = msg + event := editMessageEvent(msg) + res.Edited = append(res.Edited, domain.EditedMessageForUser{ + UserID: userID, + Message: cloneMessage(msg), + Event: event, + }) + } + } + } + if s.dialogs != nil { + s.dialogs.mu.Lock() + for userID := range s.dialogs.m { + list := s.dialogs.m[userID] + list.Messages = cloneMessages(s.m[userID]) + s.dialogs.m[userID] = list + } + s.dialogs.mu.Unlock() + } + sort.Slice(res.Edited, func(i, j int) bool { return res.Edited[i].UserID < res.Edited[j].UserID }) + return res, nil +} + +func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} + ids := normalizeMemoryMessageIDs(req.IDs) + if req.OwnerUserID == 0 || len(ids) == 0 { + return res, nil + } + if len(ids) > domain.MaxDeleteMessageIDs { + return res, fmt.Errorf("delete messages: too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs) + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + idSet := make(map[int]struct{}, len(ids)) + for _, id := range ids { + idSet[id] = struct{}{} + } + s.mu.Lock() + defer s.mu.Unlock() + deleted, revokeUIDs, _ := s.deleteMemoryMessagesLocked(req.OwnerUserID, 0, func(msg domain.Message) bool { + _, ok := idSet[msg.ID] + return ok + }) + if req.Revoke && len(revokeUIDs) > 0 { + deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...) + } + return s.finishMemoryDeleteLocked(res, deleted, req.Date, false), nil +} + +func (s *MessageStore) DeleteHistory(_ context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.Peer.ID == 0 { + return res, nil + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + deleted, revokeUIDs, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool { + return msg.Peer == req.Peer && (req.MaxID <= 0 || msg.ID <= req.MaxID) + }) + if req.Revoke && len(revokeUIDs) > 0 { + deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...) + } + res = s.finishMemoryDeleteLocked(res, deleted, req.Date, req.JustClear) + if more { + res.Offset = 1 + } + return res, nil +} + +func (s *MessageStore) nextBoxIDLocked(userID int64) int { + next := s.nextBox[userID] + 1 + s.nextBox[userID] = next + return next +} + +func (s *MessageStore) nextPtsLocked(userID int64) int { + next := s.nextPts[userID] + 1 + s.nextPts[userID] = next + return next +} + +func (s *MessageStore) nextPtsNLocked(userID int64, count int) int { + if count <= 0 { + count = 1 + } + next := s.nextPts[userID] + count + s.nextPts[userID] = next + return next +} + +func (s *MessageStore) upsertMemoryDialogsLocked(sender, recipient domain.Message) { + s.dialogs.mu.Lock() + defer s.dialogs.mu.Unlock() + list := s.dialogs.m[sender.OwnerUserID] + list = upsertMemoryDialog(list, domain.Dialog{Peer: sender.Peer, TopMessage: sender.ID, TopMessageDate: sender.Date}) + list.Messages = append(list.Messages, sender) + s.dialogs.m[sender.OwnerUserID] = list + if recipient.OwnerUserID != sender.OwnerUserID { + peerList := s.dialogs.m[recipient.OwnerUserID] + peerList = upsertMemoryDialog(peerList, domain.Dialog{Peer: recipient.Peer, TopMessage: recipient.ID, TopMessageDate: recipient.Date, UnreadCount: 1}) + peerList.Messages = append(peerList.Messages, recipient) + s.dialogs.m[recipient.OwnerUserID] = peerList + } +} + +func upsertMemoryDialog(list domain.DialogList, dialog domain.Dialog) domain.DialogList { + for i := range list.Dialogs { + if list.Dialogs[i].Peer == dialog.Peer { + if dialog.ReadInboxMaxID == 0 { + dialog.ReadInboxMaxID = list.Dialogs[i].ReadInboxMaxID + } + if dialog.ReadOutboxMaxID == 0 { + dialog.ReadOutboxMaxID = list.Dialogs[i].ReadOutboxMaxID + } + if dialog.FolderID == domain.DialogMainFolderID && list.Dialogs[i].FolderID != domain.DialogMainFolderID { + dialog.FolderID = list.Dialogs[i].FolderID + } + if dialog.UnreadCount != 0 { + dialog.UnreadCount += list.Dialogs[i].UnreadCount + } + list.Dialogs[i] = dialog + return list + } + } + list.Dialogs = append(list.Dialogs, dialog) + return list +} + +type deletedMemoryMessage struct { + userID int64 + peer domain.Peer + id int +} + +func (s *MessageStore) deleteMemoryMessagesLocked(userID int64, limit int, match func(domain.Message) bool) ([]deletedMemoryMessage, map[int64]struct{}, bool) { + messages := s.m[userID] + kept := messages[:0] + deleted := make([]deletedMemoryMessage, 0) + revokeUIDs := make(map[int64]struct{}) + more := false + for _, msg := range messages { + if match(msg) { + if limit > 0 && len(deleted) >= limit { + kept = append(kept, msg) + more = true + continue + } + deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID}) + if msg.UID != 0 { + revokeUIDs[msg.UID] = struct{}{} + } + continue + } + kept = append(kept, msg) + } + s.m[userID] = kept + return deleted, revokeUIDs, more +} + +func (s *MessageStore) deleteMemoryMessagesByUIDLocked(uids map[int64]struct{}, excludeUserID int64) []deletedMemoryMessage { + if len(uids) == 0 { + return nil + } + deleted := make([]deletedMemoryMessage, 0) + for userID, messages := range s.m { + if userID == excludeUserID { + continue + } + kept := messages[:0] + for _, msg := range messages { + if _, ok := uids[msg.UID]; ok { + deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID}) + continue + } + kept = append(kept, msg) + } + s.m[userID] = kept + } + return deleted +} + +func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult { + if len(deleted) == 0 { + return res + } + idsByOwner := make(map[int64][]int) + peersByOwner := make(map[int64]map[domain.Peer]struct{}) + for _, row := range deleted { + idsByOwner[row.userID] = append(idsByOwner[row.userID], row.id) + if peersByOwner[row.userID] == nil { + peersByOwner[row.userID] = make(map[domain.Peer]struct{}) + } + peersByOwner[row.userID][row.peer] = struct{}{} + } + if s.dialogs != nil { + s.dialogs.mu.Lock() + for userID, peers := range peersByOwner { + for peer := range peers { + s.rebuildMemoryDialogLocked(userID, peer, preserveEmptyDialogs) + } + } + s.dialogs.mu.Unlock() + } + ownerIDs := make([]int64, 0, len(idsByOwner)) + for userID := range idsByOwner { + ownerIDs = append(ownerIDs, userID) + } + sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] }) + for _, userID := range ownerIDs { + ids := normalizeMemoryMessageIDs(idsByOwner[userID]) + if len(ids) == 0 { + continue + } + pts := s.nextPtsNLocked(userID, len(ids)) + event := domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventDeleteMessages, + Pts: pts, + PtsCount: len(ids), + Date: date, + MessageIDs: ids, + } + res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{ + UserID: userID, + MessageIDs: ids, + Event: event, + }) + } + return res +} + +func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer, preserveEmpty bool) { + list := s.dialogs.m[userID] + topID := 0 + topDate := 0 + unread := 0 + for _, msg := range s.m[userID] { + if msg.Peer != peer { + continue + } + if msg.ID > topID { + topID = msg.ID + topDate = msg.Date + } + } + dialogs := list.Dialogs[:0] + for _, dialog := range list.Dialogs { + if dialog.Peer != peer { + dialogs = append(dialogs, dialog) + continue + } + if topID == 0 { + if preserveEmpty { + oldTop := dialog.TopMessage + dialog.TopMessage = 0 + dialog.TopMessageDate = 0 + if dialog.ReadInboxMaxID < oldTop { + dialog.ReadInboxMaxID = oldTop + } + if dialog.ReadOutboxMaxID < oldTop { + dialog.ReadOutboxMaxID = oldTop + } + dialog.UnreadCount = 0 + dialog.UnreadMark = false + dialog.UnreadMentions = 0 + dialog.UnreadReactions = 0 + dialogs = append(dialogs, dialog) + } + continue + } + for _, msg := range s.m[userID] { + if msg.Peer == peer && !msg.Out && msg.ID > dialog.ReadInboxMaxID { + unread++ + } + } + dialog.TopMessage = topID + dialog.TopMessageDate = topDate + dialog.UnreadCount = unread + dialog.UnreadMentions = 0 + dialog.UnreadReactions = 0 + dialogs = append(dialogs, dialog) + } + list.Dialogs = dialogs + list.Messages = cloneMessages(s.m[userID]) + s.dialogs.m[userID] = list +} + +func normalizeMemoryMessageIDs(ids []int) []int { + if len(ids) == 0 { + return nil + } + out := make([]int, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Ints(out) + return out +} + +func cloneMessage(msg domain.Message) domain.Message { + msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + msg.ReplyTo = cloneMessageReply(msg.ReplyTo) + msg.Forward = cloneMessageForward(msg.Forward) + msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions) + return msg +} + +func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { + if reply == nil { + return nil + } + clone := *reply + clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...) + return &clone +} + +func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward { + if forward == nil { + return nil + } + clone := *forward + return &clone +} + +func newMessageEvent(msg domain.Message) domain.UpdateEvent { + if msg.ID == 0 { + return domain.UpdateEvent{} + } + return domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: cloneMessage(msg), + } +} + +func editMessageEvent(msg domain.Message) domain.UpdateEvent { + if msg.ID == 0 { + return domain.UpdateEvent{} + } + return domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventEditMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.EditDate, + Message: cloneMessage(msg), + } +} + +func equalMessageEntities(a, b []domain.MessageEntity) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func hasUser(users []domain.User, id int64) bool { + for _, u := range users { + if u.ID == id { + return true + } + } + return false +} + +func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList { + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + sort.SliceStable(messages, func(i, j int) bool { + return messageLess(messages[i], messages[j]) + }) + + query := strings.ToLower(filter.Query) + base := make([]domain.Message, 0, len(messages)) + for _, msg := range messages { + if filter.HasPeer && msg.Peer != filter.Peer { + continue + } + if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { + continue + } + if filter.MaxID > 0 && msg.ID >= filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + base = append(base, msg) + } + + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + page := pageMessageHistory(base, filter, limit) + return domain.MessageList{ + Messages: page, + Users: usersForMessages(page), + Count: len(base), + Hash: messageListHash(base), + } +} + +func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message { + if limit <= 0 || len(base) == 0 { + return nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return cloneMessages(forwardMessageHistory(base, filter, limit)) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + page := make([]domain.Message, 0, limit) + page = append(page, forwardMessageHistory(base, filter, forwardLimit)...) + page = append(page, backwardMessageHistory(base, filter, backwardLimit, true)...) + sort.SliceStable(page, func(i, j int) bool { + return messageLess(page[i], page[j]) + }) + return cloneMessages(page) + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + candidates := backwardMessageHistory(base, filter, limit+start, false) + if start >= len(candidates) { + return nil + } + return cloneMessages(candidates[start:]) + } +} + +type messageHistoryLoad int + +const ( + messageHistoryLoadBackward messageHistoryLoad = iota + messageHistoryLoadForward + messageHistoryLoadAround +) + +func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad { + if addOffset >= 0 { + return messageHistoryLoadBackward + } + if addOffset+limit > 0 { + return messageHistoryLoadAround + } + return messageHistoryLoadForward +} + +func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int, includeOffset bool) []domain.Message { + if limit <= 0 { + return nil + } + out := make([]domain.Message, 0, limit) + for _, msg := range base { + if !messageBeforeHistoryOffset(msg, filter, includeOffset) { + continue + } + out = append(out, msg) + if len(out) == limit { + break + } + } + return out +} + +func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message { + if limit <= 0 { + return nil + } + out := make([]domain.Message, 0, limit) + for i := len(base) - 1; i >= 0; i-- { + msg := base[i] + if !messageAfterHistoryOffset(msg, filter) { + continue + } + out = append(out, msg) + if len(out) == limit { + break + } + } + sort.SliceStable(out, func(i, j int) bool { + return messageLess(out[i], out[j]) + }) + return out +} + +func messageBeforeHistoryOffset(msg domain.Message, filter domain.MessageFilter, includeOffset bool) bool { + if filter.OffsetDate > 0 { + if includeOffset { + return msg.Date <= filter.OffsetDate + } + return msg.Date < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return true + } + if includeOffset { + return msg.ID <= filter.OffsetID + } + return msg.ID < filter.OffsetID +} + +func messageAfterHistoryOffset(msg domain.Message, filter domain.MessageFilter) bool { + if filter.OffsetDate > 0 { + return msg.Date >= filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + return msg.ID > filter.OffsetID +} + +func messageLess(a, b domain.Message) bool { + if a.Date != b.Date { + return a.Date > b.Date + } + return a.ID > b.ID +} + +func usersForMessages(messages []domain.Message) []domain.User { + seen := map[int64]struct{}{} + users := make([]domain.User, 0, 1) + for _, msg := range messages { + for _, peer := range []domain.Peer{msg.Peer, msg.From} { + if peer.Type != domain.PeerTypeUser { + continue + } + if _, ok := seen[peer.ID]; ok { + continue + } + seen[peer.ID] = struct{}{} + if peer.ID == domain.OfficialSystemUserID { + users = append(users, domain.OfficialSystemUser()) + } + } + } + return users +} + +func messageListHash(messages []domain.Message) int64 { + if len(messages) == 0 { + return 0 + } + h := fnv.New64a() + var buf [16]byte + for _, msg := range messages { + binary.LittleEndian.PutUint32(buf[:4], uint32(msg.ID)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(msg.Date)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.From.ID)) + _, _ = h.Write(buf[:]) + writeMessageReactionsHash(h, msg.Reactions) + } + return int64(h.Sum64()) +} + +func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageReactions) { + if reactions == nil { + _, _ = h.Write([]byte{0}) + return + } + var buf [16]byte + for _, item := range reactions.Results { + _, _ = h.Write([]byte(item.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(item.Reaction.Emoticon)) + _, _ = h.Write([]byte{0}) + binary.LittleEndian.PutUint32(buf[:4], uint32(item.Count)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(item.ChosenOrder)) + _, _ = h.Write(buf[:8]) + } + _, _ = h.Write([]byte{0xfe}) + for _, item := range reactions.Recent { + _, _ = h.Write([]byte(item.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(item.Reaction.Emoticon)) + _, _ = h.Write([]byte{0}) + binary.LittleEndian.PutUint64(buf[:8], uint64(item.UserID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(item.Date)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(item.ChosenOrder)) + _, _ = h.Write(buf[:]) + } +} + +func cloneContacts(contacts []domain.Contact) []domain.Contact { + out := append([]domain.Contact(nil), contacts...) + for i := range out { + out[i] = cloneContact(out[i]) + } + return out +} + +func cloneContact(contact domain.Contact) domain.Contact { + contact.NoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...) + return contact +} + +func contactListHash(contacts []domain.Contact) int64 { + if len(contacts) == 0 { + return 0 + } + h := fnv.New64a() + var buf [16]byte + for _, contact := range contacts { + binary.LittleEndian.PutUint64(buf[:8], uint64(contact.User.ID)) + if contact.Mutual { + buf[8] = 1 + } else { + buf[8] = 0 + } + _, _ = h.Write(buf[:9]) + _, _ = h.Write([]byte(contact.FirstName)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(contact.LastName)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(contact.Phone)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(contact.Note)) + _, _ = h.Write([]byte{0}) + } + return int64(h.Sum64()) +} + +func filterDialogList(list domain.DialogList, filter domain.DialogFilter) domain.DialogList { + sort.SliceStable(list.Dialogs, func(i, j int) bool { + return dialogLess(list.Dialogs[i], list.Dialogs[j]) + }) + + base := make([]domain.Dialog, 0, len(list.Dialogs)) + for _, d := range list.Dialogs { + if !dialogMatchesFolder(d, list.Users, filter) { + continue + } + if filter.PinnedOnly && !d.Pinned { + continue + } + if filter.ExcludePinned && d.Pinned { + continue + } + base = append(base, d) + } + + list.Count = len(base) + list.Hash = dialogListHash(base) + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + page := make([]domain.Dialog, 0, len(base)) + for _, d := range base { + if !afterDialogOffset(d, filter) { + continue + } + page = append(page, d) + if len(page) >= limit { + break + } + } + list.Dialogs = page + list.Messages = keepDialogMessages(list.Messages, page) + return list +} + +func dialogMatchesFolder(d domain.Dialog, users []domain.User, filter domain.DialogFilter) bool { + if !filter.HasFolderID { + return true + } + if filter.FolderID < domain.DialogCustomFolderMinID { + return d.FolderID == filter.FolderID + } + if filter.Folder == nil { + return false + } + folder := filter.Folder + if folder.ExcludeArchived && d.FolderID == domain.DialogArchiveFolderID { + return false + } + if folder.ExcludeRead && d.UnreadCount == 0 && !d.UnreadMark { + return false + } + if hasFolderPeer(folder.ExcludePeers, d.Peer) { + return false + } + if hasFolderPeer(folder.IncludePeers, d.Peer) || hasFolderPeer(folder.PinnedPeers, d.Peer) { + return true + } + if d.Peer.Type == domain.PeerTypeUser { + user, ok := findDialogUser(users, d.Peer.ID) + if ok && user.Contact && folder.Contacts { + return true + } + if (!ok || !user.Contact) && folder.NonContacts { + return true + } + } + return false +} + +func hasFolderPeer(peers []domain.DialogFolderPeer, peer domain.Peer) bool { + for _, item := range peers { + if item.Peer == peer { + return true + } + } + return false +} + +func dialogLess(a, b domain.Dialog) bool { + if a.Pinned != b.Pinned { + return a.Pinned && !b.Pinned + } + if a.Pinned && b.Pinned && a.PinnedOrder != b.PinnedOrder { + if a.PinnedOrder == 0 { + return false + } + if b.PinnedOrder == 0 { + return true + } + return a.PinnedOrder < b.PinnedOrder + } + if a.TopMessageDate != b.TopMessageDate { + return a.TopMessageDate > b.TopMessageDate + } + if a.TopMessage != b.TopMessage { + return a.TopMessage > b.TopMessage + } + return a.Peer.ID > b.Peer.ID +} + +func afterDialogOffset(d domain.Dialog, filter domain.DialogFilter) bool { + if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { + return true + } + if filter.OffsetDate > 0 { + if d.TopMessageDate != filter.OffsetDate { + return d.TopMessageDate < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + if d.TopMessage != filter.OffsetID { + return d.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer { + return d.Peer.ID < filter.OffsetPeer.ID + } + return false + } + return d.TopMessage < filter.OffsetID +} + +func keepDialogMessages(messages []domain.Message, dialogs []domain.Dialog) []domain.Message { + want := make(map[int]struct{}, len(dialogs)) + for _, d := range dialogs { + if d.TopMessage != 0 { + want[d.TopMessage] = struct{}{} + } + } + out := make([]domain.Message, 0, len(want)) + for _, msg := range messages { + if _, ok := want[msg.ID]; ok { + out = append(out, msg) + } + } + return out +} + +func findDialogUser(users []domain.User, id int64) (domain.User, bool) { + for _, user := range users { + if user.ID == id { + return user, true + } + } + return domain.User{}, false +} + +func appendDialogUser(list *domain.DialogList, seen map[int64]struct{}, user domain.User) { + if user.ID == 0 { + return + } + if _, ok := seen[user.ID]; ok { + return + } + seen[user.ID] = struct{}{} + list.Users = append(list.Users, user) +} + +func cloneMessages(messages []domain.Message) []domain.Message { + out := append([]domain.Message(nil), messages...) + for i := range out { + out[i] = cloneMessage(out[i]) + } + return out +} + +func cloneDialogs(dialogs []domain.Dialog) []domain.Dialog { + out := append([]domain.Dialog(nil), dialogs...) + for i := range out { + if out[i].Draft != nil { + draft := cloneDialogDraft(*out[i].Draft) + out[i].Draft = &draft + } + } + return out +} + +func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft { + draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...) + draft.ReplyTo = cloneMessageReply(draft.ReplyTo) + if draft.WebPage != nil { + webpage := *draft.WebPage + draft.WebPage = &webpage + } + return draft +} + +func draftKey(peer domain.Peer, topMessageID int) dialogDraftKey { + return dialogDraftKey{peerType: peer.Type, peerID: peer.ID, topMessageID: topMessageID} +} + +func sortDialogDrafts(drafts []domain.DialogDraft) { + sort.SliceStable(drafts, func(i, j int) bool { + if drafts[i].Date != drafts[j].Date { + return drafts[i].Date > drafts[j].Date + } + if drafts[i].Peer.Type != drafts[j].Peer.Type { + return drafts[i].Peer.Type < drafts[j].Peer.Type + } + if drafts[i].Peer.ID != drafts[j].Peer.ID { + return drafts[i].Peer.ID > drafts[j].Peer.ID + } + return drafts[i].TopMessageID > drafts[j].TopMessageID + }) +} + +func cloneDialogFolder(folder domain.DialogFolder) domain.DialogFolder { + folder.TitleEntities = append([]domain.MessageEntity(nil), folder.TitleEntities...) + folder.PinnedPeers = append([]domain.DialogFolderPeer(nil), folder.PinnedPeers...) + folder.IncludePeers = append([]domain.DialogFolderPeer(nil), folder.IncludePeers...) + folder.ExcludePeers = append([]domain.DialogFolderPeer(nil), folder.ExcludePeers...) + return folder +} + +func containsInt(items []int, value int) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} + +func removeInt(items []int, value int) []int { + out := items[:0] + for _, item := range items { + if item != value { + out = append(out, item) + } + } + return out +} + +func dialogListHash(dialogs []domain.Dialog) int64 { + if len(dialogs) == 0 { + return 0 + } + h := fnv.New64a() + var buf [47]byte + for _, d := range dialogs { + binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage)) + binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate)) + binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID)) + binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID)) + binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount)) + binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions)) + binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions)) + if d.Pinned { + buf[40] = 1 + } else { + buf[40] = 0 + } + binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder)) + if d.UnreadMark { + buf[45] = 1 + } else { + buf[45] = 0 + } + if d.PeerSettingsBarHidden { + buf[46] = 1 + } else { + buf[46] = 0 + } + _, _ = h.Write(buf[:]) + } + return int64(h.Sum64()) +} + +// LangPackStore 是 store.LangPackStore 的内存实现。 +type LangPackStore struct { + mu sync.RWMutex + m map[string]domain.LangPack +} + +// NewLangPackStore 创建内存 LangPackStore。 +func NewLangPackStore() *LangPackStore { + return &LangPackStore{m: make(map[string]domain.LangPack)} +} + +func (s *LangPackStore) GetPack(_ context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + s.mu.RLock() + pack := s.m[langPackKey(langPack, langCode)] + s.mu.RUnlock() + if pack.LangPack == "" { + return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil + } + pack.FromVersion = fromVersion + if pack.Version <= fromVersion { + pack.Strings = nil + } else { + pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) + } + return pack, nil +} + +func (s *LangPackStore) GetStrings(_ context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { + s.mu.RLock() + pack := s.m[langPackKey(langPack, langCode)] + s.mu.RUnlock() + if pack.LangPack == "" { + return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil + } + if len(keys) == 0 { + pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) + return pack, nil + } + want := make(map[string]struct{}, len(keys)) + for _, key := range keys { + want[key] = struct{}{} + } + out := domain.LangPack{LangPack: pack.LangPack, LangCode: pack.LangCode, Version: pack.Version} + for _, item := range pack.Strings { + if _, ok := want[item.Key]; ok { + out.Strings = append(out.Strings, item) + } + } + return out, nil +} + +func (s *LangPackStore) UpsertPack(_ context.Context, pack domain.LangPack) error { + pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) + s.mu.Lock() + s.m[langPackKey(pack.LangPack, pack.LangCode)] = pack + s.mu.Unlock() + return nil +} + +func langPackKey(langPack, langCode string) string { + return langPack + "\x00" + langCode +} + +// PasswordStore 是 store.PasswordStore 的内存实现。 +type PasswordStore struct { + mu sync.RWMutex + m map[int64]domain.PasswordSettings + reactions map[int64]domain.AccountReactionSettings +} + +// NewPasswordStore 创建内存 PasswordStore。 +func NewPasswordStore() *PasswordStore { + return &PasswordStore{ + m: make(map[int64]domain.PasswordSettings), + reactions: make(map[int64]domain.AccountReactionSettings), + } +} + +func (s *PasswordStore) GetByUser(_ context.Context, userID int64) (domain.PasswordSettings, bool, error) { + s.mu.RLock() + settings, ok := s.m[userID] + s.mu.RUnlock() + settings.SecureRandom = append([]byte(nil), settings.SecureRandom...) + return settings, ok, nil +} + +func (s *PasswordStore) Save(_ context.Context, userID int64, settings domain.PasswordSettings) error { + settings.SecureRandom = append([]byte(nil), settings.SecureRandom...) + s.mu.Lock() + s.m[userID] = settings + s.mu.Unlock() + return nil +} + +func (s *PasswordStore) GetReactionSettings(_ context.Context, userID int64) (domain.AccountReactionSettings, bool, error) { + s.mu.RLock() + settings, ok := s.reactions[userID] + s.mu.RUnlock() + return cloneAccountReactionSettings(settings), ok, nil +} + +func (s *PasswordStore) SaveReactionSettings(_ context.Context, userID int64, settings domain.AccountReactionSettings) error { + s.mu.Lock() + s.reactions[userID] = cloneAccountReactionSettings(settings) + s.mu.Unlock() + return nil +} + +func cloneAccountReactionSettings(in domain.AccountReactionSettings) domain.AccountReactionSettings { + out := in + if in.PaidPrivacy.Peer != nil { + peer := *in.PaidPrivacy.Peer + out.PaidPrivacy.Peer = &peer + } + return out +} + +// HelpStore 是 store.AppConfigStore 和 store.CountryStore 的内存实现。 +type HelpStore struct { + mu sync.RWMutex + appConfig map[string]domain.AppConfig + countries domain.CountriesList +} + +// NewHelpStore 创建内存 HelpStore。 +func NewHelpStore() *HelpStore { + return &HelpStore{appConfig: make(map[string]domain.AppConfig)} +} + +func (s *HelpStore) GetAppConfig(_ context.Context, client string) (domain.AppConfig, bool, error) { + s.mu.RLock() + cfg, ok := s.appConfig[client] + s.mu.RUnlock() + cfg.JSON = append([]byte(nil), cfg.JSON...) + return cfg, ok, nil +} + +func (s *HelpStore) UpsertAppConfig(_ context.Context, cfg domain.AppConfig) error { + cfg.JSON = append([]byte(nil), cfg.JSON...) + s.mu.Lock() + s.appConfig[cfg.Client] = cfg + s.mu.Unlock() + return nil +} + +func (s *HelpStore) ListCountries(_ context.Context, _ string) (domain.CountriesList, error) { + s.mu.RLock() + list := s.countries + s.mu.RUnlock() + list.Countries = append([]domain.Country(nil), list.Countries...) + for i := range list.Countries { + list.Countries[i].CountryCodes = append([]domain.CountryCode(nil), list.Countries[i].CountryCodes...) + } + return list, nil +} + +func (s *HelpStore) UpsertCountries(_ context.Context, countries []domain.Country) error { + list := domain.CountriesList{Hash: 1, Countries: append([]domain.Country(nil), countries...)} + for i := range list.Countries { + list.Countries[i].CountryCodes = append([]domain.CountryCode(nil), list.Countries[i].CountryCodes...) + } + s.mu.Lock() + s.countries = list + s.mu.Unlock() + return nil +} + +// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。 +type UserStore struct { + mu sync.RWMutex + byID map[int64]domain.User + nextID int64 +} + +// NewUserStore 创建内存 UserStore。 +func NewUserStore() *UserStore { + return &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase} +} + +func (s *UserStore) ByID(_ context.Context, id int64) (domain.User, bool, error) { + s.mu.RLock() + u, ok := s.byID[id] + s.mu.RUnlock() + return u, ok, nil +} + +func (s *UserStore) ByIDs(_ context.Context, ids []int64) ([]domain.User, error) { + if len(ids) == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.User, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if u, ok := s.byID[id]; ok { + out = append(out, u) + } + } + return out, nil +} + +func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, u := range s.byID { + if u.Phone == phone { + return u, true, nil + } + } + return domain.User{}, false, nil +} + +func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User, error) { + if len(phones) == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + want := make(map[string]struct{}, len(phones)) + for _, phone := range phones { + if phone != "" { + want[phone] = struct{}{} + } + } + out := make([]domain.User, 0, len(want)) + seenIDs := map[int64]struct{}{} + for _, u := range s.byID { + if _, ok := want[u.Phone]; !ok { + continue + } + if _, ok := seenIDs[u.ID]; ok { + continue + } + seenIDs[u.ID] = struct{}{} + out = append(out, u) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, bool, error) { + username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if username == "" { + return domain.User{}, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + for _, u := range s.byID { + if strings.ToLower(u.Username) == username { + return u, true, nil + } + } + return domain.User{}, false, nil +} + +func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) { + if limit <= 0 { + limit = 50 + } + query = strings.ToLower(strings.TrimSpace(query)) + phoneQuery = strings.TrimSpace(phoneQuery) + if query == "" { + return domain.UserSearchResult{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + users := make([]domain.User, 0) + for _, u := range s.byID { + if u.ID == currentUserID { + continue + } + if userMatchesSearch(u, query, phoneQuery) { + users = append(users, u) + } + } + sort.SliceStable(users, func(i, j int) bool { + return users[i].ID < users[j].ID + }) + if len(users) > limit { + users = users[:limit] + } + return domain.UserSearchResult{Results: users}, nil +} + +func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) { + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + usernameLower := strings.ToLower(username) + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUsernameNotOccupied + } + if usernameLower != "" { + for id, existing := range s.byID { + if id != userID && strings.ToLower(existing.Username) == usernameLower { + return domain.User{}, domain.ErrUsernameOccupied + } + } + } + u.Username = username + s.byID[userID] = u + return u, nil +} + +func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, lastName, about string) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUsernameNotOccupied + } + u.FirstName = firstName + u.LastName = lastName + u.About = about + s.byID[userID] = u + return u, nil +} + +func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt int) error { + if lastSeenAt <= 0 { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.ErrUsernameNotOccupied + } + if lastSeenAt > u.LastSeenAt { + u.LastSeenAt = lastSeenAt + s.byID[userID] = u + } + return nil +} + +func userMatchesSearch(u domain.User, query, phoneQuery string) bool { + if phoneQuery != "" && strings.HasPrefix(u.Phone, phoneQuery) { + return true + } + first := strings.ToLower(u.FirstName) + last := strings.ToLower(u.LastName) + username := strings.ToLower(u.Username) + fullName := strings.TrimSpace(first + " " + last) + return strings.Contains(first, query) || + strings.Contains(last, query) || + strings.Contains(fullName, query) || + strings.Contains(username, query) +} + +func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + username := strings.ToLower(strings.TrimSpace(u.Username)) + if username != "" { + for _, existing := range s.byID { + if strings.ToLower(existing.Username) == username { + return domain.User{}, domain.ErrUsernameOccupied + } + } + } + u.ID = s.nextID + s.nextID++ + s.byID[u.ID] = u + return u, nil +} + +// AuthorizationStore 是 store.AuthorizationStore 的内存实现。 +type AuthorizationStore struct { + mu sync.RWMutex + m map[[8]byte]domain.Authorization +} + +// NewAuthorizationStore 创建内存 AuthorizationStore。 +func NewAuthorizationStore() *AuthorizationStore { + return &AuthorizationStore{m: make(map[[8]byte]domain.Authorization)} +} + +func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) error { + s.mu.Lock() + s.m[a.AuthKeyID] = a + s.mu.Unlock() + return nil +} + +func (s *AuthorizationStore) ByAuthKey(_ context.Context, id [8]byte) (domain.Authorization, bool, error) { + s.mu.RLock() + a, ok := s.m[id] + s.mu.RUnlock() + return a, ok, nil +} + +func (s *AuthorizationStore) ListByUser(_ context.Context, userID int64) ([]domain.Authorization, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Authorization, 0) + for _, a := range s.m { + if a.UserID == userID { + out = append(out, a) + } + } + return out, nil +} + +func (s *AuthorizationStore) Delete(_ context.Context, id [8]byte) error { + s.mu.Lock() + delete(s.m, id) + s.mu.Unlock() + return nil +} + +// CodeStore 是 store.CodeStore 的内存实现(带 TTL)。 +type CodeStore struct { + mu sync.Mutex + m map[string]codeEntry +} + +type codeEntry struct { + code store.PhoneCode + expires time.Time +} + +// NewCodeStore 创建内存 CodeStore。 +func NewCodeStore() *CodeStore { + return &CodeStore{m: make(map[string]codeEntry)} +} + +func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error { + s.mu.Lock() + s.m[hash] = codeEntry{code: code, expires: time.Now().Add(ttl)} + s.mu.Unlock() + return nil +} + +func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.m[hash] + if !ok || time.Now().After(e.expires) { + return store.PhoneCode{}, false, nil + } + return e.code, true, nil +} + +func (s *CodeStore) Del(_ context.Context, hash string) error { + s.mu.Lock() + delete(s.m, hash) + s.mu.Unlock() + return nil +} diff --git a/internal/store/memory/message_test.go b/internal/store/memory/message_test.go new file mode 100644 index 00000000..37dd776c --- /dev/null +++ b/internal/store/memory/message_test.go @@ -0,0 +1,531 @@ +package memory + +import ( + "context" + "errors" + "reflect" + "testing" + + "telesrv/internal/domain" +) + +func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + req := domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 99, + Message: "hello", + Date: 1700000100, + } + + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if got.SenderMessage.ID != 1 || got.SenderMessage.OwnerUserID != req.SenderUserID || !got.SenderMessage.Out || got.SenderMessage.Pts != 1 { + t.Fatalf("sender message = %+v, want first outgoing box with pts=1", got.SenderMessage) + } + if got.RecipientMessage.ID != 1 || got.RecipientMessage.OwnerUserID != req.RecipientUserID || got.RecipientMessage.Out || got.RecipientMessage.Pts != 1 { + t.Fatalf("recipient message = %+v, want first incoming box with pts=1", got.RecipientMessage) + } + if got.SenderMessage.UID == 0 || got.SenderMessage.UID != got.RecipientMessage.UID { + t.Fatalf("uid = sender %d recipient %d, want shared private message uid", got.SenderMessage.UID, got.RecipientMessage.UID) + } + + second, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: req.SenderUserID, + RecipientUserID: req.RecipientUserID, + RandomID: 100, + Message: "again", + Date: 1700000110, + }) + if err != nil { + t.Fatalf("SendPrivateText second: %v", err) + } + if second.SenderMessage.ID != 2 || second.SenderMessage.Pts != 2 || second.RecipientMessage.ID != 2 || second.RecipientMessage.Pts != 2 { + t.Fatalf("second send = %+v/%+v, want per-owner box_id and pts to advance", second.SenderMessage, second.RecipientMessage) + } + + dup, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText duplicate: %v", err) + } + if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID { + t.Fatalf("duplicate = %+v, want original message boxes", dup) + } + + senderHistory, err := messages.ListByUser(ctx, req.SenderUserID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history: %v", err) + } + recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(senderHistory.Messages) != 2 || len(recipientHistory.Messages) != 2 { + t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages)) + } +} + +func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + aliceID := int64(1000000001) + bobID := int64(1000000002) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 101, + Message: "react to me", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} + res, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{ + UserID: bobID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + MessageID: sent.RecipientMessage.ID, + Reactions: []domain.MessageReaction{ + reaction, + }, + Big: true, + Date: 1700000200, + }) + if err != nil { + t.Fatalf("SetMessageReactions: %v", err) + } + if len(res.Messages) != 2 { + t.Fatalf("reaction result messages = %d, want both owner boxes", len(res.Messages)) + } + + aliceReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{ + OwnerUserID: aliceID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + IDs: []int{sent.SenderMessage.ID}, + }) + if err != nil { + t.Fatalf("alice GetMessageReactions: %v", err) + } + if len(aliceReactions.Messages) != 1 || aliceReactions.Messages[0].Reactions == nil { + t.Fatalf("alice reactions = %+v, want one enriched message", aliceReactions) + } + if got := aliceReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].Reaction != reaction || got[0].Count != 1 || got[0].ChosenOrder != 0 { + t.Fatalf("alice reaction counts = %+v, want one peer reaction without chosen order", got) + } + if got := aliceReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || !got[0].Big || got[0].My { + t.Fatalf("alice recent reactions = %+v, want bob non-my big reaction", got) + } + + bobReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{ + OwnerUserID: bobID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + IDs: []int{sent.RecipientMessage.ID}, + }) + if err != nil { + t.Fatalf("bob GetMessageReactions: %v", err) + } + if got := bobReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].ChosenOrder != 1 { + t.Fatalf("bob reaction counts = %+v, want own chosen order", got) + } + if got := bobReactions.Messages[0].Reactions.Recent; len(got) != 1 || !got[0].My { + t.Fatalf("bob recent reactions = %+v, want my reaction", got) + } +} + +func TestMessageStoreSendPrivateTextReplyAndForwardMetadata(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + aliceID := int64(1000000001) + bobID := int64(1000000002) + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 501, + Message: "first", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("seed SendPrivateText: %v", err) + } + reply, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 502, + Message: "reply", + Silent: true, + NoForwards: true, + ReplyTo: &domain.MessageReply{ + MessageID: first.SenderMessage.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + QuoteText: "fir", + QuoteOffset: 0, + }, + Date: 1700000110, + }) + if err != nil { + t.Fatalf("reply SendPrivateText: %v", err) + } + if reply.SenderMessage.ReplyTo == nil || reply.SenderMessage.ReplyTo.MessageID != first.SenderMessage.ID { + t.Fatalf("sender reply = %+v, want sender-side message id", reply.SenderMessage.ReplyTo) + } + if reply.RecipientMessage.ReplyTo == nil || reply.RecipientMessage.ReplyTo.MessageID != first.RecipientMessage.ID { + t.Fatalf("recipient reply = %+v, want translated recipient-side message id", reply.RecipientMessage.ReplyTo) + } + if !reply.SenderMessage.Silent || !reply.SenderMessage.NoForwards { + t.Fatalf("reply flags = silent %v noforwards %v, want true/true", reply.SenderMessage.Silent, reply.SenderMessage.NoForwards) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 504, + Message: "bad quote offset", + ReplyTo: &domain.MessageReply{ + MessageID: first.SenderMessage.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + QuoteText: "fir", + QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1, + }, + Date: 1700000115, + }); !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err) + } + + forwarded, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{ + OwnerUserID: aliceID, + FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + ToUserID: bobID, + MessageIDs: []int{first.SenderMessage.ID}, + RandomIDs: []int64{503}, + ReplyTo: &domain.MessageReply{ + MessageID: first.SenderMessage.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + }, + Date: 1700000120, + }) + if err != nil { + t.Fatalf("ForwardPrivateMessages: %v", err) + } + if len(forwarded.SenderMessages) != 1 || forwarded.SenderMessages[0].Forward == nil || forwarded.SenderMessages[0].Forward.From.ID != aliceID { + t.Fatalf("forwarded messages = %+v, want original author header", forwarded.SenderMessages) + } + if forwarded.SenderMessages[0].ReplyTo == nil || forwarded.SenderMessages[0].ReplyTo.MessageID != first.SenderMessage.ID { + t.Fatalf("forward reply = %+v, want target dialog reply header", forwarded.SenderMessages[0].ReplyTo) + } + if _, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{ + OwnerUserID: aliceID, + FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + ToUserID: aliceID, + MessageIDs: []int{reply.SenderMessage.ID}, + RandomIDs: []int64{504}, + Date: 1700000130, + }); err != domain.ErrChatForwardsRestricted { + t.Fatalf("forward protected err=%v, want ErrChatForwardsRestricted", err) + } +} + +func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + aliceID := int64(1000000001) + bobID := int64(1000000002) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: aliceID} + + for i := 1; i <= 6; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: int64(600 + i), + Message: "history", + Date: 1700000000 + i, + }); err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + } + + around, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -3, + Limit: 6, + }) + if err != nil { + t.Fatalf("around history: %v", err) + } + if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) { + t.Fatalf("around ids = %v, want unread/newer side plus older context", got) + } + + forward, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -3, + Limit: 3, + }) + if err != nil { + t.Fatalf("forward history: %v", err) + } + if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) { + t.Fatalf("forward ids = %v, want messages newer than offset", got) + } + + hugePositive, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + AddOffset: 1 << 30, + Limit: 3, + }) + if err != nil { + t.Fatalf("huge positive add_offset history: %v", err) + } + if len(hugePositive.Messages) != 0 { + t.Fatalf("huge positive add_offset ids = %v, want bounded empty page", messageIDs(hugePositive.Messages)) + } + + hugeNegative, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -1 << 30, + Limit: 3, + }) + if err != nil { + t.Fatalf("huge negative add_offset history: %v", err) + } + if got := messageIDs(hugeNegative.Messages); !sameInts(got, []int{6, 5, 4}) { + t.Fatalf("huge negative add_offset ids = %v, want clamped forward page", got) + } +} + +func TestMessageStoreReadHistoryEmitsInboxAndOutboxReceipts(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000001) + recipientID := int64(1000000002) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 101, + Message: "hello", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + + read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipientID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, + Date: 1700000200, + }) + if err != nil { + t.Fatalf("ReadHistory: %v", err) + } + if !read.Changed || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.Pts != 2 || read.InboxEvent.MaxID != sent.RecipientMessage.ID { + t.Fatalf("inbox read = %+v, want recipient inbox pts=2 max recipient id", read) + } + if !read.OutboxChanged || read.OutboxUserID != senderID || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID { + t.Fatalf("outbox read = %+v, want sender outbox receipt with sender message id", read) + } + date, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{ + OwnerUserID: senderID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, + ID: sent.SenderMessage.ID, + }) + if err != nil || date != 1700000200 { + t.Fatalf("outbox read date = %d err=%v, want read date", date, err) + } +} + +func TestMessageStoreReadMessageContentsReturnsExistingOwnerIDs(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1001, + RecipientUserID: 1002, + RandomID: 88, + Message: "voice placeholder", + Date: 1700000300, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + got, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: 1002, + IDs: []int{sent.RecipientMessage.ID, domain.MaxMessageBoxID}, + }) + if err != nil { + t.Fatalf("ReadMessageContents: %v", err) + } + if !reflect.DeepEqual(got.MessageIDs, []int{sent.RecipientMessage.ID}) { + t.Fatalf("MessageIDs = %v, want existing recipient id", got.MessageIDs) + } + if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: 1002, + IDs: []int{0}, + }); !errors.Is(err, domain.ErrMessageIDInvalid) { + t.Fatalf("invalid id error = %v, want ErrMessageIDInvalid", err) + } +} + +func messageIDs(messages []domain.Message) []int { + out := make([]int, 0, len(messages)) + for _, msg := range messages { + out = append(out, msg.ID) + } + return out +} + +func sameInts(got, want []int) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +func TestMessageStoreEditMessageUpdatesBothBoxes(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000001) + recipientID := int64(1000000002) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 102, + Message: "before", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + + edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: senderID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, + ID: sent.SenderMessage.ID, + Message: "after", + EditDate: 1700000200, + }) + if err != nil { + t.Fatalf("EditMessage: %v", err) + } + if len(edited.Edited) != 2 || edited.Self().Message.Body != "after" || edited.Self().Event.Type != domain.UpdateEventEditMessage { + t.Fatalf("edited = %+v, want both owner boxes and self edit event", edited) + } + recipientHistory, err := messages.ListByUser(ctx, recipientID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after" || recipientHistory.Messages[0].EditDate != 1700000200 { + t.Fatalf("recipient history = %+v, want edited body/date", recipientHistory.Messages) + } +} + +func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000001) + recipientID := int64(1000000002) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipientID} + + for i := 0; i < 2; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: int64(100 + i), + Message: "hello", + Date: 1700000200 + i, + }); err != nil { + t.Fatalf("seed send %d: %v", i, err) + } + } + deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: senderID, + Peer: peer, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("DeleteHistory: %v", err) + } + if self := deleted.Self(); self.Event.Pts != 4 || self.Event.PtsCount != 2 || len(self.MessageIDs) != 2 { + t.Fatalf("delete result = %+v, want sender delete range pts=4 count=2", self) + } + senderHistory, err := messages.ListByUser(ctx, senderID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history: %v", err) + } + recipientHistory, err := messages.ListByUser(ctx, recipientID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 { + t.Fatalf("history sizes sender=%d recipient=%d, want sender cleared only", len(senderHistory.Messages), len(recipientHistory.Messages)) + } + senderDialogs, err := dialogs.ListByUser(ctx, senderID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("sender dialogs after delete: %v", err) + } + if len(senderDialogs.Dialogs) != 0 { + t.Fatalf("sender dialogs = %+v, want dialog deleted after full history delete", senderDialogs.Dialogs) + } + + rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 200, + Message: "rebuilt", + Date: 1700000400, + }) + if err != nil { + t.Fatalf("send after delete: %v", err) + } + senderDialogs, err = dialogs.ListByUser(ctx, senderID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("sender dialogs after rebuild: %v", err) + } + if len(senderDialogs.Dialogs) != 1 || senderDialogs.Dialogs[0].Peer != peer || senderDialogs.Dialogs[0].TopMessage != rebuilt.SenderMessage.ID { + t.Fatalf("rebuilt dialogs = %+v, want one dialog with new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID) + } + + preservedOwner := int64(1000000003) + preservedPeerID := int64(1000000004) + preservedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: preservedPeerID} + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: preservedOwner, + RecipientUserID: preservedPeerID, + RandomID: 300, + Message: "clear but keep dialog", + Date: 1700000500, + }); err != nil { + t.Fatalf("seed preserved send: %v", err) + } + if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: preservedOwner, + Peer: preservedPeer, + JustClear: true, + Date: 1700000600, + }); err != nil { + t.Fatalf("DeleteHistory just_clear: %v", err) + } + preservedDialogs, err := dialogs.ListByUser(ctx, preservedOwner, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("preserved dialogs: %v", err) + } + if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer || preservedDialogs.Dialogs[0].TopMessage != 0 || len(preservedDialogs.Messages) != 0 { + t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages) + } +} diff --git a/internal/store/message.go b/internal/store/message.go new file mode 100644 index 00000000..ea842f8e --- /dev/null +++ b/internal/store/message.go @@ -0,0 +1,24 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// MessageStore 持久化账号视角下的消息。 +type MessageStore interface { + Create(ctx context.Context, msg domain.Message) (domain.Message, error) + SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) + ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) + ReadHistory(ctx context.Context, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) + ReadMessageContents(ctx context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) + GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error) + SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) + GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) + EditMessage(ctx context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error) + DeleteMessages(ctx context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) + DeleteHistory(ctx context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) + GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) + ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) +} diff --git a/internal/store/postgres/account.go b/internal/store/postgres/account.go new file mode 100644 index 00000000..33b51784 --- /dev/null +++ b/internal/store/postgres/account.go @@ -0,0 +1,126 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// PasswordStore 用 PostgreSQL 实现 store.PasswordStore。 +type PasswordStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewPasswordStore 基于 pgx 连接池(或事务)创建 PasswordStore。 +func NewPasswordStore(db sqlcgen.DBTX) *PasswordStore { + return &PasswordStore{db: db, q: sqlcgen.New(db)} +} + +func (s *PasswordStore) GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) { + row, err := s.q.GetPasswordByUser(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PasswordSettings{}, false, nil + } + return domain.PasswordSettings{}, false, fmt.Errorf("get account password: %w", err) + } + return domain.PasswordSettings{ + HasRecovery: row.HasRecovery, + HasSecureValues: row.HasSecureValues, + HasPassword: row.HasPassword, + Hint: row.Hint, + EmailUnconfirmedPattern: row.EmailUnconfirmedPattern, + LoginEmailPattern: row.LoginEmailPattern, + SecureRandom: append([]byte(nil), row.SecureRandom...), + }, true, nil +} + +func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error { + if err := s.q.UpsertPassword(ctx, sqlcgen.UpsertPasswordParams{ + UserID: userID, + HasRecovery: settings.HasRecovery, + HasSecureValues: settings.HasSecureValues, + HasPassword: settings.HasPassword, + Hint: settings.Hint, + EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern, + LoginEmailPattern: settings.LoginEmailPattern, + SecureRandom: settings.SecureRandom, + }); err != nil { + return fmt.Errorf("upsert account password: %w", err) + } + return nil +} + +func (s *PasswordStore) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews, + default_reaction_type, default_reaction_value, + paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id +FROM account_reaction_settings +WHERE user_id = $1`, userID) + var messagesFrom, storiesFrom, pollVotesFrom string + var defaultType, defaultValue string + var paidKind string + var paidPeerType sql.NullString + var paidPeerID sql.NullInt64 + settings := domain.DefaultAccountReactionSettings() + if err := row.Scan( + &messagesFrom, &storiesFrom, &pollVotesFrom, &settings.Notify.ShowPreviews, + &defaultType, &defaultValue, &paidKind, &paidPeerType, &paidPeerID, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountReactionSettings{}, false, nil + } + return domain.AccountReactionSettings{}, false, fmt.Errorf("get account reaction settings: %w", err) + } + settings.Notify.MessagesFrom = domain.ReactionNotifyFrom(messagesFrom) + settings.Notify.StoriesFrom = domain.ReactionNotifyFrom(storiesFrom) + settings.Notify.PollVotesFrom = domain.ReactionNotifyFrom(pollVotesFrom) + settings.DefaultReaction = domain.MessageReaction{Type: domain.MessageReactionType(defaultType), Emoticon: defaultValue} + settings.PaidPrivacy = domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyKind(paidKind)} + if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && paidPeerType.Valid && paidPeerID.Valid { + peer := domain.Peer{Type: domain.PeerType(paidPeerType.String), ID: paidPeerID.Int64} + settings.PaidPrivacy.Peer = &peer + } + return settings, true, nil +} + +func (s *PasswordStore) SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error { + var paidPeerType any + var paidPeerID any + if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && settings.PaidPrivacy.Peer != nil { + paidPeerType = string(settings.PaidPrivacy.Peer.Type) + paidPeerID = settings.PaidPrivacy.Peer.ID + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO account_reaction_settings ( + user_id, messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews, + default_reaction_type, default_reaction_value, paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) +ON CONFLICT (user_id) DO UPDATE SET + messages_notify_from = EXCLUDED.messages_notify_from, + stories_notify_from = EXCLUDED.stories_notify_from, + poll_votes_notify_from = EXCLUDED.poll_votes_notify_from, + show_previews = EXCLUDED.show_previews, + default_reaction_type = EXCLUDED.default_reaction_type, + default_reaction_value = EXCLUDED.default_reaction_value, + paid_privacy_kind = EXCLUDED.paid_privacy_kind, + paid_privacy_peer_type = EXCLUDED.paid_privacy_peer_type, + paid_privacy_peer_id = EXCLUDED.paid_privacy_peer_id, + updated_at = now()`, + userID, + string(settings.Notify.MessagesFrom), string(settings.Notify.StoriesFrom), string(settings.Notify.PollVotesFrom), settings.Notify.ShowPreviews, + string(settings.DefaultReaction.Type), settings.DefaultReaction.Emoticon, + string(settings.PaidPrivacy.Kind), paidPeerType, paidPeerID, + ); err != nil { + return fmt.Errorf("save account reaction settings: %w", err) + } + return nil +} diff --git a/internal/store/postgres/auth_login_integration_test.go b/internal/store/postgres/auth_login_integration_test.go new file mode 100644 index 00000000..a760b33e --- /dev/null +++ b/internal/store/postgres/auth_login_integration_test.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "context" + "crypto/rand" + "fmt" + "strings" + "testing" + "time" + + appauth "telesrv/internal/app/auth" + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +func TestAuthSignUpWritesOfficialLoginMessagePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + phone := fmt.Sprintf("1555%d31", time.Now().UnixNano()) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE phone = $1", phone) + }) + + users := NewUserStore(pool) + dialogs := NewDialogStore(pool) + messages := NewMessageStore(pool) + svc := appauth.NewService( + users, + NewAuthorizationStore(pool), + memory.NewCodeStore(), + nil, + nil, + "12345", + appauth.WithLoginMessages(messages, dialogs), + ) + + var authKeyID [8]byte + var authKeyBody [256]byte + if _, err := rand.Read(authKeyID[:]); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(authKeyBody[:]); err != nil { + t.Fatal(err) + } + if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{ID: authKeyID, Value: authKeyBody}); err != nil { + t.Fatalf("save auth key: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authKeyID)) + }) + hash, err := svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode: %v", err) + } + if _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345"); err != nil || !needSignUp { + t.Fatalf("SignIn needSignUp = %v err = %v, want need sign-up", needSignUp, err) + } + + u, msg, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "PgLogin", "Test") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if u.Phone != phone || msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") { + t.Fatalf("sign-up user/message = user %+v message %+v, want login message", u, msg) + } + + systemUser, found, err := users.ByID(ctx, domain.OfficialSystemUserID) + if err != nil || !found || !systemUser.Verified || !systemUser.Support { + t.Fatalf("official system user = %+v found=%v err=%v, want seeded verified support user", systemUser, found, err) + } + list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID { + t.Fatalf("dialogs = %+v, want official login dialog", list.Dialogs) + } + if len(list.Messages) != 1 || list.Messages[0].ID != msg.ID || !strings.Contains(list.Messages[0].Body, "Login code: 12345") { + t.Fatalf("messages = %+v, want returned login message", list.Messages) + } + if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID || !list.Users[0].Verified || !list.Users[0].Support { + t.Fatalf("users = %+v, want official support user", list.Users) + } +} diff --git a/internal/store/postgres/authkey.go b/internal/store/postgres/authkey.go new file mode 100644 index 00000000..faba897c --- /dev/null +++ b/internal/store/postgres/authkey.go @@ -0,0 +1,67 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// AuthKeyStore 用 PostgreSQL 实现 store.AuthKeyStore。 +type AuthKeyStore struct { + q *sqlcgen.Queries +} + +// NewAuthKeyStore 基于 pgx 连接池(或事务)创建 AuthKeyStore。 +func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore { + return &AuthKeyStore{q: sqlcgen.New(db)} +} + +// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT; +// created_at 交由 DB 默认值(now()),故传入的 CreatedAt 不落库。 +func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error { + if err := s.q.UpsertAuthKey(ctx, sqlcgen.UpsertAuthKeyParams{ + AuthKeyID: authKeyIDToInt64(k.ID), + Body: k.Value[:], + ServerSalt: k.ServerSalt, + }); err != nil { + return fmt.Errorf("upsert auth key: %w", err) + } + return nil +} + +// Get 实现 store.AuthKeyStore。不存在时 found=false。 +func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) { + row, err := s.q.GetAuthKey(ctx, authKeyIDToInt64(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return store.AuthKeyData{}, false, nil + } + return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err) + } + if len(row.Body) != len(store.AuthKeyData{}.Value) { + return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(row.Body)) + } + data := store.AuthKeyData{ID: id, ServerSalt: row.ServerSalt} + copy(data.Value[:], row.Body) + if row.CreatedAt.Valid { + data.CreatedAt = row.CreatedAt.Time.Unix() + } + return data, true, nil +} + +// authKeyIDToInt64 把 [8]byte 的 auth_key_id 按小端解释为 int64(MTProto 定义即 SHA1 低 64 位)。 +func authKeyIDToInt64(id [8]byte) int64 { + return int64(binary.LittleEndian.Uint64(id[:])) +} + +func authKeyIDFromInt64(v int64) [8]byte { + var id [8]byte + binary.LittleEndian.PutUint64(id[:], uint64(v)) + return id +} diff --git a/internal/store/postgres/authkey_integration_test.go b/internal/store/postgres/authkey_integration_test.go new file mode 100644 index 00000000..3523b5cc --- /dev/null +++ b/internal/store/postgres/authkey_integration_test.go @@ -0,0 +1,72 @@ +package postgres + +import ( + "context" + "crypto/rand" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/store" +) + +// testPool 连接 TELESRV_TEST_POSTGRES_DSN 指向的库(迁移到最新),未设则跳过。 +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test") + } + if err := Migrate(dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := Open(context.Background(), dsn) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +// TestAuthKeyStoreRoundTrip 验证 auth_key 落 PG 后,用全新 store 实例(模拟进程重启、无内存缓存)能原样读回。 +// 这是「server 重启保住 auth_key」的直接证明。 +func TestAuthKeyStoreRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + var id [8]byte + var val [256]byte + if _, err := rand.Read(id[:]); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(val[:]); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(id)) + }) + + want := store.AuthKeyData{ID: id, Value: val, ServerSalt: 0x0badf00d} + if err := NewAuthKeyStore(pool).Save(ctx, want); err != nil { + t.Fatalf("save: %v", err) + } + + got, found, err := NewAuthKeyStore(pool).Get(ctx, id) + if err != nil { + t.Fatalf("get: %v", err) + } + if !found { + t.Fatal("auth key not found after save (重启后丢失)") + } + if got.ID != want.ID || got.Value != want.Value || got.ServerSalt != want.ServerSalt { + t.Fatalf("round trip mismatch: got salt=%#x value[:4]=%x, want salt=%#x value[:4]=%x", + got.ServerSalt, got.Value[:4], want.ServerSalt, want.Value[:4]) + } + + var missing [8]byte + missing[0] = id[0] ^ 0xff + if _, found, err := NewAuthKeyStore(pool).Get(ctx, missing); err != nil || found { + t.Fatalf("missing key: found=%v err=%v, want found=false err=nil", found, err) + } +} diff --git a/internal/store/postgres/authorization.go b/internal/store/postgres/authorization.go new file mode 100644 index 00000000..d6284799 --- /dev/null +++ b/internal/store/postgres/authorization.go @@ -0,0 +1,93 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// AuthorizationStore 用 PostgreSQL 实现 store.AuthorizationStore。 +type AuthorizationStore struct { + q *sqlcgen.Queries +} + +// NewAuthorizationStore 基于 pgx 连接池(或事务)创建 AuthorizationStore。 +func NewAuthorizationStore(db sqlcgen.DBTX) *AuthorizationStore { + return &AuthorizationStore{q: sqlcgen.New(db)} +} + +func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) error { + if err := s.q.UpsertAuthorization(ctx, sqlcgen.UpsertAuthorizationParams{ + AuthKeyID: authKeyIDToInt64(a.AuthKeyID), + UserID: a.UserID, + Layer: int32(a.Layer), + DeviceModel: a.DeviceModel, + Platform: a.Platform, + SystemVersion: a.SystemVersion, + ApiID: int32(a.APIID), + AppVersion: a.AppVersion, + Ip: a.IP, + }); err != nil { + return fmt.Errorf("upsert authorization: %w", err) + } + return nil +} + +func (s *AuthorizationStore) ByAuthKey(ctx context.Context, id [8]byte) (domain.Authorization, bool, error) { + row, err := s.q.GetAuthorizationByAuthKey(ctx, authKeyIDToInt64(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Authorization{}, false, nil + } + return domain.Authorization{}, false, fmt.Errorf("get authorization: %w", err) + } + return domain.Authorization{ + AuthKeyID: id, + UserID: row.UserID, + Layer: int(row.Layer), + DeviceModel: row.DeviceModel, + Platform: row.Platform, + SystemVersion: row.SystemVersion, + APIID: int(row.ApiID), + AppVersion: row.AppVersion, + IP: row.Ip, + }, true, nil +} + +func (s *AuthorizationStore) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) { + rows, err := s.q.ListAuthorizationsByUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list authorizations by user: %w", err) + } + out := make([]domain.Authorization, 0, len(rows)) + for _, row := range rows { + out = append(out, authorizationFromRow(row)) + } + return out, nil +} + +func (s *AuthorizationStore) Delete(ctx context.Context, id [8]byte) error { + if err := s.q.DeleteAuthorization(ctx, authKeyIDToInt64(id)); err != nil { + return fmt.Errorf("delete authorization: %w", err) + } + return nil +} + +func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization { + return domain.Authorization{ + AuthKeyID: authKeyIDFromInt64(row.AuthKeyID), + UserID: row.UserID, + Layer: int(row.Layer), + DeviceModel: row.DeviceModel, + Platform: row.Platform, + SystemVersion: row.SystemVersion, + APIID: int(row.ApiID), + AppVersion: row.AppVersion, + IP: row.Ip, + } +} diff --git a/internal/store/postgres/business_integration_test.go b/internal/store/postgres/business_integration_test.go new file mode 100644 index 00000000..ef156ee9 --- /dev/null +++ b/internal/store/postgres/business_integration_test.go @@ -0,0 +1,288 @@ +package postgres + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestBusinessStoresRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + suffix := randomSuffix(t) + var authID [8]byte + var authBody [256]byte + if _, err := rand.Read(authID[:]); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(authBody[:]); err != nil { + t.Fatal(err) + } + if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{ + ID: authID, + Value: authBody, + ServerSalt: 42, + }); err != nil { + t.Fatalf("save auth key: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authID)) + }) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 1, + Phone: "+1555" + suffix + "01", + FirstName: "Owner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + if owner.ID < domain.UserIDSequenceBase { + t.Fatalf("owner id = %d, want >= base %d", owner.ID, domain.UserIDSequenceBase) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 2, + Phone: "+1555" + suffix + "02", + FirstName: "Friend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + username := "owner_" + suffix + owner, err = users.UpdateUsername(ctx, owner.ID, username) + if err != nil { + t.Fatalf("update owner username: %v", err) + } + if owner.Username != username { + t.Fatalf("owner username = %q, want %q", owner.Username, username) + } + byUsername, found, err := users.ByUsername(ctx, strings.ToUpper(username)) + if err != nil || !found || byUsername.ID != owner.ID { + t.Fatalf("by username = user %+v found %v err %v, want owner", byUsername, found, err) + } + if _, err := users.UpdateUsername(ctx, friend.ID, strings.ToUpper(username)); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("duplicate username err = %v, want username occupied", err) + } + + if _, err := pool.Exec(ctx, ` + INSERT INTO contacts (user_id, contact_user_id, mutual) + VALUES ($1, $2, true) + `, owner.ID, friend.ID); err != nil { + t.Fatalf("insert contact: %v", err) + } + contacts, err := NewContactStore(pool).ListByUser(ctx, owner.ID) + if err != nil { + t.Fatalf("list contacts: %v", err) + } + if len(contacts.Contacts) != 1 || contacts.Contacts[0].User.ID != friend.ID || !contacts.Contacts[0].Mutual { + t.Fatalf("contacts = %+v, want friend mutual contact", contacts) + } + if contacts.Hash == 0 { + t.Fatal("contacts hash is zero for non-empty contact list") + } + search, err := users.Search(ctx, owner.ID, "Friend", "", 10) + if err != nil { + t.Fatalf("search users: %v", err) + } + if len(search.MyResults) != 1 || search.MyResults[0].ID != friend.ID || !search.MyResults[0].Contact { + t.Fatalf("search contacts = %+v, want friend in my results", search) + } + + if _, err := pool.Exec(ctx, ` + INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, unread_count, pinned) + VALUES ($1, 'user', $2, 10, 2, true) + `, owner.ID, friend.ID); err != nil { + t.Fatalf("insert dialog: %v", err) + } + dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{PinnedOnly: true, Limit: 10}) + if err != nil { + t.Fatalf("list dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer.ID != friend.ID || !dialogs.Dialogs[0].Pinned { + t.Fatalf("dialogs = %+v, want pinned friend dialog", dialogs) + } + emptyDialogsPage, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{ + PinnedOnly: true, + OffsetID: dialogs.Dialogs[0].TopMessage, + HasOffsetPeer: true, + OffsetPeer: dialogs.Dialogs[0].Peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("list empty dialogs page: %v", err) + } + if len(emptyDialogsPage.Dialogs) != 0 || emptyDialogsPage.Count != dialogs.Count || emptyDialogsPage.Hash != dialogs.Hash { + t.Fatalf("empty dialogs page = %+v, want empty rows with full count/hash from %+v", emptyDialogsPage, dialogs) + } + peerDialogs, err := NewDialogStore(pool).ListByPeers(ctx, owner.ID, []domain.Peer{ + {Type: domain.PeerTypeUser, ID: friend.ID}, + {Type: domain.PeerTypeUser, ID: owner.ID}, + }) + if err != nil { + t.Fatalf("list peer dialogs: %v", err) + } + if len(peerDialogs.Dialogs) != 2 || peerDialogs.Dialogs[0].Peer.ID != friend.ID || peerDialogs.Dialogs[0].TopMessage != 10 { + t.Fatalf("peer dialogs = %+v, want friend dialog plus owner placeholder", peerDialogs) + } + if peerDialogs.Dialogs[1].Peer.ID != owner.ID || peerDialogs.Dialogs[1].TopMessage != 0 { + t.Fatalf("placeholder dialog = %+v, want owner empty dialog", peerDialogs.Dialogs[1]) + } + if len(peerDialogs.Users) != 2 { + t.Fatalf("peer dialog users = %+v, want both requested users", peerDialogs.Users) + } + + wantState := domain.UpdateState{Pts: 11, Qts: 12, Date: 13, Seq: 14} + states := NewUpdateStateStore(pool) + if err := states.Save(ctx, authID, owner.ID, wantState); err != nil { + t.Fatalf("save update state: %v", err) + } + gotState, found, err := states.Get(ctx, authID, owner.ID) + if err != nil { + t.Fatalf("get update state: %v", err) + } + if !found || gotState != wantState { + t.Fatalf("update state = %+v found=%v, want %+v found=true", gotState, found, wantState) + } + if err := states.Delete(ctx, authID, owner.ID); err != nil { + t.Fatalf("delete update state: %v", err) + } + if _, found, err := states.Get(ctx, authID, owner.ID); err != nil || found { + t.Fatalf("state after delete found=%v err=%v, want not found", found, err) + } + + if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{ + TempAuthKeyID: authID, + PermAuthKeyID: 12345, + Nonce: 67890, + TempSessionID: 24680, + ExpiresAt: 111, + EncryptedMessage: []byte("binding"), + }); err != nil { + t.Fatalf("save temp auth key binding: %v", err) + } + var bindingCount int + var tempSessionID int64 + if err := pool.QueryRow(ctx, "SELECT count(*), coalesce(max(temp_session_id), 0) FROM temp_auth_key_bindings WHERE temp_auth_key_id = $1", authKeyIDToInt64(authID)).Scan(&bindingCount, &tempSessionID); err != nil { + t.Fatalf("count temp auth key binding: %v", err) + } + if bindingCount != 1 || tempSessionID != 24680 { + t.Fatalf("temp auth key binding count/session = %d/%d, want 1/24680", bindingCount, tempSessionID) + } + + passwords := NewPasswordStore(pool) + wantPassword := domain.PasswordSettings{ + Hint: "dev", + SecureRandom: []byte("secure-random"), + } + if err := passwords.Save(ctx, owner.ID, wantPassword); err != nil { + t.Fatalf("save password settings: %v", err) + } + gotPassword, found, err := passwords.GetByUser(ctx, owner.ID) + if err != nil { + t.Fatalf("get password settings: %v", err) + } + if !found || gotPassword.Hint != wantPassword.Hint || string(gotPassword.SecureRandom) != string(wantPassword.SecureRandom) { + t.Fatalf("password settings = %+v found=%v, want %+v found=true", gotPassword, found, wantPassword) + } + + help := NewHelpStore(pool) + client := "tdesktop-test-" + suffix + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM app_configs WHERE client = $1", client) + }) + if err := help.UpsertAppConfig(ctx, domain.AppConfig{Client: client, Hash: 9, JSON: []byte(`{"test":true}`)}); err != nil { + t.Fatalf("upsert app config: %v", err) + } + cfg, found, err := help.GetAppConfig(ctx, client) + if err != nil { + t.Fatalf("get app config: %v", err) + } + if !found || cfg.Hash != 9 || string(cfg.JSON) != `{"test": true}` { + t.Fatalf("app config = %+v found=%v, want hash=9 json", cfg, found) + } + countryISO := "T" + suffix[:1] + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM countries WHERE iso2 = $1", countryISO) + }) + if err := help.UpsertCountries(ctx, []domain.Country{{ + ISO2: countryISO, + DefaultName: "Testland", + CountryCodes: []domain.CountryCode{ + {CountryCode: "999", Prefixes: []string{"999"}, Patterns: []string{"XXX"}}, + }, + }}); err != nil { + t.Fatalf("upsert countries: %v", err) + } + countryList, err := help.ListCountries(ctx, "en") + if err != nil { + t.Fatalf("list countries: %v", err) + } + var foundCountry bool + for _, country := range countryList.Countries { + if country.ISO2 == countryISO && len(country.CountryCodes) == 1 && country.CountryCodes[0].CountryCode == "999" { + foundCountry = true + } + } + if !foundCountry { + t.Fatalf("country %s not found in %+v", countryISO, countryList) + } + + langCode := "test-" + suffix + lang := NewLangPackStore(pool) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM lang_packs WHERE lang_pack = $1 AND lang_code = $2", "tdesktop", langCode) + }) + if err := lang.UpsertPack(ctx, domain.LangPack{ + LangPack: "tdesktop", + LangCode: langCode, + Version: 7, + Strings: []domain.LangPackString{ + {Key: "lng_test", Value: "Test"}, + {Key: "lng_items", Pluralized: true, OneValue: "{count} item", OtherValue: "{count} items"}, + }, + }); err != nil { + t.Fatalf("upsert lang pack: %v", err) + } + pack, err := lang.GetPack(ctx, "tdesktop", langCode, 0) + if err != nil { + t.Fatalf("get lang pack: %v", err) + } + if pack.Version != 7 || len(pack.Strings) != 2 { + t.Fatalf("lang pack = %+v, want version 7 with 2 strings", pack) + } + notModified, err := lang.GetPack(ctx, "tdesktop", langCode, 7) + if err != nil { + t.Fatalf("get lang pack not modified: %v", err) + } + if notModified.Version != 7 || len(notModified.Strings) != 0 { + t.Fatalf("not modified pack = %+v, want version 7 with no strings", notModified) + } + selected, err := lang.GetStrings(ctx, "tdesktop", langCode, []string{"lng_test"}) + if err != nil { + t.Fatalf("get lang pack strings: %v", err) + } + if len(selected.Strings) != 1 || selected.Strings[0].Value != "Test" { + t.Fatalf("selected strings = %+v, want lng_test=Test", selected.Strings) + } +} + +func randomSuffix(t *testing.T) string { + t.Helper() + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatal(err) + } + return fmt.Sprintf("%s", hex.EncodeToString(b[:])) +} diff --git a/internal/store/postgres/channel.go b/internal/store/postgres/channel.go new file mode 100644 index 00000000..8eabd576 --- /dev/null +++ b/internal/store/postgres/channel.go @@ -0,0 +1,10149 @@ +package postgres + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +const channelDialogQueryLimit = 500 +const channelMemberFilterBatch = 1000 +const retryableChannelTxAttempts = 3 + +// ChannelStore 用 PostgreSQL 实现 store.ChannelStore。 +type ChannelStore struct { + db sqlcgen.DBTX + ids store.ChannelIDAllocator + pts store.ChannelPtsAllocator + msgIDs store.ChannelMessageIDAllocator +} + +// ChannelStoreOption 调整 PostgreSQL ChannelStore 依赖。 +type ChannelStoreOption func(*ChannelStore) + +// WithChannelAllocators 注入 Redis-backed channel id / pts / message id allocator。 +func WithChannelAllocators(ids store.ChannelIDAllocator, pts store.ChannelPtsAllocator, msgIDs store.ChannelMessageIDAllocator) ChannelStoreOption { + return func(s *ChannelStore) { + s.ids = ids + s.pts = pts + s.msgIDs = msgIDs + } +} + +// NewChannelStore 基于 pgx 连接池(或事务)创建 ChannelStore。 +func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore { + s := &ChannelStore{db: db} + for _, opt := range opts { + opt(s) + } + if s.ids == nil { + s.ids = pgChannelIDAllocator{db: db} + } + if s.pts == nil { + s.pts = pgChannelPtsAllocator{db: db} + } + if s.msgIDs == nil { + s.msgIDs = pgChannelMessageIDAllocator{db: db} + } + return s +} + +func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { + if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if !req.Broadcast && !req.Megagroup { + req.Broadcast = true + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("create channel: db does not support transactions") + } + channelID, err := s.ids.NextChannelID(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("allocate channel id: %w", err) + } + accessHash, err := randomChannelAccessHash() + if err != nil { + return domain.CreateChannelResult{}, err + } + msgID, err := s.msgIDs.NextChannelMessageID(ctx, channelID) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("allocate channel message id: %w", err) + } + pts, err := s.pts.NextChannelPts(ctx, channelID) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("allocate channel pts: %w", err) + } + date := req.Date + if date == 0 { + date = nowUnix() + } + members := []domain.ChannelMember{creatorChannelMember(channelID, req.CreatorUserID, date)} + for _, userID := range uniqueChannelUserIDs(req.MemberUserIDs, req.CreatorUserID) { + members = append(members, domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: req.CreatorUserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + }) + } + channel := domain.Channel{ + ID: channelID, + AccessHash: accessHash, + CreatorUserID: req.CreatorUserID, + Title: strings.TrimSpace(req.Title), + About: req.About, + Broadcast: req.Broadcast, + Megagroup: req.Megagroup, + Forum: req.Forum, + ForumTabs: req.ForumTabs, + ParticipantsCount: len(members), + AdminsCount: 1, + TopMessageID: msgID, + Pts: pts, + TTLPeriod: req.TTLPeriod, + Date: date, + } + msg := domain.ChannelMessage{ + ChannelID: channelID, + ID: msgID, + SenderUserID: req.CreatorUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.CreatorUserID}, + Date: date, + Post: channel.Broadcast, + Action: &domain.ChannelMessageAction{Type: domain.ChannelActionCreate, Title: channel.Title}, + Pts: pts, + } + event := domain.ChannelUpdateEvent{ + ChannelID: channelID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: date, + Message: msg, + SenderUserID: req.CreatorUserID, + } + + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin create channel: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + if err := insertChannelTx(ctx, tx, channel); err != nil { + return domain.CreateChannelResult{}, err + } + for _, member := range members { + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + } + if err := insertChannelMessageTx(ctx, tx, msg); err != nil { + return domain.CreateChannelResult{}, err + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.CreateChannelResult{}, err + } + for _, member := range members { + readMax := 0 + if member.UserID == req.CreatorUserID { + readMax = msgID + } + if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, readMax, readMax); err != nil { + return domain.CreateChannelResult{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit create channel: %w", err) + } + committed = true + return domain.CreateChannelResult{ + Channel: channel, + Members: append([]domain.ChannelMember(nil), members...), + Message: msg, + Event: event, + Recipients: channelMemberIDs(members), + }, nil +} + +func (s *ChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + channel, member, preview, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + if preview { + return domain.ChannelView{ + Channel: channel, + Self: member, + Dialog: previewChannelDialog(viewerUserID, channel, member), + }, nil + } + dialog, err := s.getChannelDialog(ctx, s.db, viewerUserID, channel) + if err != nil { + return domain.ChannelView{}, err + } + return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog}, nil +} + +func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + var sendAsType sql.NullString + var sendAsID sql.NullInt64 + if req.SendAs != nil { + if req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + sendAsType = sql.NullString{String: string(req.SendAs.Type), Valid: true} + sendAsID = sql.NullInt64{Int64: req.SendAs.ID, Valid: true} + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelView{}, err + } + topMessageID := channel.TopMessageID + if topMessageID <= member.AvailableMinID { + topMessageID = 0 + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, + default_send_as_peer_type, default_send_as_peer_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (user_id, channel_id) DO UPDATE SET + default_send_as_peer_type = EXCLUDED.default_send_as_peer_type, + default_send_as_peer_id = EXCLUDED.default_send_as_peer_id, + updated_at = now()`, + req.UserID, + req.ChannelID, + topMessageID, + channel.Date, + member.ReadInboxMaxID, + member.ReadOutboxMaxID, + sendAsType, + sendAsID, + ); err != nil { + return domain.ChannelView{}, fmt.Errorf("save channel default send as: %w", err) + } + dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) + if err != nil { + return domain.ChannelView{}, err + } + return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog}, nil +} + +func (s *ChannelStore) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) { + if channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return getChannelByID(ctx, s.db, channelID) +} + +func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelParticipantList{}, err + } + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { + limit = domain.MaxChannelParticipantsLimit + } + count := channel.ParticipantsCount + if channel.ParticipantsHidden && !isChannelAdmin(viewer) { + switch filter.Kind { + case domain.ChannelParticipantsAdmins: + case domain.ChannelParticipantsBots: + return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil + default: + return domain.ChannelParticipantList{Channel: channel, Count: channel.ParticipantsCount}, nil + } + } + where := []string{"m.channel_id = $1"} + args := []any{channelID} + joinUsers := false + query := strings.TrimSpace(filter.Query) + switch filter.Kind { + case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions: + where = append(where, "m.status = 'active'") + case domain.ChannelParticipantsAdmins: + where = append(where, "m.status = 'active'", "m.role IN ('creator','admin')") + count = channel.AdminsCount + case domain.ChannelParticipantsKicked: + count = channel.KickedCount + if !isChannelAdmin(viewer) { + return domain.ChannelParticipantList{Channel: channel, Count: channel.KickedCount}, nil + } + where = append(where, "(m.status = 'kicked' OR (m.banned_rights->>'ViewMessages')::boolean IS TRUE)") + case domain.ChannelParticipantsBanned: + count = channel.BannedCount + if !isChannelAdmin(viewer) { + return domain.ChannelParticipantList{Channel: channel, Count: channel.BannedCount}, nil + } + where = append(where, "m.status <> 'kicked'", `( + m.status = 'banned' OR + (m.banned_rights->>'SendMessages')::boolean IS TRUE OR + (m.banned_rights->>'SendMedia')::boolean IS TRUE OR + (m.banned_rights->>'SendStickers')::boolean IS TRUE OR + (m.banned_rights->>'SendGifs')::boolean IS TRUE OR + (m.banned_rights->>'SendGames')::boolean IS TRUE OR + (m.banned_rights->>'SendInline')::boolean IS TRUE OR + (m.banned_rights->>'EmbedLinks')::boolean IS TRUE OR + (m.banned_rights->>'SendPolls')::boolean IS TRUE OR + (m.banned_rights->>'ChangeInfo')::boolean IS TRUE OR + (m.banned_rights->>'InviteUsers')::boolean IS TRUE OR + (m.banned_rights->>'PinMessages')::boolean IS TRUE +)`) + case domain.ChannelParticipantsSearch: + where = append(where, "m.status = 'active'") + count = 0 + case domain.ChannelParticipantsBots: + return domain.ChannelParticipantList{Channel: channel}, nil + default: + where = append(where, "m.status = 'active'") + } + if query != "" { + joinUsers = true + count = 0 + args = append(args, "%"+strings.ToLower(query)+"%") + placeholder := fmt.Sprintf("$%d", len(args)) + where = append(where, fmt.Sprintf(`( + lower(COALESCE(u.first_name, '')) LIKE %s OR + lower(COALESCE(u.last_name, '')) LIKE %s OR + lower(COALESCE(u.username, '')) LIKE %s OR + COALESCE(u.phone, '') LIKE %s OR + m.user_id::text LIKE %s +)`, placeholder, placeholder, placeholder, placeholder, placeholder)) + } + args = append(args, offset, limit) + offsetArg := fmt.Sprintf("$%d", len(args)-1) + limitArg := fmt.Sprintf("$%d", len(args)) + from := "FROM channel_members m" + if joinUsers { + from += " JOIN users u ON u.id = m.user_id" + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, + rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +`+from+` +WHERE `+strings.Join(where, " AND ")+` +ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id +OFFSET `+offsetArg+` LIMIT `+limitArg, args...) + if err != nil { + return domain.ChannelParticipantList{}, fmt.Errorf("list channel participants: %w", err) + } + defer rows.Close() + out := domain.ChannelParticipantList{Channel: channel, Count: count} + for rows.Next() { + member, err := scanChannelMember(rows) + if err != nil { + return domain.ChannelParticipantList{}, err + } + out.Participants = append(out.Participants, member) + } + if err := rows.Err(); err != nil { + return domain.ChannelParticipantList{}, err + } + if out.Count == 0 { + out.Count = len(out.Participants) + } + return out, nil +} + +func (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { + if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { + return domain.ChannelMember{}, err + } + return s.getChannelMember(ctx, s.db, channelID, participantUserID) +} + +func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { + if channelID == 0 || inviterUserID == 0 || len(userIDs) == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("invite channel: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin invite channel: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, date) + } + }() + channel, inviter, err := s.getChannelForMember(ctx, tx, inviterUserID, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + if !canInviteToChannel(channel, inviter) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + if date == 0 { + date = nowUnix() + } + requested := uniqueChannelUserIDs(userIDs, 0) + inviteOne := len(requested) == 1 + canRestoreKicked := canBanChannelUsers(inviter) + invitedIDs := make([]int64, 0, len(requested)) + members := make([]domain.ChannelMember, 0, len(requested)) + restoredKicked := 0 + for _, userID := range requested { + if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { + if existing.Status == domain.ChannelMemberActive { + if inviteOne { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + continue + } + if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { + if !canRestoreKicked { + if inviteOne { + return domain.CreateChannelResult{}, domain.ErrUserKicked + } + continue + } + if existing.Status == domain.ChannelMemberKicked { + restoredKicked++ + } + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.CreateChannelResult{}, err + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: inviterUserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: inviterUserID, + Date: date, + Type: domain.ChannelAdminLogParticipantInvite, + Participant: &member, + }); err != nil { + return domain.CreateChannelResult{}, err + } + members = append(members, member) + invitedIDs = append(invitedIDs, userID) + } + if len(members) > 0 { + if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + $2, kicked_count = GREATEST(kicked_count - $3, 0), updated_at = now() WHERE id = $1`, channelID, len(members), restoredKicked); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) + } + channel.ParticipantsCount += len(members) + channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0) + } + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if len(members) > 0 && channel.Megagroup { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, inviterUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatAddUser, + UserIDs: invitedIDs, + }, &reserved) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + for _, member := range members { + if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0) + return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { + if channelID == 0 || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("join channel: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin join channel: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, date) + } + }() + channel, err := getChannelByID(ctx, tx, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { + switch { + case existing.Status == domain.ChannelMemberActive: + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + case existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages: + return domain.CreateChannelResult{}, domain.ErrChannelUserBanned + } + } + if date == 0 { + date = nowUnix() + } + if channel.JoinRequest { + if err := s.recordPublicJoinRequestTx(ctx, tx, channel, userID, date); err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit public channel join request: %w", err) + } + committed = true + return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent + } + preJoinTopID := channel.TopMessageID + minID := channelInitialAvailableMinID(channel) + member := domain.ChannelMember{ChannelID: channelID, UserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: date, AvailableMinID: minID, AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: maxInt(minID, preJoinTopID)} + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantJoin, + }); err != nil { + return domain.CreateChannelResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + 1, updated_at = now() WHERE id = $1`, channelID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) + } + channel.ParticipantsCount++ + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatJoined, + UserIDs: []int64{userID}, + }, &reserved) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) + if msg.ID != 0 && msg.SenderUserID == userID { + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_max_id = GREATEST(read_inbox_max_id, $3), + read_outbox_max_id = GREATEST(read_outbox_max_id, $4), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update joined channel read watermarks: %w", err) + } + if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0) + return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { + if channelID == 0 || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("leave channel: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin leave channel: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, date) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + if date == 0 { + date = nowUnix() + } + if _, err := tx.Exec(ctx, `UPDATE channel_members SET status = 'left', left_at = $3, updated_at = now() WHERE channel_id = $1 AND user_id = $2`, channelID, userID, date); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("leave channel member: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = GREATEST(participants_count - 1, 0), updated_at = now() WHERE id = $1`, channelID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) + } + member.Status = domain.ChannelMemberLeft + member.LeftAt = date + if err := upsertUserChannelMemberIndexTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantLeave, + }); err != nil { + return domain.CreateChannelResult{}, err + } + channel.ParticipantsCount-- + if channel.ParticipantsCount < 0 { + channel.ParticipantsCount = 0 + } + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatDelete, + UserIDs: []int64{userID}, + }, &reserved) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit leave channel: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0) + recipients = append(recipients, userID) + return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelTitleResult{}, fmt.Errorf("edit channel title: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + title := strings.TrimSpace(req.Title) + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelTitleResult{}, fmt.Errorf("begin edit channel title: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelTitleResult{}, err + } + if !canChangeChannelInfo(member) { + return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired + } + if channel.Title == title { + return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified + } + prevTitle := channel.Title + if _, err := tx.Exec(ctx, `UPDATE channels SET title = $2, updated_at = now() WHERE id = $1`, req.ChannelID, title); err != nil { + return domain.EditChannelTitleResult{}, fmt.Errorf("update channel title: %w", err) + } + channel.Title = title + msg, event, err := s.insertServiceMessage(ctx, tx, channel, req.UserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionEditTitle, + Title: title, + }, &reserved) + if err != nil { + return domain.EditChannelTitleResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, msg.ID, msg.ID); err != nil { + return domain.EditChannelTitleResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogChangeTitle, + PrevString: prevTitle, + NewString: title, + }); err != nil { + return domain.EditChannelTitleResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelTitleResult{}, fmt.Errorf("commit edit channel title: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.EditChannelTitleResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("edit channel about: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin edit channel about: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET about = $2, updated_at = now() WHERE id = $1`, req.ChannelID, req.About); err != nil { + return domain.Channel{}, fmt.Errorf("update channel about: %w", err) + } + channel.About = req.About + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit edit channel about: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelAdminResult{}, fmt.Errorf("edit channel admin: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelAdminResult{}, fmt.Errorf("begin edit channel admin: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + if !canAddChannelAdmins(actor) { + return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired + } + if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) { + return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden + } + previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID) + if err != nil { + if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.EditChannelAdminResult{}, err + } + previous = domain.ChannelMember{ + ChannelID: req.ChannelID, + UserID: req.MemberID, + InviterUserID: req.UserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: req.Date, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + } + } + if previous.Role == domain.ChannelRoleCreator { + return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator + } + member := previous + member.InviterUserID = req.UserID + member.Status = domain.ChannelMemberActive + member.LeftAt = 0 + member.Rank = req.Rank + if previous.Status != domain.ChannelMemberActive { + if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { + member.AvailableMinPts = minPts + } + } + member.AdminRights = req.AdminRights + if zeroChannelAdminRights(req.AdminRights) { + member.Role = domain.ChannelRoleMember + member.Rank = "" + } else { + member.Role = domain.ChannelRoleAdmin + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.EditChannelAdminResult{}, err + } + logType := domain.ChannelAdminLogParticipantPromote + if member.Role != domain.ChannelRoleAdmin { + logType = domain.ChannelAdminLogParticipantDemote + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: logType, + PrevParticipant: &previous, + NewParticipant: &member, + }); err != nil { + return domain.EditChannelAdminResult{}, err + } + channel, err = refreshChannelCountsTx(ctx, tx, channel) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + event, channel, err := s.insertParticipantEventTx(ctx, tx, channel, req.UserID, previous, member, req.Date, &reserved) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) + if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.EditChannelAdminResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelAdminResult{}, fmt.Errorf("commit edit channel admin: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + recipients = append(recipients, req.MemberID) + return domain.EditChannelAdminResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil +} + +func (s *ChannelStore) EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { + return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelBannedResult{}, fmt.Errorf("edit channel banned: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelBannedResult{}, fmt.Errorf("begin edit channel banned: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + if !canBanChannelUsers(actor) { + return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired + } + previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.Participant.ID) + if err != nil { + if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.EditChannelBannedResult{}, err + } + previous = domain.ChannelMember{ + ChannelID: req.ChannelID, + UserID: req.Participant.ID, + InviterUserID: req.UserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberLeft, + } + } + if previous.Role == domain.ChannelRoleCreator { + return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator + } + member := previous + member.BannedRights = req.BannedRights + member.Role = domain.ChannelRoleMember + switch { + case req.BannedRights.ViewMessages: + member.InviterUserID = req.UserID + member.Status = domain.ChannelMemberKicked + member.LeftAt = req.Date + case zeroChannelBannedRights(req.BannedRights): + if previous.Status == domain.ChannelMemberActive { + member.Status = domain.ChannelMemberActive + } else { + member.Status = domain.ChannelMemberLeft + } + member.LeftAt = 0 + default: + member.InviterUserID = req.UserID + if previous.Status == domain.ChannelMemberActive { + member.Status = domain.ChannelMemberActive + } else { + member.Status = domain.ChannelMemberBanned + } + } + if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive { + member.JoinedAt = req.Date + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.EditChannelBannedResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: adminLogBanType(previous, member), + PrevParticipant: &previous, + NewParticipant: &member, + }); err != nil { + return domain.EditChannelBannedResult{}, err + } + channel, err = refreshChannelCountsTx(ctx, tx, channel) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + event, channel, err := s.insertParticipantEventTx(ctx, tx, channel, req.UserID, previous, member, req.Date, &reserved) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + if member.Status == domain.ChannelMemberActive { + msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) + if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.EditChannelBannedResult{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelBannedResult{}, fmt.Errorf("commit edit channel banned: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + recipients = append(recipients, req.Participant.ID) + return domain.EditChannelBannedResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil +} + +func (s *ChannelStore) EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + channel, actor, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + if !canBanChannelUsers(actor) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if channel.DefaultBannedRights == req.BannedRights { + return domain.Channel{}, domain.ErrChannelNotModified + } + rights, err := marshalJSON(req.BannedRights, "{}") + if err != nil { + return domain.Channel{}, err + } + if _, err := s.db.Exec(ctx, ` +UPDATE channels +SET default_banned_rights = $2::jsonb, updated_at = now() +WHERE id = $1 AND NOT deleted`, req.ChannelID, rights); err != nil { + return domain.Channel{}, fmt.Errorf("edit channel default banned rights: %w", err) + } + channel.DefaultBannedRights = req.BannedRights + return channel, nil +} + +func (s *ChannelStore) DeleteChannel(ctx context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.DeleteChannelResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelResult{}, fmt.Errorf("delete channel: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelResult{}, fmt.Errorf("begin delete channel: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelResult{}, err + } + if member.Role != domain.ChannelRoleCreator { + return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired + } + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + if _, err := tx.Exec(ctx, `UPDATE channels SET deleted = true, updated_at = now() WHERE id = $1`, req.ChannelID); err != nil { + return domain.DeleteChannelResult{}, fmt.Errorf("mark channel deleted: %w", err) + } + if err := markUserChannelMemberIndexDeletedTx(ctx, tx, req.ChannelID, true); err != nil { + return domain.DeleteChannelResult{}, err + } + channel.Deleted = true + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelResult{}, fmt.Errorf("commit delete channel: %w", err) + } + committed = true + return domain.DeleteChannelResult{Channel: channel, Recipients: recipients}, nil +} + +func (s *ChannelStore) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) { + if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" { + return false, domain.ErrChannelInvalid + } + if _, _, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil { + return false, err + } + usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + var existingChannelID int64 + err := s.db.QueryRow(ctx, `SELECT channel_id FROM channel_usernames WHERE username_lower = $1`, usernameLower).Scan(&existingChannelID) + if err == nil { + return existingChannelID == channelID, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return false, fmt.Errorf("check channel username: %w", err) + } + var userIDWithUsername int64 + err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE lower(username) = $1 AND username <> '' LIMIT 1`, usernameLower).Scan(&userIDWithUsername) + if err == nil { + return false, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return false, fmt.Errorf("check channel username user collision: %w", err) + } + return true, nil +} + +func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("update channel username: db does not support transactions") + } + username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) + usernameLower := strings.ToLower(username) + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin update channel username: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if strings.EqualFold(channel.Username, username) { + return domain.Channel{}, domain.ErrChannelNotModified + } + if usernameLower != "" { + var userIDWithUsername int64 + err := tx.QueryRow(ctx, `SELECT id FROM users WHERE lower(username) = $1 AND username <> '' LIMIT 1`, usernameLower).Scan(&userIDWithUsername) + if err == nil { + return domain.Channel{}, domain.ErrUsernameOccupied + } + if !errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, fmt.Errorf("check user username collision: %w", err) + } + var existingChannelID int64 + err = tx.QueryRow(ctx, `SELECT channel_id FROM channel_usernames WHERE username_lower = $1 FOR UPDATE`, usernameLower).Scan(&existingChannelID) + if err == nil && existingChannelID != req.ChannelID { + return domain.Channel{}, domain.ErrUsernameOccupied + } + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, fmt.Errorf("lock channel username: %w", err) + } + } + if _, err := tx.Exec(ctx, `DELETE FROM channel_usernames WHERE channel_id = $1`, req.ChannelID); err != nil { + return domain.Channel{}, fmt.Errorf("delete old channel username: %w", err) + } + if usernameLower != "" { + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_usernames (username_lower, channel_id) +VALUES ($1,$2) +ON CONFLICT (username_lower) DO UPDATE SET channel_id = EXCLUDED.channel_id, updated_at = now() +WHERE channel_usernames.channel_id = EXCLUDED.channel_id`, usernameLower, req.ChannelID); err != nil { + if isUniqueViolation(err) { + return domain.Channel{}, domain.ErrUsernameOccupied + } + return domain.Channel{}, fmt.Errorf("insert channel username: %w", err) + } + } + if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil { + return domain.Channel{}, fmt.Errorf("update channel username: %w", err) + } + prevUsername := channel.Username + channel.Username = username + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: nowUnix(), + Type: domain.ChannelAdminLogChangeUsername, + PrevString: prevUsername, + NewString: username, + }); err != nil { + return domain.Channel{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit update channel username: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+` +FROM channel_members m +JOIN channels c ON c.id = m.channel_id AND NOT c.deleted +WHERE m.user_id = $1 + AND m.status = 'active' + AND m.role IN ('creator','admin') + AND COALESCE(c.username, '') <> '' +ORDER BY c.id DESC +LIMIT $2`, userID, domain.MaxAdminedPublicChannels) + if err != nil { + return nil, fmt.Errorf("list admined public channels: %w", err) + } + defer rows.Close() + out := make([]domain.Channel, 0) + for rows.Next() { + ch, err := scanChannel(rows) + if err != nil { + return nil, err + } + out = append(out, ch) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { + if viewerUserID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if usernameLower == "" { + return domain.Channel{}, false, nil + } + ch, err := scanChannel(s.db.QueryRow(ctx, ` +SELECT `+channelColumns+` +FROM channel_usernames u +JOIN channels c ON c.id = u.channel_id +WHERE u.username_lower = $1 + AND NOT c.deleted + AND (c.broadcast OR c.megagroup) + AND COALESCE(c.username, '') <> '' +LIMIT 1`, usernameLower)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, false, nil + } + return domain.Channel{}, false, fmt.Errorf("resolve public channel username: %w", err) + } + return ch, true, nil +} + +func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { + if viewerUserID == 0 { + return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit { + limit = domain.MaxPublicChannelSearchLimit + } + queryLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@"))) + if queryLower == "" { + return domain.PublicChannelSearchResult{}, nil + } + queryPrefix := escapeLike(queryLower) + "%" + queryLike := "%" + escapeLike(queryLower) + "%" + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + EXISTS ( + SELECT 1 + FROM channel_members m + WHERE m.channel_id = c.id + AND m.user_id = $1 + AND m.status = 'active' + ) AS viewer_member +FROM channels c +WHERE NOT c.deleted + AND (c.broadcast OR c.megagroup) + AND COALESCE(c.username, '') <> '' + AND ( + lower(c.username) = $2 + OR lower(c.username) LIKE $3 ESCAPE '\' + OR lower(c.title) LIKE $3 ESCAPE '\' + OR lower(c.username) LIKE $4 ESCAPE '\' + OR lower(c.title) LIKE $4 ESCAPE '\' + ) +ORDER BY CASE + WHEN lower(c.username) = $2 THEN 0 + WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1 + WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2 + WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3 + ELSE 4 + END, + viewer_member DESC, + c.participants_count DESC, + c.date DESC, + c.id DESC +LIMIT $5`, viewerUserID, queryLower, queryPrefix, queryLike, limit) + if err != nil { + return domain.PublicChannelSearchResult{}, fmt.Errorf("search public channels: %w", err) + } + defer rows.Close() + out := domain.PublicChannelSearchResult{ + MyResults: make([]domain.Channel, 0), + Results: make([]domain.Channel, 0, limit), + } + for rows.Next() { + ch, viewerMember, err := scanChannelWithViewerMember(rows) + if err != nil { + return domain.PublicChannelSearchResult{}, err + } + if viewerMember { + out.MyResults = append(out.MyResults, ch) + } else { + out.Results = append(out.Results, ch) + } + } + if err := rows.Err(); err != nil { + return domain.PublicChannelSearchResult{}, err + } + return out, nil +} + +func (s *ChannelStore) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel signatures: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel signatures: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.Signatures + if _, err := tx.Exec(ctx, `UPDATE channels SET signatures = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel signatures: %w", err) + } + channel.Signatures = enabled + if prev != enabled { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleSignatures, + PrevBool: prev, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel signatures: %w", err) + } + committed = true + return channel, nil +} + +// SetChannelPhoto 设置/清除频道头像(反范式列)。photo==nil 表示清除。 +func (s *ChannelStore) SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("set channel photo: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin set channel photo: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + var ( + photoID int64 + dcID int + stripped []byte + ) + if photo != nil && photo.ID != 0 { + photoID = photo.ID + dcID = photo.DCID + stripped = domain.StrippedFromSizes(photo.Sizes) + } + if stripped == nil { + stripped = []byte{} + } + if _, err := tx.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`, + channelID, photoID, dcID, stripped); err != nil { + return domain.Channel{}, fmt.Errorf("update channel photo: %w", err) + } + channel.PhotoID = photoID + channel.PhotoDCID = dcID + channel.PhotoStripped = stripped + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit set channel photo: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel prehistory: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel prehistory: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.PreHistoryHidden + if _, err := tx.Exec(ctx, `UPDATE channels SET pre_history_hidden = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel prehistory: %w", err) + } + channel.PreHistoryHidden = enabled + if prev != enabled { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogTogglePreHistoryHidden, + PrevBool: prev, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel prehistory: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel participants hidden: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel participants hidden: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || !canBanChannelUsers(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET participants_hidden = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel participants hidden: %w", err) + } + channel.ParticipantsHidden = enabled + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel participants hidden: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel forum: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel forum: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || channel.Broadcast { + return domain.Channel{}, domain.ErrChannelNotModified + } + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if enabled && channel.LinkedChatID != 0 { + return domain.Channel{}, domain.ErrChatDiscussionUnallowed + } + prevForum := channel.Forum + prevTabs := channel.ForumTabs + nextTabs := enabled && tabs + if _, err := tx.Exec(ctx, ` +UPDATE channels +SET forum = $2, + forum_tabs = $3, + updated_at = now() +WHERE id = $1`, channelID, enabled, nextTabs); err != nil { + return domain.Channel{}, fmt.Errorf("update channel forum: %w", err) + } + channel.Forum = enabled + channel.ForumTabs = nextTabs + if prevForum != channel.Forum || prevTabs != channel.ForumTabs { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleForum, + PrevBool: prevForum, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel forum: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel autotranslation: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel autotranslation: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.Autotranslation + if _, err := tx.Exec(ctx, `UPDATE channels SET autotranslation = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel autotranslation: %w", err) + } + channel.Autotranslation = enabled + if prev != enabled { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleAutotranslation, + PrevBool: prev, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel autotranslation: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel restricted sponsored: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel restricted sponsored: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET restricted_sponsored = $2, updated_at = now() WHERE id = $1`, channelID, restricted); err != nil { + return domain.Channel{}, fmt.Errorf("update channel restricted sponsored: %w", err) + } + channel.RestrictedSponsored = restricted + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel restricted sponsored: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 || stars < 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("update channel paid messages price: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin update channel paid messages price: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + broadcastAllowed := channel.Broadcast && broadcastMessagesAllowed + if _, err := tx.Exec(ctx, `UPDATE channels SET send_paid_messages_stars = $2, broadcast_messages_allowed = $3, updated_at = now() WHERE id = $1`, channelID, stars, broadcastAllowed); err != nil { + return domain.Channel{}, fmt.Errorf("update channel paid messages price: %w", err) + } + channel.SendPaidMessagesStars = stars + channel.BroadcastMessagesAllowed = broadcastAllowed + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit update channel paid messages price: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel antispam: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel antispam: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.AntiSpam + if _, err := tx.Exec(ctx, `UPDATE channels SET antispam = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel antispam: %w", err) + } + channel.AntiSpam = enabled + if prev != enabled { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleAntiSpam, + PrevBool: prev, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel antispam: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { + if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel slowmode: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel slowmode: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.SlowmodeSeconds + if _, err := tx.Exec(ctx, `UPDATE channels SET slowmode_seconds = $2, updated_at = now() WHERE id = $1`, channelID, seconds); err != nil { + return domain.Channel{}, fmt.Errorf("update channel slowmode: %w", err) + } + channel.SlowmodeSeconds = seconds + if prev != seconds { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleSlowMode, + PrevInt: prev, + NewInt: seconds, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel slowmode: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel noforwards: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel noforwards: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET noforwards = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel noforwards: %w", err) + } + channel.NoForwards = enabled + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel noforwards: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel join_to_send: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel join_to_send: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || !canExportChannelInvite(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET join_to_send = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel join_to_send: %w", err) + } + channel.JoinToSend = enabled + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel join_to_send: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel join_request: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel join_request: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || !canExportChannelInvite(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if enabled && strings.TrimSpace(channel.Username) == "" { + return domain.Channel{}, domain.ErrChatPublicRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET join_request = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel join_request: %w", err) + } + channel.JoinRequest = enabled + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel join_request: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("set channel available reactions: db does not support transactions") + } + policyJSON, err := marshalJSON(policy, "{}") + if err != nil { + return domain.Channel{}, err + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin set channel available reactions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET available_reactions = $2, updated_at = now() WHERE id = $1`, channelID, policyJSON); err != nil { + return domain.Channel{}, fmt.Errorf("update channel available reactions: %w", err) + } + channel.ReactionPolicy = policy + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit set channel available reactions: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("set channel color: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin set channel color: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if forProfile { + if _, err := tx.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`, + channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil { + return domain.Channel{}, fmt.Errorf("update channel profile color: %w", err) + } + channel.ProfileColor = color + } else { + if _, err := tx.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`, + channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil { + return domain.Channel{}, fmt.Errorf("update channel color: %w", err) + } + channel.Color = color + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit set channel color: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if status.DocumentID == 0 { + status.Until = 0 + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("set channel emoji status: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin set channel emoji status: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`, + channelID, status.DocumentID, status.Until); err != nil { + return domain.Channel{}, fmt.Errorf("update channel emoji status: %w", err) + } + channel.EmojiStatus = status + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit set channel emoji status: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelAdminLogResult{}, err + } + if !isChannelAdmin(member) { + return domain.ChannelAdminLogResult{}, domain.ErrChannelAdminRequired + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelAdminLogLimit { + limit = domain.MaxChannelAdminLogLimit + } + where := []string{"channel_id = $1"} + args := []any{req.ChannelID} + nextArg := func(v any) string { + args = append(args, v) + return fmt.Sprintf("$%d", len(args)) + } + if req.MaxID > 0 { + where = append(where, "id < "+nextArg(req.MaxID)) + } + if req.MinID > 0 { + where = append(where, "id > "+nextArg(req.MinID)) + } + if len(req.AdminUserIDs) > 0 { + where = append(where, "actor_user_id = ANY("+nextArg(int64s(req.AdminUserIDs))+"::bigint[])") + } + if types := adminLogEventTypesForFilter(req.Filter); len(types) > 0 { + where = append(where, "event_type = ANY("+nextArg(types)+"::text[])") + } else if !req.Filter.Empty() { + return domain.ChannelAdminLogResult{Channel: channel}, nil + } + query := strings.ToLower(strings.TrimSpace(req.Query)) + if query != "" { + like := adminLogLikePattern(query) + where = append(where, `(lower(prev_string) LIKE `+nextArg(like)+` ESCAPE '\' OR lower(new_string) LIKE `+nextArg(like)+` ESCAPE '\' OR lower(query) LIKE `+nextArg(like)+` ESCAPE '\')`) + } + args = append(args, limit) + rows, err := s.db.Query(ctx, ` +SELECT channel_id, id, actor_user_id, event_date, event_type, prev_string, new_string, prev_bool, new_bool, prev_int, new_int, + prev_participant::text, new_participant::text, participant::text, message::text, prev_message::text, new_message::text, query +FROM channel_admin_log_events +WHERE `+strings.Join(where, " AND ")+` +ORDER BY id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelAdminLogResult{}, fmt.Errorf("list channel admin log: %w", err) + } + defer rows.Close() + events := make([]domain.ChannelAdminLogEvent, 0, limit) + for rows.Next() { + event, err := scanChannelAdminLogEvent(rows) + if err != nil { + return domain.ChannelAdminLogResult{}, err + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return domain.ChannelAdminLogResult{}, err + } + return domain.ChannelAdminLogResult{Channel: channel, Events: events}, nil +} + +func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero()) { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + var lastErr error + for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { + res, err := s.sendChannelMessageOnce(ctx, req) + if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { + return res, err + } + lastErr = err + } + return domain.SendChannelMessageResult{}, lastErr +} + +func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + if req.RandomID != 0 { + if dup, found, err := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID); err != nil { + return domain.SendChannelMessageResult{}, err + } else if found { + return dup, nil + } + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.SendChannelMessageResult{}, fmt.Errorf("send channel message: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("begin send channel: %w", err) + } + var reserved []reservedChannelPts + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + if len(reserved) > 0 { + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + if !canSendChannelMessage(channel, member) { + return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden + } + replyTo, err := s.resolveChannelReply(ctx, tx, req, member, channel) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + if _, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, replyTo, req.Forward); err != nil { + return domain.SendChannelMessageResult{}, err + } + if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { + return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) + } + var sendAs *domain.Peer + if req.SendAs != nil { + p := *req.SendAs + sendAs = &p + } + msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err) + } + pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel pts: %w", err) + } + reserved = []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} + var discussion *domain.SendChannelDiscussionResult + var discussionRef *domain.ChannelDiscussionRef + if channel.Broadcast && channel.LinkedChatID != 0 { + linked, err := getChannelByID(ctx, tx, channel.LinkedChatID) + if err == nil && !linked.Deleted && linked.Megagroup { + discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate discussion message id: %w", err) + } + discussionPts, err := s.pts.NextChannelPts(ctx, linked.ID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate discussion pts: %w", err) + } + reserved = append(reserved, reservedChannelPts{channelID: linked.ID, pts: discussionPts, count: 1}) + discussionRef = &domain.ChannelDiscussionRef{ChannelID: linked.ID, MessageID: discussionMsgID} + discussionMsg := domain.ChannelMessage{ + ChannelID: linked.ID, + ID: discussionMsgID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, + Date: req.Date, + Silent: req.Silent, + NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, + Pts: discussionPts, + } + discussionEvent := domain.ChannelUpdateEvent{ + ChannelID: linked.ID, + Type: domain.ChannelUpdateNewMessage, + Pts: discussionPts, + PtsCount: 1, + Date: req.Date, + Message: discussionMsg, + } + if err := insertChannelMessageTx(ctx, tx, discussionMsg); err != nil { + return domain.SendChannelMessageResult{}, err + } + if err := insertChannelEventTx(ctx, tx, discussionEvent); err != nil { + return domain.SendChannelMessageResult{}, err + } + if err := insertChannelUnreadMentionsTx(ctx, tx, linked.ID, discussionMsg, req.UserID, req.MentionUserIDs); err != nil { + return domain.SendChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, linked.ID, discussionMsgID, discussionPts); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("update discussion channel top: %w", err) + } + linked.TopMessageID = discussionMsgID + linked.Pts = discussionPts + if err := upsertChannelDialogsForMessageTx(ctx, tx, linked, discussionMsg, 0); err != nil { + return domain.SendChannelMessageResult{}, err + } + discussion = &domain.SendChannelDiscussionResult{ + Channel: linked, + Message: discussionMsg, + Event: discussionEvent, + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.SendChannelMessageResult{}, err + } + } + msg := domain.ChannelMessage{ + ChannelID: req.ChannelID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, + Date: req.Date, + Post: channel.Broadcast, + Silent: req.Silent, + NoForwards: req.NoForwards || channel.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ReplyTo: replyTo, + Forward: cloneMessageForward(req.Forward), + SendAs: sendAs, + Discussion: discussionRef, + Action: cloneChannelMessageAction(req.Action), + Pts: pts, + } + if discussionRef != nil { + msg.Replies = &domain.ChannelMessageReplies{Comments: true, ChannelID: discussionRef.ChannelID, RepliesPts: discussion.Event.Pts} + } + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Message: msg, + SenderUserID: req.UserID, + } + if err := insertChannelMessageTx(ctx, tx, msg); err != nil { + if isUniqueViolation(err) { + dup, found, dupErr := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID) + if dupErr != nil || !found { + return domain.SendChannelMessageResult{}, dupErr + } + dup.Duplicate = true + return dup, nil + } + return domain.SendChannelMessageResult{}, err + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.SendChannelMessageResult{}, err + } + if err := insertChannelUnreadMentionsTx(ctx, tx, req.ChannelID, msg, req.UserID, req.MentionUserIDs); err != nil { + return domain.SendChannelMessageResult{}, err + } + if err := updateForumTopicTopMessageTx(ctx, tx, req.ChannelID, msg); err != nil { + return domain.SendChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.ChannelID, msgID, pts); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("update channel top: %w", err) + } + channel.TopMessageID = msgID + channel.Pts = pts + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET slowmode_last_send_date = $3, + read_inbox_max_id = GREATEST(read_inbox_max_id, $4), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, req.Date, msgID); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("update channel member slowmode send date: %w", err) + } + if err := upsertChannelDialogsForMessageTx(ctx, tx, channel, msg, req.UserID); err != nil { + return domain.SendChannelMessageResult{}, err + } + if channel.Broadcast { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogSendMessage, + Message: &msg, + Query: msg.Body, + }); err != nil { + return domain.SendChannelMessageResult{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + if discussion != nil { + discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0) + } + return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion}, nil +} + +func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || strings.TrimSpace(req.Message) == "" { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelMessageResult{}, fmt.Errorf("edit channel message: db does not support transactions") + } + pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("allocate channel edit pts: %w", err) + } + reserved := []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} + if req.EditDate == 0 { + req.EditDate = nowUnix() + } + entities, err := encodeMessageEntities(req.Entities) + if err != nil { + s.recordChannelPtsGaps(ctx, reserved, req.EditDate) + return domain.EditChannelMessageResult{}, err + } + tx, err := beginner.Begin(ctx) + if err != nil { + s.recordChannelPtsGaps(ctx, reserved, req.EditDate) + return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.EditDate) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelMessageResult{}, err + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.ID) + if err != nil { + return domain.EditChannelMessageResult{}, err + } + if msg.Deleted || msg.Action != nil { + return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid + } + if msg.SenderUserID != req.UserID && !canEditChannelMessage(member) { + return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired + } + if msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { + return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified + } + prevMsg := msg + if _, err := tx.Exec(ctx, ` +UPDATE channel_messages +SET body = $4, entities = $5, edit_date = $6, pts = $7, updated_at = now() +WHERE channel_id = $1 AND id = $2 AND NOT deleted AND sender_user_id = $3 OR ( + channel_id = $1 AND id = $2 AND NOT deleted AND $8 +)`, + req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, pts, canEditChannelMessage(member)); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err) + } + msg.Body = req.Message + msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) + msg.EditDate = req.EditDate + msg.Pts = pts + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateEditMessage, + Pts: pts, + PtsCount: 1, + Date: req.EditDate, + Message: msg, + SenderUserID: req.UserID, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.EditChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET pts = $2, updated_at = now() WHERE id = $1`, req.ChannelID, pts); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit pts: %w", err) + } + channel.Pts = pts + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.EditDate, + Type: domain.ChannelAdminLogEditMessage, + PrevMessage: &prevMsg, + NewMessage: &msg, + Query: msg.Body, + }); err != nil { + return domain.EditChannelMessageResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("commit edit channel message: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.EditChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxDeleteMessageIDs { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelMessagesResult{}, fmt.Errorf("delete channel messages: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin delete channel messages: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, req.IDs, req.UserID, req.Date, &reserved) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit delete channel messages: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients}, nil +} + +func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete channel history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete channel history: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > channel.TopMessageID { + maxID = channel.TopMessageID + } + if !req.ForEveryone { + appliedMinID := maxInt(member.AvailableMinID, maxID) + topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET available_min_id = GREATEST(available_min_id, $3), + read_inbox_max_id = GREATEST(read_inbox_max_id, $3), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err) + } + if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, req.UserID, req.ChannelID); err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark +) VALUES ($1,$2,$3,$4,$5,0,0,false) +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + unread_count = 0, + unread_mark = false, + updated_at = now()`, req.UserID, req.ChannelID, topID, topDate, appliedMinID); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("upsert channel local clear dialog: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err) + } + committed = true + return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + rows, err := tx.Query(ctx, ` +SELECT id +FROM channel_messages +WHERE channel_id = $1 AND id <= $2 AND NOT deleted +ORDER BY id DESC +LIMIT $3`, req.ChannelID, maxID, domain.MaxDeleteHistoryBatch) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list channel history delete ids: %w", err) + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + rows.Close() + deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete channel history: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + offset := 0 + if len(deleted) == domain.MaxDeleteHistoryBatch { + offset = 1 + } + return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil +} + +func (s *ChannelStore) DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete participant channel history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete participant channel history: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + rows, err := tx.Query(ctx, ` +SELECT id +FROM channel_messages +WHERE channel_id = $1 AND sender_user_id = $2 AND NOT deleted +ORDER BY id DESC +LIMIT $3`, req.ChannelID, req.ParticipantUserID, domain.MaxDeleteHistoryBatch) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list participant channel history delete ids: %w", err) + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + rows.Close() + deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete participant channel history: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + offset := 0 + if len(deleted) == domain.MaxDeleteHistoryBatch { + offset = 1 + } + return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil +} + +func (s *ChannelStore) UpdatePinnedMessage(ctx context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("pin channel message: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("allocate channel pin pts: %w", err) + } + reserved := []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} + tx, err := beginner.Begin(ctx) + if err != nil { + s.recordChannelPtsGaps(ctx, reserved, req.Date) + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("begin pin channel message: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) + if err != nil || msg.Deleted { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid + } + pinnedID := 0 + if req.Pinned { + pinnedID = req.MessageID + } + if channel.PinnedMessageID == pinnedID { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified + } + if _, err := tx.Exec(ctx, ` +UPDATE channels +SET pinned_message_id = $2, pts = $3, updated_at = now() +WHERE id = $1`, req.ChannelID, pinnedID, pts); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("update channel pinned message: %w", err) + } + channel.PinnedMessageID = pinnedID + channel.Pts = pts + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdatePinnedMessages, + Pts: pts, + PtsCount: 1, + Date: req.Date, + MessageIDs: []int{req.MessageID}, + SenderUserID: req.UserID, + Pinned: req.Pinned, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogUpdatePinned, + Message: &msg, + Query: msg.Body, + }); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("commit pin channel message: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.UpdateChannelPinnedMessageResult{Channel: channel, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ExportChannelInviteResult{}, fmt.Errorf("export channel invite: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ExportChannelInviteResult{}, fmt.Errorf("begin export channel invite: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + if !canExportChannelInvite(member) { + return domain.ExportChannelInviteResult{}, domain.ErrChannelAdminRequired + } + if req.LegacyRevokePermanent { + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET revoked = true, updated_at = now() +WHERE channel_id = $1 AND admin_user_id = $2 AND permanent AND NOT revoked`, req.ChannelID, req.UserID); err != nil { + return domain.ExportChannelInviteResult{}, fmt.Errorf("revoke permanent channel invite: %w", err) + } + } + inviteID, err := randomPositiveInt64() + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + hash, err := randomInviteHash() + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + invite := domain.ChannelInvite{ + ChannelID: req.ChannelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: req.UserID, + Title: req.Title, + Permanent: req.ExpireDate == 0 && req.UsageLimit == 0 && !req.RequestNeeded && req.Title == "", + RequestNeeded: req.RequestNeeded, + ExpireDate: req.ExpireDate, + UsageLimit: req.UsageLimit, + Date: req.Date, + } + if err := insertChannelInviteTx(ctx, tx, invite); err != nil { + return domain.ExportChannelInviteResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ExportChannelInviteResult{}, fmt.Errorf("commit export channel invite: %w", err) + } + committed = true + return domain.ExportChannelInviteResult{Channel: channel, Invite: invite}, nil +} + +func (s *ChannelStore) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { + if userID == 0 || strings.TrimSpace(hash) == "" { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty + } + if date == 0 { + date = nowUnix() + } + channel, invite, err := s.getInviteByHash(ctx, s.db, strings.TrimSpace(hash)) + if err != nil { + return domain.CheckChannelInviteResult{}, err + } + if invite.ExpireDate > 0 && invite.ExpireDate < date { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashExpired + } + member, err := s.getChannelMember(ctx, s.db, channel.ID, userID) + already := false + if err == nil { + if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid + } + already = member.Status == domain.ChannelMemberActive + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.CheckChannelInviteResult{}, err + } + return domain.CheckChannelInviteResult{Channel: channel, Invite: invite, Already: already, Self: member}, nil +} + +func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("import channel invite: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin import channel invite: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, invite, err := s.getInviteByHashForUpdate(ctx, tx, strings.TrimSpace(req.Hash)) + if err != nil { + return domain.CreateChannelResult{}, err + } + if invite.ExpireDate > 0 && invite.ExpireDate < req.Date { + return domain.CreateChannelResult{}, domain.ErrInviteHashExpired + } + if invite.RequestNeeded { + if err := s.recordPendingInviteImporterTx(ctx, tx, invite, req.UserID, req.Date); err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit pending channel invite request: %w", err) + } + committed = true + return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent + } + result, err := s.approveInviteImporterTx(ctx, tx, channel, invite, req.UserID, 0, req.Date, &reserved) + if err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit import channel invite: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0) + result.Recipients = recipients + return result, nil +} + +func (s *ChannelStore) approveInviteImporterTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, invite domain.ChannelInvite, userID, approvedBy int64, date int, reserved *[]reservedChannelPts) (domain.CreateChannelResult, error) { + if invite.InviteID != 0 && invite.UsageLimit > 0 && invite.UsageCount >= invite.UsageLimit { + return domain.CreateChannelResult{}, domain.ErrUsersTooMuch + } + channelID := channel.ID + if channelID == 0 { + channelID = invite.ChannelID + } + if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { + if existing.Status == domain.ChannelMemberActive { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.CreateChannelResult{}, err + } + preJoinTopID := channel.TopMessageID + minID := channelInitialAvailableMinID(channel) + inviterID := invite.AdminUserID + if inviterID == 0 { + inviterID = approvedBy + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: inviterID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AvailableMinID: minID, + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: maxInt(minID, preJoinTopID), + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantJoin, + }); err != nil { + return domain.CreateChannelResult{}, err + } + if invite.InviteID != 0 { + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET requested_count = GREATEST(requested_count - 1, 0), + updated_at = now() +WHERE channel_id = $1 + AND invite_id = ( + SELECT invite_id + FROM channel_invite_importers + WHERE channel_id = $1 AND user_id = $2 AND requested + )`, channelID, userID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("clear pending channel invite request: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET usage_count = usage_count + 1, + updated_at = now() +WHERE channel_id = $1 AND invite_id = $2`, channelID, invite.InviteID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("increment channel invite usage: %w", err) + } + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested, approved_by) +VALUES ($1, $2, $3, $4, false, $5) +ON CONFLICT (channel_id, user_id) DO UPDATE +SET invite_id = EXCLUDED.invite_id, + date = EXCLUDED.date, + requested = false, + approved_by = EXCLUDED.approved_by, + updated_at = now()`, channelID, invite.InviteID, userID, date, approvedBy); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("upsert channel invite importer: %w", err) + } + channel, err := refreshChannelCountsTx(ctx, tx, channel) + if err != nil { + return domain.CreateChannelResult{}, err + } + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatJoined, + UserIDs: []int64{userID}, + }, reserved) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) + if msg.ID != 0 { + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_max_id = $3, read_outbox_max_id = $4, updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, channel.ID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update imported member read state: %w", err) + } + if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, err + } + return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event}, nil +} + +func (s *ChannelStore) recordPendingInviteImporterTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite, userID int64, date int) error { + if existing, err := s.getChannelMember(ctx, tx, invite.ChannelID, userID); err == nil { + if existing.Status == domain.ChannelMemberActive { + return domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.ErrInviteHashInvalid + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return err + } + tag, err := tx.Exec(ctx, ` +INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested) +VALUES ($1, $2, $3, $4, true) +ON CONFLICT (channel_id, user_id) DO UPDATE +SET invite_id = EXCLUDED.invite_id, + date = EXCLUDED.date, + requested = true, + approved_by = 0, + updated_at = now() +WHERE NOT channel_invite_importers.requested`, + invite.ChannelID, invite.InviteID, userID, date) + if err != nil { + return fmt.Errorf("record pending channel invite importer: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrInviteRequestSent + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET requested_count = requested_count + 1, updated_at = now() +WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { + return fmt.Errorf("increment channel invite requested count: %w", err) + } + return nil +} + +func (s *ChannelStore) recordPublicJoinRequestTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, userID int64, date int) error { + if existing, err := s.getChannelMember(ctx, tx, channel.ID, userID); err == nil { + if existing.Status == domain.ChannelMemberActive { + return domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.ErrInviteHashInvalid + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return err + } + if existing, err := s.getPendingInviteImporterTx(ctx, tx, channel.ID, userID, true); err == nil && existing.Requested { + return domain.ErrInviteRequestSent + } else if err != nil && !errors.Is(err, domain.ErrHideRequesterMissing) { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested) +VALUES ($1, 0, $2, $3, true) +ON CONFLICT (channel_id, user_id) DO UPDATE +SET invite_id = 0, + date = EXCLUDED.date, + requested = true, + approved_by = 0, + updated_at = now()`, channel.ID, userID, date); err != nil { + return fmt.Errorf("insert public channel join request: %w", err) + } + return nil +} + +func (s *ChannelStore) ListExportedInvites(ctx context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ChannelInviteList{}, domain.ErrChannelInvalid + } + _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelInviteList{}, err + } + if !canExportChannelInvite(member) { + return domain.ChannelInviteList{}, domain.ErrChannelAdminRequired + } + var total int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_invites +WHERE channel_id = $1 AND admin_user_id = $2 AND revoked = $3`, req.ChannelID, req.AdminUserID, req.Revoked).Scan(&total); err != nil { + return domain.ChannelInviteList{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelInviteListLimit { + limit = domain.MaxChannelInviteListLimit + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, + EXTRACT(EPOCH FROM created_at)::int +FROM channel_invites +WHERE channel_id = $1 + AND admin_user_id = $2 + AND revoked = $3 + AND (($4::int = 0 AND $5::text = '') OR (EXTRACT(EPOCH FROM created_at)::int, hash) < ($4, $5)) +ORDER BY EXTRACT(EPOCH FROM created_at)::int DESC, hash DESC +LIMIT $6`, req.ChannelID, req.AdminUserID, req.Revoked, req.OffsetDate, req.OffsetHash, limit) + if err != nil { + return domain.ChannelInviteList{}, err + } + defer rows.Close() + invites := make([]domain.ChannelInvite, 0, limit) + for rows.Next() { + invite, err := scanChannelInvite(rows) + if err != nil { + return domain.ChannelInviteList{}, err + } + invites = append(invites, invite) + } + if err := rows.Err(); err != nil { + return domain.ChannelInviteList{}, err + } + return domain.ChannelInviteList{Count: total, Invites: invites}, nil +} + +func (s *ChannelStore) GetExportedInvite(ctx context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.ChannelInvite{}, domain.ErrInviteHashEmpty + } + _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelInvite{}, err + } + if !canExportChannelInvite(member) { + return domain.ChannelInvite{}, domain.ErrChannelAdminRequired + } + return s.getInviteByChannelHash(ctx, s.db, req.ChannelID, req.Hash, false) +} + +func (s *ChannelStore) EditExportedInvite(ctx context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelInviteResult{}, fmt.Errorf("edit channel invite: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("begin edit channel invite: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if _, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID); err != nil { + return domain.EditChannelInviteResult{}, err + } else if !canExportChannelInvite(member) { + return domain.EditChannelInviteResult{}, domain.ErrChannelAdminRequired + } + invite, err := s.getInviteByChannelHash(ctx, tx, req.ChannelID, req.Hash, true) + if err != nil { + return domain.EditChannelInviteResult{}, err + } + if req.Revoked { + if invite.Revoked { + return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing + } + if _, err := tx.Exec(ctx, `UPDATE channel_invites SET revoked = true, updated_at = now() WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("revoke channel invite: %w", err) + } + invite.Revoked = true + result := domain.EditChannelInviteResult{Invite: invite} + if invite.Permanent { + newInvite, err := s.newPostgresReplacementInvite(invite, req.Date) + if err != nil { + return domain.EditChannelInviteResult{}, err + } + if err := insertChannelInviteTx(ctx, tx, newInvite); err != nil { + return domain.EditChannelInviteResult{}, err + } + result.NewInvite = &newInvite + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("commit edit channel invite: %w", err) + } + committed = true + return result, nil + } + if invite.Revoked { + return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing + } + if invite.Permanent && ((req.HasExpireDate && req.ExpireDate > 0) || (req.HasUsageLimit && req.UsageLimit > 0) || (req.HasRequestNeeded && req.RequestNeeded)) { + return domain.EditChannelInviteResult{}, domain.ErrInvitePermanent + } + if req.HasExpireDate { + invite.ExpireDate = req.ExpireDate + } + if req.HasUsageLimit { + invite.UsageLimit = req.UsageLimit + } + if req.HasRequestNeeded { + invite.RequestNeeded = req.RequestNeeded + } + if req.HasTitle { + invite.Title = req.Title + } + invite.Permanent = invite.ExpireDate == 0 && invite.UsageLimit == 0 && !invite.RequestNeeded && invite.Title == "" + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET title = $3, + expire_date = NULLIF($4, 0), + usage_limit = NULLIF($5, 0), + request_needed = $6, + permanent = $7, + updated_at = now() +WHERE channel_id = $1 AND invite_id = $2`, + invite.ChannelID, invite.InviteID, invite.Title, invite.ExpireDate, invite.UsageLimit, invite.RequestNeeded, invite.Permanent); err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("update channel invite: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("commit edit channel invite: %w", err) + } + committed = true + return domain.EditChannelInviteResult{Invite: invite}, nil +} + +func (s *ChannelStore) DeleteExportedInvite(ctx context.Context, req domain.DeleteChannelInviteRequest) error { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.ErrInviteHashEmpty + } + if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { + return err + } else if !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + tag, err := s.db.Exec(ctx, ` +WITH deleted AS ( + DELETE FROM channel_invites + WHERE channel_id = $1 AND hash = $2 + RETURNING hash +) +DELETE FROM channel_invite_hashes h USING deleted d WHERE h.hash = d.hash`, req.ChannelID, strings.TrimSpace(req.Hash)) + if err != nil { + return fmt.Errorf("delete channel invite: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrInviteRevokedMissing + } + return nil +} + +func (s *ChannelStore) DeleteRevokedExportedInvites(ctx context.Context, req domain.DeleteRevokedChannelInvitesRequest) error { + if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ErrChannelInvalid + } + if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { + return err + } else if !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { + limit = domain.MaxChannelHideJoinRequests + } + if _, err := s.db.Exec(ctx, ` +WITH deleted AS ( + DELETE FROM channel_invites + WHERE ctid IN ( + SELECT ctid FROM channel_invites + WHERE channel_id = $1 AND admin_user_id = $2 AND revoked + ORDER BY updated_at ASC + LIMIT $3 + ) + RETURNING hash +) +DELETE FROM channel_invite_hashes h USING deleted d WHERE h.hash = d.hash`, req.ChannelID, req.AdminUserID, limit); err != nil { + return fmt.Errorf("delete revoked channel invites: %w", err) + } + return nil +} + +func (s *ChannelStore) ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { + if userID == 0 || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + if _, member, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil { + return nil, err + } else if !canExportChannelInvite(member) { + return nil, domain.ErrChannelAdminRequired + } + rows, err := s.db.Query(ctx, ` +SELECT admin_user_id, + COUNT(*) FILTER (WHERE NOT revoked)::int, + COUNT(*) FILTER (WHERE revoked)::int +FROM channel_invites +WHERE channel_id = $1 +GROUP BY admin_user_id +ORDER BY admin_user_id ASC`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]domain.ChannelAdminInviteCount, 0) + for rows.Next() { + var count domain.ChannelAdminInviteCount + if err := rows.Scan(&count.AdminUserID, &count.InvitesCount, &count.RevokedInvitesCount); err != nil { + return nil, err + } + out = append(out, count) + } + return out, rows.Err() +} + +func (s *ChannelStore) ListInviteImporters(ctx context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { + return domain.ChannelInviteImporterList{}, err + } else if !canExportChannelInvite(member) { + return domain.ChannelInviteImporterList{}, domain.ErrChannelAdminRequired + } + var inviteID int64 + if req.Hash != "" { + invite, err := s.getInviteByChannelHash(ctx, s.db, req.ChannelID, req.Hash, false) + if err != nil { + return domain.ChannelInviteImporterList{}, err + } + inviteID = invite.InviteID + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelInviteListLimit { + limit = domain.MaxChannelInviteListLimit + } + args := []any{req.ChannelID, req.Requested, inviteID, req.Query, req.OffsetDate, req.OffsetUserID, limit} + where := []string{ + "i.channel_id = $1", + "i.requested = $2", + "($3::bigint = 0 OR i.invite_id = $3)", + "($4::text = '' OR lower(trim(u.username || ' ' || u.first_name || ' ' || u.last_name)) LIKE '%' || lower($4) || '%')", + "(($5::int = 0 AND $6::bigint = 0) OR (i.date, i.user_id) < ($5, $6))", + } + whereSQL := strings.Join(where, " AND ") + var total int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_invite_importers i +JOIN users u ON u.id = i.user_id +WHERE `+whereSQL, args[:6]...).Scan(&total); err != nil { + return domain.ChannelInviteImporterList{}, err + } + rows, err := s.db.Query(ctx, ` +SELECT i.channel_id, i.invite_id, i.user_id, i.date, i.requested, i.approved_by, i.via_chatlist, i.about +FROM channel_invite_importers i +JOIN users u ON u.id = i.user_id +WHERE `+whereSQL+` +ORDER BY i.date DESC, i.user_id DESC +LIMIT $7`, args...) + if err != nil { + return domain.ChannelInviteImporterList{}, err + } + defer rows.Close() + importers := make([]domain.ChannelInviteImporter, 0, limit) + for rows.Next() { + var importer domain.ChannelInviteImporter + if err := rows.Scan(&importer.ChannelID, &importer.InviteID, &importer.UserID, &importer.Date, &importer.Requested, &importer.ApprovedBy, &importer.ViaChatlist, &importer.About); err != nil { + return domain.ChannelInviteImporterList{}, err + } + importers = append(importers, importer) + } + if err := rows.Err(); err != nil { + return domain.ChannelInviteImporterList{}, err + } + return domain.ChannelInviteImporterList{Count: total, Importers: importers}, nil +} + +func (s *ChannelStore) PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { + if channelID == 0 { + return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelPendingJoinRecentRequesters { + limit = domain.MaxChannelPendingJoinRecentRequesters + } + rows, err := s.db.Query(ctx, ` +SELECT user_id, COUNT(*) OVER()::int +FROM channel_invite_importers +WHERE channel_id = $1 AND requested +ORDER BY date DESC, user_id DESC +LIMIT $2`, channelID, limit) + if err != nil { + return domain.ChannelPendingJoinRequests{}, fmt.Errorf("list pending channel join requests: %w", err) + } + defer rows.Close() + out := domain.ChannelPendingJoinRequests{ + ChannelID: channelID, + RecentRequesters: make([]int64, 0, limit), + } + for rows.Next() { + var userID int64 + var count int + if err := rows.Scan(&userID, &count); err != nil { + return domain.ChannelPendingJoinRequests{}, err + } + out.Count = count + out.RecentRequesters = append(out.RecentRequesters, userID) + } + if err := rows.Err(); err != nil { + return domain.ChannelPendingJoinRequests{}, err + } + return out, nil +} + +func (s *ChannelStore) HideChatJoinRequest(ctx context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("hide channel join request: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin hide channel join request: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + if !canExportChannelInvite(member) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + importer, err := s.getPendingInviteImporterTx(ctx, tx, req.ChannelID, req.TargetUserID, true) + if err != nil { + return domain.CreateChannelResult{}, err + } + invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} + if importer.InviteID != 0 { + invite, err = s.getInviteByID(ctx, tx, req.ChannelID, importer.InviteID, true) + if err != nil { + return domain.CreateChannelResult{}, err + } + } + var result domain.CreateChannelResult + if req.Approved { + result, err = s.approveInviteImporterTx(ctx, tx, channel, invite, req.TargetUserID, req.UserID, req.Date, &reserved) + if err != nil { + return domain.CreateChannelResult{}, err + } + } else if err := deletePendingInviteImporterTx(ctx, tx, invite, req.TargetUserID); err != nil { + return domain.CreateChannelResult{}, err + } else { + result = domain.CreateChannelResult{Channel: channel} + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit hide channel join request: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + result.Recipients = recipients + return result, nil +} + +func (s *ChannelStore) HideAllChatJoinRequests(ctx context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + var inviteID int64 + if req.Hash != "" { + invite, err := s.GetExportedInvite(ctx, domain.GetChannelInviteRequest{UserID: req.UserID, ChannelID: req.ChannelID, Hash: req.Hash}) + if err != nil { + return domain.CreateChannelResult{}, err + } + inviteID = invite.InviteID + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { + limit = domain.MaxChannelHideJoinRequests + } + rows, err := s.db.Query(ctx, ` +SELECT user_id +FROM channel_invite_importers +WHERE channel_id = $1 AND requested AND ($2::bigint = 0 OR invite_id = $2) +ORDER BY date ASC, user_id ASC +LIMIT $3`, req.ChannelID, inviteID, limit) + if err != nil { + return domain.CreateChannelResult{}, err + } + targets := make([]int64, 0, limit) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + rows.Close() + return domain.CreateChannelResult{}, err + } + targets = append(targets, userID) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.CreateChannelResult{}, err + } + rows.Close() + var result domain.CreateChannelResult + for _, target := range targets { + next, err := s.HideChatJoinRequest(ctx, domain.HideChannelJoinRequestRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + TargetUserID: target, + Approved: req.Approved, + Date: req.Date, + }) + if err != nil { + return domain.CreateChannelResult{}, err + } + result = next + } + if result.Channel.ID == 0 { + ch, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + result.Channel = ch + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + result.Recipients = recipients + } + return result, nil +} + +func channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr string) string { + return fmt.Sprintf(`( + SELECT COUNT(*)::int + FROM channel_messages cm_unread + WHERE cm_unread.channel_id = c.id + AND cm_unread.id > GREATEST(%s, m.available_min_id) + AND cm_unread.id <= %s + AND NOT cm_unread.deleted + AND cm_unread.sender_user_id <> m.user_id + )`, readInboxExpr, topIDExpr) +} + +func channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr string) string { + return fmt.Sprintf(`EXISTS ( + SELECT 1 + FROM channel_messages cm_unread + WHERE cm_unread.channel_id = c.id + AND cm_unread.id > GREATEST(%s, m.available_min_id) + AND cm_unread.id <= %s + AND NOT cm_unread.deleted + AND cm_unread.sender_user_id <> m.user_id + )`, readInboxExpr, topIDExpr) +} + +func channelDialogVisibleUnreadCountSQL(readInboxExpr, topIDExpr string) string { + dynamicCount := channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr) + return fmt.Sprintf(`CASE + WHEN c.broadcast OR c.participants_count > %d THEN %s + ELSE COALESCE(d.unread_count, %s) + END`, domain.MaxSynchronousChannelDialogFanout, dynamicCount, dynamicCount) +} + +func channelDialogHasUnreadSQL(readInboxExpr, topIDExpr string) string { + dynamicUnread := channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr) + return fmt.Sprintf(`CASE + WHEN c.broadcast OR c.participants_count > %d THEN %s + ELSE COALESCE(d.unread_count > 0, %s) + END`, domain.MaxSynchronousChannelDialogFanout, dynamicUnread, dynamicUnread) +} + +func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { + if viewerUserID == 0 { + return domain.ChannelDialogList{}, nil + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" + visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END" + visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" + visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) + args := []any{viewerUserID} + where := []string{"m.user_id = $1", "m.status = 'active'"} + if filter.HasFolderID && filter.FolderID < domain.DialogCustomFolderMinID { + args = append(args, filter.FolderID) + where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args))) + } + if filter.PinnedOnly { + where = append(where, "COALESCE(d.pinned, false)") + } + if filter.ExcludePinned { + where = append(where, "NOT COALESCE(d.pinned, false)") + } + switch { + case filter.OffsetDate > 0: + args = append(args, filter.OffsetDate, filter.OffsetID) + dateArg := fmt.Sprintf("$%d", len(args)-1) + idArg := fmt.Sprintf("$%d", len(args)) + if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 { + args = append(args, filter.OffsetPeer.ID) + peerArg := fmt.Sprintf("$%d", len(args)) + where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s) OR (%s = %s AND %s = %s AND c.id < %s))", + visibleTopDate, dateArg, + visibleTopDate, dateArg, visibleTopID, idArg, + visibleTopDate, dateArg, visibleTopID, idArg, peerArg)) + } else { + where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s))", + visibleTopDate, dateArg, + visibleTopDate, dateArg, visibleTopID, idArg)) + } + case filter.OffsetID > 0: + args = append(args, filter.OffsetID) + idArg := fmt.Sprintf("$%d", len(args)) + if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 { + args = append(args, filter.OffsetPeer.ID) + peerArg := fmt.Sprintf("$%d", len(args)) + where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND c.id < %s))", + visibleTopID, idArg, visibleTopID, idArg, peerArg)) + } else { + where = append(where, fmt.Sprintf("%s < %s", visibleTopID, idArg)) + } + case filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0: + args = append(args, filter.OffsetPeer.ID) + where = append(where, fmt.Sprintf("c.id <> $%d", len(args))) + } + if filter.Folder != nil { + folder := filter.Folder + if folder.ExcludeArchived { + where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) <> %d", domain.DialogArchiveFolderID)) + } + if folder.ExcludeRead { + where = append(where, fmt.Sprintf(`(COALESCE(d.unread_mark, m.unread_mark) OR %s)`, + channelDialogHasUnreadSQL(visibleReadInbox, visibleTopID))) + } + if excludeIDs := channelFolderPeerIDs(folder.ExcludePeers); len(excludeIDs) > 0 { + args = append(args, excludeIDs) + where = append(where, fmt.Sprintf("NOT (c.id = ANY($%d::bigint[]))", len(args))) + } + includeIDs := channelFolderPeerIDs(folder.IncludePeers, folder.PinnedPeers) + include := make([]string, 0, 3) + if len(includeIDs) > 0 { + args = append(args, includeIDs) + include = append(include, fmt.Sprintf("c.id = ANY($%d::bigint[])", len(args))) + } + if folder.Groups { + include = append(include, "c.megagroup") + } + if folder.Broadcasts { + include = append(include, "c.broadcast") + } + if len(include) > 0 { + where = append(where, "("+strings.Join(include, " OR ")+")") + } + } + args = append(args, channelDialogQueryLimit) + limitArg := fmt.Sprintf("$%d", len(args)) + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + `+visibleTopID+`, + `+visibleTopDate+`, + COALESCE(d.folder_id, 0), `+visibleReadInbox+`, + COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), `+visibleUnreadCount+`, + COALESCE(d.pinned, false), + COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), + COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), + COALESCE(d.view_forum_as_messages, false) +FROM channel_members m +JOIN channels c ON c.id = m.channel_id AND NOT c.deleted +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE `+strings.Join(where, " AND ")+` +ORDER BY COALESCE(d.pinned, false) DESC, + COALESCE(d.pinned_order, 0) DESC, + `+visibleTopDate+` DESC, + `+visibleTopID+` DESC, + c.id DESC +LIMIT `+limitArg, args...) + if err != nil { + return domain.ChannelDialogList{}, fmt.Errorf("list channel dialogs: %w", err) + } + defer rows.Close() + type item struct { + channel domain.Channel + dialog domain.Dialog + } + items := make([]item, 0, limit) + for rows.Next() { + ch, dialog, err := scanChannelDialogRow(rows, viewerUserID) + if err != nil { + return domain.ChannelDialogList{}, err + } + if !channelDialogMatchesFilter(dialog, ch, filter) { + continue + } + items = append(items, item{channel: ch, dialog: dialog}) + } + if err := rows.Err(); err != nil { + return domain.ChannelDialogList{}, err + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].dialog.Pinned != items[j].dialog.Pinned { + return items[i].dialog.Pinned + } + if items[i].dialog.PinnedOrder != items[j].dialog.PinnedOrder { + return items[i].dialog.PinnedOrder > items[j].dialog.PinnedOrder + } + if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate { + return items[i].dialog.TopMessageDate > items[j].dialog.TopMessageDate + } + if items[i].dialog.TopMessage != items[j].dialog.TopMessage { + return items[i].dialog.TopMessage > items[j].dialog.TopMessage + } + return items[i].dialog.Peer.ID > items[j].dialog.Peer.ID + }) + out := domain.ChannelDialogList{Count: len(items)} + if len(items) > limit { + items = items[:limit] + } + for _, item := range items { + msg, _ := s.getChannelMessage(ctx, s.db, item.channel.ID, item.dialog.TopMessage) + if msg.ID != 0 { + item.dialog.TopMessageDate = msg.Date + out.Messages = append(out.Messages, msg) + } + out.Dialogs = append(out.Dialogs, item.dialog) + out.Channels = append(out.Channels, item.channel) + } + return out, nil +} + +func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { + out := domain.ChannelDialogList{} + seen := make(map[int64]struct{}, len(channelIDs)) + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + if _, ok := seen[channelID]; ok { + continue + } + seen[channelID] = struct{}{} + channel, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) { + continue + } + return domain.ChannelDialogList{}, err + } + dialog, err := s.getChannelDialog(ctx, s.db, viewerUserID, channel) + if err != nil { + return domain.ChannelDialogList{}, err + } + msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID) + if msg.ID != 0 { + dialog.TopMessageDate = msg.Date + out.Messages = append(out.Messages, msg) + } + out.Dialogs = append(out.Dialogs, channelDialogToDialog(dialog)) + out.Channels = append(out.Channels, channel) + } + out.Count = len(out.Dialogs) + return out, nil +} + +func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { + if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { + return domain.CommonChannelsResult{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxCommonChannelsLimit { + limit = domain.MaxCommonChannelsLimit + } + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM user_channel_member_index selfm +JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id +WHERE selfm.user_id = $1 + AND targetm.user_id = $2 + AND selfm.status = 'active' + AND targetm.status = 'active' + AND selfm.megagroup + AND NOT selfm.broadcast + AND NOT selfm.deleted + AND targetm.megagroup + AND NOT targetm.broadcast + AND NOT targetm.deleted`, req.UserID, req.TargetUserID).Scan(&count); err != nil { + return domain.CommonChannelsResult{}, fmt.Errorf("count common channels: %w", err) + } + out := domain.CommonChannelsResult{Count: count} + if req.CountOnly { + return out, nil + } + rows, err := s.db.Query(ctx, ` +SELECT selfm.channel_id +FROM user_channel_member_index selfm +JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id +WHERE selfm.user_id = $1 + AND targetm.user_id = $2 + AND selfm.status = 'active' + AND targetm.status = 'active' + AND selfm.megagroup + AND NOT selfm.broadcast + AND NOT selfm.deleted + AND targetm.megagroup + AND NOT targetm.broadcast + AND NOT targetm.deleted + AND ($3::bigint = 0 OR selfm.channel_id > $3) +ORDER BY selfm.channel_id ASC +LIMIT $4`, req.UserID, req.TargetUserID, req.MaxID, limit) + if err != nil { + return domain.CommonChannelsResult{}, fmt.Errorf("list common channels: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, limit) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return domain.CommonChannelsResult{}, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return domain.CommonChannelsResult{}, err + } + channels, err := listChannelsByIDs(ctx, s.db, ids) + if err != nil { + return domain.CommonChannelsResult{}, err + } + out.Channels = channels + return out, nil +} + +func (s *ChannelStore) ListLeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { + if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { + return domain.LeftChannelsResult{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxLeftChannelsLimit { + limit = domain.MaxLeftChannelsLimit + } + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_members m +JOIN channels c ON c.id = m.channel_id +WHERE m.user_id = $1 + AND m.status = 'left' + AND (c.broadcast OR c.megagroup) + AND NOT c.deleted`, userID).Scan(&count); err != nil { + return domain.LeftChannelsResult{}, fmt.Errorf("count left channels: %w", err) + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, + m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, + m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date +FROM channel_members m +JOIN channels c ON c.id = m.channel_id +WHERE m.user_id = $1 + AND m.status = 'left' + AND (c.broadcast OR c.megagroup) + AND NOT c.deleted +ORDER BY m.left_at DESC, c.id DESC +OFFSET $2 +LIMIT $3`, userID, offset, limit) + if err != nil { + return domain.LeftChannelsResult{}, fmt.Errorf("list left channels: %w", err) + } + defer rows.Close() + out := domain.LeftChannelsResult{Count: count, Channels: make([]domain.LeftChannel, 0, limit)} + for rows.Next() { + ch, member, err := scanChannelWithMember(rows) + if err != nil { + return domain.LeftChannelsResult{}, err + } + out.Channels = append(out.Channels, domain.LeftChannel{Channel: ch, Self: member}) + } + return out, rows.Err() +} + +func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { + if userID == 0 { + return domain.ChannelDialogList{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxInactiveChannelsLimit { + limit = domain.MaxInactiveChannelsLimit + } + visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" + visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END" + visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" + visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + `+visibleTopID+`, + `+visibleTopDate+`, + COALESCE(d.folder_id, 0), `+visibleReadInbox+`, + COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), `+visibleUnreadCount+`, + COALESCE(d.pinned, false), + COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), + COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), + COALESCE(d.view_forum_as_messages, false) +FROM channel_members m +JOIN channels c ON c.id = m.channel_id AND NOT c.deleted +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE m.user_id = $1 + AND m.status = 'active' + AND (c.broadcast OR c.megagroup) +ORDER BY `+visibleTopDate+` ASC, + `+visibleTopID+` ASC, + c.id ASC +LIMIT $2`, userID, limit) + if err != nil { + return domain.ChannelDialogList{}, fmt.Errorf("list inactive channels: %w", err) + } + defer rows.Close() + out := domain.ChannelDialogList{Dialogs: make([]domain.Dialog, 0, limit), Channels: make([]domain.Channel, 0, limit)} + for rows.Next() { + ch, dialog, err := scanChannelDialogRow(rows, userID) + if err != nil { + return domain.ChannelDialogList{}, err + } + out.Dialogs = append(out.Dialogs, dialog) + out.Channels = append(out.Channels, ch) + } + if err := rows.Err(); err != nil { + return domain.ChannelDialogList{}, err + } + out.Count = len(out.Dialogs) + return out, nil +} + +func (s *ChannelStore) ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { + if req.UserID == 0 || req.SourceChannelID < 0 { + return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit { + limit = domain.DefaultChannelRecommendationsLimit + } + args := []any{req.UserID, req.SourceChannelID} + where := []string{ + "($1::bigint <> 0)", + "c.broadcast", + "NOT c.megagroup", + "NOT c.deleted", + "COALESCE(c.username, '') <> ''", + "($2::bigint = 0 OR c.id <> $2)", + } + if req.SourceChannelID == 0 { + where = append(where, `NOT EXISTS ( + SELECT 1 + FROM channel_members m + WHERE m.channel_id = c.id + AND m.user_id = $1 + AND m.status = 'active' +)`) + } + whereSQL := strings.Join(where, " AND ") + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channels c +WHERE `+whereSQL, args...).Scan(&count); err != nil { + return domain.ChannelRecommendationsResult{}, fmt.Errorf("count channel recommendations: %w", err) + } + args = append(args, limit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+` +FROM channels c +WHERE `+whereSQL+` +ORDER BY c.participants_count DESC, c.date DESC, c.id DESC +LIMIT $3`, args...) + if err != nil { + return domain.ChannelRecommendationsResult{}, fmt.Errorf("list channel recommendations: %w", err) + } + defer rows.Close() + out := domain.ChannelRecommendationsResult{Count: count, Channels: make([]domain.Channel, 0, limit)} + for rows.Next() { + ch, err := scanChannel(rows) + if err != nil { + return domain.ChannelRecommendationsResult{}, err + } + out.Channels = append(out.Channels, ch) + } + if err := rows.Err(); err != nil { + return domain.ChannelRecommendationsResult{}, err + } + return out, nil +} + +func (s *ChannelStore) ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit { + limit = domain.MaxDiscussionGroupsLimit + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+` +FROM channel_members m +JOIN channels c ON c.id = m.channel_id +WHERE m.user_id = $1 + AND m.status = 'active' + AND c.megagroup + AND NOT c.broadcast + AND NOT c.forum + AND NOT c.deleted + AND ( + m.role = 'creator' + OR (m.role = 'admin' AND COALESCE((m.admin_rights->>'PinMessages')::boolean, false)) + ) +ORDER BY c.id DESC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list discussion groups: %w", err) + } + defer rows.Close() + out := make([]domain.Channel, 0, limit) + for rows.Next() { + ch, err := scanChannel(rows) + if err != nil { + return nil, err + } + out = append(out, ch) + } + return out, rows.Err() +} + +func (s *ChannelStore) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { + if userID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid + } + if broadcastID == 0 && groupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("set discussion group: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("begin set discussion group: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + changed := make(map[int64]domain.Channel) + markChanged := func(channel domain.Channel) { + if channel.ID != 0 { + changed[channel.ID] = channel + } + } + setLinked := func(channel domain.Channel, linkedID int64) (domain.Channel, error) { + if channel.LinkedChatID == linkedID { + return channel, nil + } + if _, err := tx.Exec(ctx, `UPDATE channels SET linked_chat_id = $2, updated_at = now() WHERE id = $1`, channel.ID, linkedID); err != nil { + return domain.Channel{}, fmt.Errorf("update linked chat: %w", err) + } + channel.LinkedChatID = linkedID + markChanged(channel) + return channel, nil + } + logLinkChange := func(channelID, prev, next int64) error { + if prev == next || channelID == 0 { + return nil + } + return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(prev), + NewInt: int(next), + }) + } + + if broadcastID == 0 { + group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID) + if err != nil || !validDiscussionGroup(group) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid + } + if !canManageDiscussionGroup(groupMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + oldBroadcastID := group.LinkedChatID + if oldBroadcastID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID) + if err == nil && oldBroadcast.LinkedChatID == groupID { + updated, err := setLinked(oldBroadcast, 0) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := logLinkChange(updated.ID, groupID, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.DiscussionGroupUpdateResult{}, err + } + if _, err := setLinked(group, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) + } + committed = true + return discussionGroupUpdateResult(changed), nil + } + + broadcast, broadcastMember, err := s.getChannelForMember(ctx, tx, userID, broadcastID) + if err != nil || !broadcast.Broadcast || broadcast.Megagroup { + return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid + } + if !canManageDiscussionBroadcast(broadcastMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + oldGroupID := broadcast.LinkedChatID + if groupID == 0 { + if oldGroupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + updated, err := setLinked(broadcast, 0) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := logLinkChange(updated.ID, oldGroupID, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + oldGroup, err := getChannelByID(ctx, tx, oldGroupID) + if err == nil && oldGroup.LinkedChatID == broadcastID { + if _, err := setLinked(oldGroup, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) + } + committed = true + return discussionGroupUpdateResult(changed), nil + } + + group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID) + if err != nil || !validDiscussionGroup(group) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid + } + if group.PreHistoryHidden { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden + } + if !canManageDiscussionGroup(groupMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + if oldGroupID == groupID && group.LinkedChatID == broadcastID { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + oldBroadcastID := group.LinkedChatID + if oldGroupID != 0 && oldGroupID != groupID { + oldGroup, err := getChannelByID(ctx, tx, oldGroupID) + if err == nil && oldGroup.LinkedChatID == broadcastID { + if _, err := setLinked(oldGroup, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.DiscussionGroupUpdateResult{}, err + } + } + if oldBroadcastID != 0 && oldBroadcastID != broadcastID { + oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID) + if err == nil && oldBroadcast.LinkedChatID == groupID { + updated, err := setLinked(oldBroadcast, 0) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := logLinkChange(updated.ID, groupID, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.DiscussionGroupUpdateResult{}, err + } + } + updatedBroadcast, err := setLinked(broadcast, groupID) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if _, err := setLinked(group, broadcastID); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := logLinkChange(updatedBroadcast.ID, oldGroupID, groupID); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) + } + committed = true + return discussionGroupUpdateResult(changed), nil +} + +func (s *ChannelStore) SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + var changed bool + if err := s.db.QueryRow(ctx, ` +WITH target AS ( + SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date + FROM channels c + JOIN channel_members m ON m.channel_id = c.id + WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted +), +ensured AS ( + INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) + SELECT $1, channel_id, top_message_id, top_message_date FROM target + ON CONFLICT (user_id, channel_id) DO NOTHING +), +next_order AS ( + SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value + FROM channel_dialogs + WHERE user_id = $1 AND pinned +), +updated AS ( + UPDATE channel_dialogs d + SET pinned = $3, + pinned_order = CASE + WHEN $3::boolean THEN CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END + ELSE 0 + END, + updated_at = now() + FROM next_order + WHERE d.user_id = $1 AND d.channel_id = $2 + AND EXISTS (SELECT 1 FROM target) + AND (d.pinned IS DISTINCT FROM $3::boolean OR ($3::boolean AND d.pinned_order = 0)) + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean`, userID, channelID, pinned).Scan(&changed); err != nil { + return false, fmt.Errorf("set channel dialog pinned: %w", err) + } + return changed, nil +} + +func (s *ChannelStore) ReorderChannelPinnedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error { + if userID == 0 { + return nil + } + peerTypes, peerIDs := peerArrays(order) + if force { + if _, err := s.db.Exec(ctx, ` +WITH requested AS ( + SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) +) +UPDATE channel_dialogs d +SET pinned = false, pinned_order = 0, updated_at = now() +WHERE d.user_id = $1 + AND d.pinned + AND NOT EXISTS ( + SELECT 1 FROM requested r + WHERE r.peer_type = 'channel' AND r.peer_id = d.channel_id + )`, userID, peerTypes, peerIDs); err != nil { + return fmt.Errorf("clear channel pinned dialogs not in order: %w", err) + } + } + if len(peerIDs) == 0 { + return nil + } + if _, err := s.db.Exec(ctx, ` +WITH requested AS ( + SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id, i::int AS pos + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_id) peer_id, (cardinality($3::bigint[]) - pos + 1)::int AS ord + FROM requested + WHERE peer_type = 'channel' + ORDER BY peer_id, pos +) +UPDATE channel_dialogs d +SET pinned = true, pinned_order = deduped.ord, updated_at = now() +FROM deduped +WHERE d.user_id = $1 AND d.channel_id = deduped.peer_id`, userID, peerTypes, peerIDs); err != nil { + return fmt.Errorf("reorder channel pinned dialogs: %w", err) + } + return nil +} + +func (s *ChannelStore) SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + var changed bool + if err := s.db.QueryRow(ctx, ` +WITH target AS ( + SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date + FROM channels c + JOIN channel_members m ON m.channel_id = c.id + WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted +), +ensured AS ( + INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) + SELECT $1, channel_id, top_message_id, top_message_date FROM target + ON CONFLICT (user_id, channel_id) DO NOTHING +), +updated_dialog AS ( + UPDATE channel_dialogs d + SET unread_mark = $3, updated_at = now() + WHERE d.user_id = $1 AND d.channel_id = $2 + AND EXISTS (SELECT 1 FROM target) + AND d.unread_mark IS DISTINCT FROM $3::boolean + RETURNING d.user_id +), +updated_member AS ( + UPDATE channel_members m + SET unread_mark = $3 + WHERE m.user_id = $1 AND m.channel_id = $2 AND m.status = 'active' + RETURNING m.user_id +) +SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, unread).Scan(&changed); err != nil { + return false, fmt.Errorf("set channel dialog unread mark: %w", err) + } + return changed, nil +} + +func (s *ChannelStore) SetChannelViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + var changed bool + if err := s.db.QueryRow(ctx, ` +WITH target AS ( + SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date + FROM channels c + JOIN channel_members m ON m.channel_id = c.id + WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted +), +ensured AS ( + INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) + SELECT $1, channel_id, top_message_id, top_message_date FROM target + ON CONFLICT (user_id, channel_id) DO NOTHING +), +updated_dialog AS ( + UPDATE channel_dialogs d + SET view_forum_as_messages = $3, updated_at = now() + WHERE d.user_id = $1 AND d.channel_id = $2 + AND EXISTS (SELECT 1 FROM target) + AND d.view_forum_as_messages IS DISTINCT FROM $3::boolean + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, enabled).Scan(&changed); err != nil { + return false, fmt.Errorf("set channel view forum as messages: %w", err) + } + return changed, nil +} + +func (s *ChannelStore) ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) { + if userID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT d.channel_id +FROM channel_dialogs d +JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active' +JOIN channels c ON c.id = d.channel_id AND NOT c.deleted +WHERE d.user_id = $1 AND d.unread_mark +ORDER BY d.top_message_date DESC, d.top_message_id DESC, d.channel_id DESC +LIMIT 500`, userID) + if err != nil { + return nil, fmt.Errorf("list channel unread marks: %w", err) + } + defer rows.Close() + out := make([]domain.Peer, 0) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return nil, err + } + out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + if userID == 0 || len(peers) == 0 { + return nil + } + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + folderIDs := make([]int32, 0, len(peers)) + seen := make(map[int64]struct{}, len(peers)) + for _, item := range peers { + if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { + continue + } + if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { + continue + } + if _, ok := seen[item.Peer.ID]; ok { + continue + } + seen[item.Peer.ID] = struct{}{} + peerTypes = append(peerTypes, string(item.Peer.Type)) + peerIDs = append(peerIDs, item.Peer.ID) + folderIDs = append(folderIDs, int32(item.FolderID)) + } + if len(peerIDs) == 0 { + return nil + } + if _, err := s.db.Exec(ctx, ` +WITH requested AS ( + SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) AND i <= cardinality($4::int[]) +), +deduped AS ( + SELECT DISTINCT ON (channel_id) channel_id, folder_id + FROM requested + WHERE peer_type = 'channel' AND folder_id IN (0, 1) + ORDER BY channel_id +) +UPDATE channel_dialogs d +SET folder_id = deduped.folder_id, updated_at = now() +FROM deduped +WHERE d.user_id = $1 + AND d.channel_id = deduped.channel_id + AND EXISTS ( + SELECT 1 FROM channel_members m + WHERE m.user_id = d.user_id AND m.channel_id = d.channel_id AND m.status = 'active' + )`, userID, peerTypes, peerIDs, folderIDs); err != nil { + return fmt.Errorf("edit channel peer folders: %w", err) + } + return nil +} + +func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { + channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + args := []any{filter.ChannelID} + where := "channel_id = $1 AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + if filter.Query != "" { + args = append(args, filter.Query) + where += fmt.Sprintf(" AND body ILIKE '%%' || $%d || '%%'", len(args)) + } + if filter.SenderUserID != 0 { + args = append(args, filter.SenderUserID) + where += fmt.Sprintf(" AND sender_user_id = $%d", len(args)) + } + if filter.MinDate > 0 { + args = append(args, filter.MinDate) + where += fmt.Sprintf(" AND message_date > $%d", len(args)) + } + if filter.MaxDate > 0 { + args = append(args, filter.MaxDate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } else if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + } + if filter.MaxID > 0 { + args = append(args, filter.MaxID) + where += fmt.Sprintf(" AND id <= $%d", len(args)) + } + if filter.MinID > 0 { + args = append(args, filter.MinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + queryLimit := limit + 1 + args = append(args, queryLimit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("list channel history: %w", err) + } + defer rows.Close() + out := domain.ChannelHistory{Channel: channel, Self: member} + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Messages = append(out.Messages, msg) + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, err + } + if len(out.Messages) > limit { + out.Messages = out.Messages[:limit] + out.Count = limit + 1 + } else { + out.Count = len(out.Messages) + } + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + return out, nil +} + +func (s *ChannelStore) SearchPublicPosts(ctx context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { + query := strings.TrimSpace(req.Query) + hashtag := strings.TrimSpace(req.Hashtag) + if (query == "") == (hashtag == "") { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelSearchPostsLimit { + limit = domain.MaxChannelSearchPostsLimit + } + args := []any{} + where := `NOT deleted +AND body <> '' +AND EXISTS ( + SELECT 1 + FROM channels c + WHERE c.id = channel_messages.channel_id + AND NOT c.deleted + AND COALESCE(c.username, '') <> '' + )` + if query != "" { + args = append(args, "%"+escapeLike(query)+"%") + where += fmt.Sprintf(" AND body ILIKE $%d ESCAPE '\\'", len(args)) + } + if hashtag != "" { + args = append(args, "%#"+escapeLike(hashtag)+"%") + where += fmt.Sprintf(" AND body ILIKE $%d ESCAPE '\\'", len(args)) + } + switch { + case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0: + args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID) + n := len(args) + where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n) + case req.OffsetRate > 0: + args = append(args, req.OffsetRate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + case req.OffsetChannelID > 0 && req.OffsetID > 0: + args = append(args, req.OffsetChannelID, req.OffsetID) + n := len(args) + where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n) + case req.OffsetID > 0: + args = append(args, req.OffsetID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } + queryLimit := limit + 1 + args = append(args, queryLimit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY message_date DESC, channel_id DESC, id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("search public channel posts: %w", err) + } + defer rows.Close() + out := domain.ChannelHistory{} + channelRefs := make(map[int64]struct{}) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Messages = append(out.Messages, msg) + channelRefs[msg.ChannelID] = struct{}{} + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, err + } + if len(out.Messages) > limit { + out.Messages = out.Messages[:limit] + out.Count = limit + 1 + channelRefs = make(map[int64]struct{}, len(out.Messages)) + for _, msg := range out.Messages { + channelRefs[msg.ChannelID] = struct{}{} + } + } else { + out.Count = len(out.Messages) + } + channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Channels = channels + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + return out, nil +} + +func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { + query := strings.TrimSpace(req.Query) + if viewerUserID == 0 || query == "" { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { + limit = domain.MaxChannelGlobalSearchLimit + } + args := []any{viewerUserID, "%" + escapeLike(query) + "%"} + where := `NOT deleted +AND body <> '' +AND body ILIKE $2 ESCAPE '\' +AND EXISTS ( + SELECT 1 + FROM channels c + JOIN channel_members cm ON cm.channel_id = c.id + AND cm.user_id = $1 + AND cm.status = 'active' + AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false) + LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1 + WHERE c.id = channel_messages.channel_id + AND NOT c.deleted + AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)` + if req.BroadcastsOnly { + where += ` + AND c.broadcast AND NOT c.megagroup` + } + if req.GroupsOnly { + where += ` + AND c.megagroup` + } + if req.HasFolderID { + args = append(args, req.FolderID) + where += fmt.Sprintf(` + AND d.folder_id = $%d`, len(args)) + } + where += ` +)` + if req.MinDate > 0 { + args = append(args, req.MinDate) + where += fmt.Sprintf(" AND message_date > $%d", len(args)) + } + if req.MaxDate > 0 { + args = append(args, req.MaxDate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + } + switch { + case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0: + args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID) + n := len(args) + where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n) + case req.OffsetRate > 0: + args = append(args, req.OffsetRate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + case req.OffsetChannelID > 0 && req.OffsetID > 0: + args = append(args, req.OffsetChannelID, req.OffsetID) + n := len(args) + where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n) + case req.OffsetID > 0: + args = append(args, req.OffsetID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } + queryLimit := limit + 1 + args = append(args, queryLimit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY message_date DESC, channel_id DESC, id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("search joined channel messages: %w", err) + } + defer rows.Close() + out := domain.ChannelHistory{} + channelRefs := make(map[int64]struct{}) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Messages = append(out.Messages, msg) + channelRefs[msg.ChannelID] = struct{}{} + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, err + } + if len(out.Messages) > limit { + out.Messages = out.Messages[:limit] + out.Count = limit + 1 + channelRefs = make(map[int64]struct{}, len(out.Messages)) + for _, msg := range out.Messages { + channelRefs[msg.ChannelID] = struct{}{} + } + } else { + out.Count = len(out.Messages) + } + channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Channels = channels + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + return out, nil +} + +func (s *ChannelStore) GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelHistory{}, err + } + if len(ids) == 0 { + return domain.ChannelHistory{Channel: channel, Self: member}, nil + } + if len(ids) > domain.MaxGetMessageIDs { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + id32, _, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return domain.ChannelHistory{}, err + } + args := []any{channelID, id32} + where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC`, args...) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("get channel messages by ids: %w", err) + } + defer rows.Close() + out := domain.ChannelHistory{Channel: channel, Self: member} + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Messages = append(out.Messages, msg) + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, err + } + out.Count = len(out.Messages) + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + return out, nil +} + +func (s *ChannelStore) ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel message contents: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("begin read channel message contents: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if len(req.IDs) == 0 { + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err) + } + committed = true + return domain.ReadChannelMessageContentsResult{Channel: channel}, nil + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + id32, _, err := validUniqueChannelMessageIDs(req.IDs) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + args := []any{req.ChannelID, id32} + where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + rows, err := tx.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC`, args...) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel messages by ids: %w", err) + } + messages := make([]domain.ChannelMessage, 0, len(id32)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + rows.Close() + return domain.ReadChannelMessageContentsResult{}, err + } + messages = append(messages, msg) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.ReadChannelMessageContentsResult{}, err + } + rows.Close() + visibleIDs := make([]int32, 0, len(messages)) + for _, msg := range messages { + visibleIDs = append(visibleIDs, int32(msg.ID)) + } + cleared, err := clearChannelUnreadReactionsForMessageIDsTx(ctx, tx, req.UserID, req.ChannelID, visibleIDs) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if err := s.populateChannelMessageReplies(ctx, tx, req.UserID, channel, messages); err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if err := s.populateChannelMessagesReactions(ctx, tx, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err) + } + committed = true + return domain.ReadChannelMessageContentsResult{ + Channel: channel, + Messages: messages, + ClearedUnreadReactionMessageIDs: cleared, + }, nil +} + +func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageViewsResult{}, err + } + if len(req.IDs) == 0 { + return domain.ChannelMessageViewsResult{Views: map[int]int{}}, nil + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + id32, _, err := validUniqueChannelMessageIDs(req.IDs) + if err != nil { + return domain.ChannelMessageViewsResult{}, err + } + if req.Increment { + date := req.Date + if date <= 0 { + date = nowUnix() + } + rows, err := s.db.Query(ctx, ` +WITH inserted AS ( + INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at) + SELECT m.channel_id, m.id, $3, $4 + FROM channel_messages m + WHERE m.channel_id = $1 + AND m.id = ANY($2::int[]) + AND NOT m.deleted + AND m.id > $5 + ON CONFLICT DO NOTHING + RETURNING message_id +), updated AS ( + UPDATE channel_messages m + SET views_count = views_count + 1, + updated_at = now() + FROM inserted i + WHERE m.channel_id = $1 + AND m.id = i.message_id + RETURNING m.id +) +SELECT i.message_id +FROM inserted i +LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, date, member.AvailableMinID) + if err != nil { + return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err) + } + for rows.Next() { + var ignored int + if err := rows.Scan(&ignored); err != nil { + rows.Close() + return domain.ChannelMessageViewsResult{}, err + } + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.ChannelMessageViewsResult{}, err + } + rows.Close() + } + args := []any{req.ChannelID, id32} + where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + rows, err := s.db.Query(ctx, ` +SELECT id, views_count +FROM channel_messages +WHERE `+where, args...) + if err != nil { + return domain.ChannelMessageViewsResult{}, fmt.Errorf("get channel message views: %w", err) + } + defer rows.Close() + out := make(map[int]int, len(req.IDs)) + for rows.Next() { + var id int + var views int + if err := rows.Scan(&id, &views); err != nil { + return domain.ChannelMessageViewsResult{}, err + } + out[id] = views + } + if err := rows.Err(); err != nil { + return domain.ChannelMessageViewsResult{}, err + } + return domain.ChannelMessageViewsResult{Views: out}, nil +} + +func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + for _, reaction := range req.Reactions { + if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" || len(reaction.Emoticon) > domain.MaxChannelReactionEmoticonLength { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + } + if req.Date <= 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("set channel message reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin set channel message reactions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if _, err := tx.Exec(ctx, ` +DELETE FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, req.ChannelID, req.MessageID, req.UserID); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel message reactions: %w", err) + } + for i, reaction := range req.Reactions { + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_message_reactions ( + channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, + big, unread, chosen_order, reaction_date +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + req.ChannelID, req.MessageID, req.UserID, msg.SenderUserID, string(reaction.Type), reaction.Emoticon, req.Big, msg.SenderUserID != 0 && msg.SenderUserID != req.UserID, i+1, req.Date); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("insert channel message reaction: %w", err) + } + if req.AddToRecent { + if _, err := tx.Exec(ctx, ` +INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) +VALUES ($1,$2,$3,$4) +ON CONFLICT (user_id, reaction_type, reaction_value) +DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, + req.UserID, string(reaction.Type), reaction.Emoticon, req.Date); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert recent message reaction: %w", err) + } + } + if _, err := tx.Exec(ctx, ` +INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) +VALUES ($1,$2,$3,1,$4) +ON CONFLICT (user_id, reaction_type, reaction_value) +DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, + req.UserID, string(reaction.Type), reaction.Emoticon, req.Date); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert top message reaction: %w", err) + } + } + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit set channel message reactions: %w", err) + } + committed = true + messages := []domain.ChannelMessage{msg} + if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + msg = messages[0] + reactions := emptyChannelMessageReactions(channel) + if msg.Reactions != nil { + reactions = *msg.Reactions + } else { + msg.Reactions = &reactions + } + recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) + if err != nil { + recipients = []int64{req.UserID} + } + return domain.ChannelMessageReactionsResult{ + Channel: channel, + Message: msg, + Messages: []domain.ChannelMessage{msg}, + Reactions: reactions, + Recipients: recipients, + }, nil +} + +func (s *ChannelStore) DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel participant reaction: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin delete channel participant reaction: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelAdminRequired + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if _, err := tx.Exec(ctx, ` +DELETE FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, + req.ChannelID, req.MessageID, req.ParticipantUserID); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete participant reaction: %w", err) + } + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit delete participant reaction: %w", err) + } + committed = true + messages := []domain.ChannelMessage{msg} + if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + msg = messages[0] + reactions := emptyChannelMessageReactions(channel) + if msg.Reactions != nil { + reactions = *msg.Reactions + } else { + msg.Reactions = &reactions + } + recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) + if err != nil { + recipients = []int64{req.UserID} + } + return domain.ChannelMessageReactionsResult{ + Channel: channel, + Message: msg, + Messages: []domain.ChannelMessage{msg}, + Reactions: reactions, + Recipients: recipients, + }, nil +} + +func (s *ChannelStore) DeleteChannelParticipantReactions(ctx context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { + req.Limit = domain.MaxDeleteParticipantReactionsBatch + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete channel participant reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("begin delete channel participant reactions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelAdminRequired + } + rows, err := tx.Query(ctx, ` +SELECT message_id, MAX(sender_user_id) +FROM channel_message_reactions +WHERE channel_id = $1 AND reacted_user_id = $2 +GROUP BY message_id +ORDER BY MAX(reaction_date) DESC, message_id DESC +LIMIT $3`, req.ChannelID, req.ParticipantUserID, req.Limit) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("list participant reaction messages: %w", err) + } + ids := make([]int, 0, req.Limit) + owners := make(map[int64]struct{}) + for rows.Next() { + var msgID int + var senderUserID int64 + if err := rows.Scan(&msgID, &senderUserID); err != nil { + rows.Close() + return domain.DeleteChannelParticipantReactionsResult{}, err + } + ids = append(ids, msgID) + if senderUserID != 0 { + owners[senderUserID] = struct{}{} + } + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.DeleteChannelParticipantReactionsResult{}, err + } + rows.Close() + if len(ids) > 0 { + if _, err := tx.Exec(ctx, ` +DELETE FROM channel_message_reactions +WHERE channel_id = $1 AND reacted_user_id = $2 AND message_id = ANY($3::int[])`, + req.ChannelID, req.ParticipantUserID, int32s(ids)); err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete participant reactions: %w", err) + } + for ownerID := range owners { + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, ownerID, req.ChannelID); err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, err + } + } + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("commit delete participant reactions: %w", err) + } + committed = true + messages := []domain.ChannelMessage{} + if len(ids) > 0 { + res, err := s.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + IDs: ids, + }) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, err + } + messages = res.Messages + } + recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) + if err != nil { + recipients = []int64{req.UserID} + } + return domain.DeleteChannelParticipantReactionsResult{ + Channel: channel, + Messages: messages, + Recipients: recipients, + Deleted: len(ids), + }, nil +} + +func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if len(req.IDs) == 0 { + return domain.ChannelMessageReactionsResult{Channel: channel}, nil + } + id32, _, err := validUniqueChannelMessageIDs(req.IDs) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + args := []any{req.ChannelID, id32} + where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC`, args...) + if err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("get channel message reactions messages: %w", err) + } + defer rows.Close() + messages := make([]domain.ChannelMessage, 0, len(req.IDs)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + messages = append(messages, msg) + } + if err := rows.Err(); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + res := domain.ChannelMessageReactionsResult{Channel: channel, Messages: messages} + if len(messages) == 1 { + res.Message = messages[0] + res.Reactions = emptyChannelMessageReactions(channel) + if messages[0].Reactions != nil { + res.Reactions = *messages[0].Reactions + } + } + return res, nil +} + +func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { + req.Limit = domain.MaxChannelMessageReactionListLimit + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsList{}, err + } + if channel.Broadcast && !channel.Megagroup { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden + } + msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessageReactionsList{}, err + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid + } + baseWhere := []string{"channel_id = $1", "message_id = $2"} + baseArgs := []any{req.ChannelID, req.MessageID} + if req.Reaction != nil { + if req.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(req.Reaction.Emoticon) == "" { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + baseArgs = append(baseArgs, string(req.Reaction.Type), req.Reaction.Emoticon) + baseWhere = append(baseWhere, fmt.Sprintf("reaction_type = $%d AND reaction_value = $%d", len(baseArgs)-1, len(baseArgs))) + } + var count int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_message_reactions WHERE `+strings.Join(baseWhere, " AND "), baseArgs...).Scan(&count); err != nil { + return domain.ChannelMessageReactionsList{}, fmt.Errorf("count channel message reactions: %w", err) + } + where := append([]string(nil), baseWhere...) + args := append([]any(nil), baseArgs...) + if req.Offset != "" { + cursor, ok := parseChannelReactionOffset(req.Offset) + if !ok { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + args = append(args, cursor.date, cursor.userID, cursor.emoticon) + n := len(args) + where = append(where, fmt.Sprintf("(reaction_date < $%d OR (reaction_date = $%d AND (reacted_user_id < $%d OR (reacted_user_id = $%d AND reaction_value > $%d))))", n-2, n-2, n-1, n-1, n)) + } + args = append(args, req.Limit+1) + rows, err := s.db.Query(ctx, ` +SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, + big, unread, chosen_order, reaction_date +FROM channel_message_reactions +WHERE `+strings.Join(where, " AND ")+` +ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_value ASC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelMessageReactionsList{}, fmt.Errorf("list channel message reactions: %w", err) + } + defer rows.Close() + reactions := make([]domain.ChannelMessagePeerReaction, 0, req.Limit+1) + for rows.Next() { + row, err := scanChannelMessagePeerReaction(rows, req.UserID) + if err != nil { + return domain.ChannelMessageReactionsList{}, err + } + reactions = append(reactions, row) + } + if err := rows.Err(); err != nil { + return domain.ChannelMessageReactionsList{}, err + } + next := "" + if len(reactions) > req.Limit { + reactions = reactions[:req.Limit] + next = channelReactionOffset(reactions[len(reactions)-1]) + } + return domain.ChannelMessageReactionsList{ + Channel: channel, + Message: msg, + Count: count, + Reactions: reactions, + NextOffset: next, + }, nil +} + +func (s *ChannelStore) ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxTopMessageReactions { + limit = domain.MaxTopMessageReactions + } + rows, err := s.db.Query(ctx, ` +SELECT reaction_type, reaction_value +FROM user_top_reactions +WHERE user_id = $1 +ORDER BY reaction_count DESC, reaction_date DESC, updated_at DESC, reaction_value ASC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list top message reactions: %w", err) + } + defer rows.Close() + out := make([]domain.MessageReaction, 0, limit) + for rows.Next() { + var reactionType, reactionValue string + if err := rows.Scan(&reactionType, &reactionValue); err != nil { + return nil, err + } + out = append(out, domain.MessageReaction{ + Type: domain.MessageReactionType(reactionType), + Emoticon: reactionValue, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxRecentMessageReactions { + limit = domain.MaxRecentMessageReactions + } + rows, err := s.db.Query(ctx, ` +SELECT reaction_type, reaction_value +FROM user_recent_reactions +WHERE user_id = $1 +ORDER BY reaction_date DESC, updated_at DESC, reaction_value ASC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list recent message reactions: %w", err) + } + defer rows.Close() + out := make([]domain.MessageReaction, 0, limit) + for rows.Next() { + var reactionType, reactionValue string + if err := rows.Scan(&reactionType, &reactionValue); err != nil { + return nil, err + } + out = append(out, domain.MessageReaction{ + Type: domain.MessageReactionType(reactionType), + Emoticon: reactionValue, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { + if userID == 0 || len(reactions) == 0 { + return nil + } + if date <= 0 { + date = nowUnix() + } + for _, reaction := range reactions { + if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" || len(reaction.Emoticon) > domain.MaxChannelReactionEmoticonLength { + continue + } + if addToRecent { + if _, err := s.db.Exec(ctx, ` +INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) +VALUES ($1,$2,$3,$4) +ON CONFLICT (user_id, reaction_type, reaction_value) +DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, + userID, string(reaction.Type), reaction.Emoticon, date); err != nil { + return fmt.Errorf("record recent message reaction: %w", err) + } + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) +VALUES ($1,$2,$3,1,$4) +ON CONFLICT (user_id, reaction_type, reaction_value) +DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, + userID, string(reaction.Type), reaction.Emoticon, date); err != nil { + return fmt.Errorf("record top message reaction: %w", err) + } + } + return nil +} + +func (s *ChannelStore) ClearRecentMessageReactions(ctx context.Context, userID int64) error { + if userID == 0 { + return domain.ErrChannelInvalid + } + if _, err := s.db.Exec(ctx, `DELETE FROM user_recent_reactions WHERE user_id = $1`, userID); err != nil { + return fmt.Errorf("clear recent message reactions: %w", err) + } + return nil +} + +func (s *ChannelStore) ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.SavedReactionTag{}, nil + } + if limit > domain.MaxSavedReactionTags { + limit = domain.MaxSavedReactionTags + } + rows, err := s.db.Query(ctx, ` +SELECT reaction_type, reaction_value, title, reaction_count +FROM user_saved_reaction_tags +WHERE user_id = $1 +ORDER BY reaction_count DESC, updated_at DESC, reaction_value ASC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list saved reaction tags: %w", err) + } + defer rows.Close() + out := make([]domain.SavedReactionTag, 0, limit) + for rows.Next() { + var reactionType, reactionValue, title string + var count int + if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil { + return nil, err + } + out = append(out, domain.SavedReactionTag{ + UserID: userID, + Reaction: domain.MessageReaction{ + Type: domain.MessageReactionType(reactionType), + Emoticon: reactionValue, + }, + Title: title, + Count: count, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error { + if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji { + return domain.ErrChannelInvalid + } + reactionValue := strings.TrimSpace(tag.Reaction.Emoticon) + if reactionValue == "" { + return domain.ErrChannelInvalid + } + if tag.Count < 0 { + tag.Count = 0 + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO user_saved_reaction_tags (user_id, reaction_type, reaction_value, title, reaction_count) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET + title = EXCLUDED.title, + reaction_count = GREATEST(user_saved_reaction_tags.reaction_count, EXCLUDED.reaction_count), + updated_at = now()`, tag.UserID, string(tag.Reaction.Type), reactionValue, tag.Title, tag.Count); err != nil { + return fmt.Errorf("upsert saved reaction tag: %w", err) + } + return nil +} + +func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.RandomID == 0 { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + title := strings.TrimSpace(req.Title) + if title == "" && !req.TitleMissing { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + if !channel.Forum || channel.Broadcast || !channel.Megagroup { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + if !canSendChannelMessage(channel, member) { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden + } + if req.IconColor == 0 { + req.IconColor = domain.DefaultForumTopicIconColor + } + res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + RandomID: req.RandomID, + SendAs: req.SendAs, + Action: &domain.ChannelMessageAction{ + Type: domain.ChannelActionTopicCreate, + Title: title, + IconColor: req.IconColor, + IconEmojiID: req.IconEmojiID, + TitleMissing: req.TitleMissing, + }, + Date: req.Date, + }) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionTopicCreate { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO channel_forum_topics ( + channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, + title_missing, date, top_message_id, read_inbox_max_id, read_outbox_max_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $2, $2, $2) +ON CONFLICT (channel_id, topic_id) DO NOTHING`, + req.ChannelID, res.Message.ID, req.UserID, title, req.IconColor, req.IconEmojiID, req.TitleMissing, res.Message.Date); err != nil { + return domain.CreateChannelForumTopicResult{}, fmt.Errorf("insert forum topic: %w", err) + } + topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, res.Message.ID) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + return domain.CreateChannelForumTopicResult{ + Channel: res.Channel, + Topic: topic, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + Duplicate: res.Duplicate, + }, nil +} + +func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + if !channel.Forum { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + if !canManageForumTopic(channel, member, topic, req.UserID) { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired + } + next := topic + action := domain.ChannelMessageAction{Type: domain.ChannelActionTopicEdit} + changed := false + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if next.Title != title { + next.Title = title + action.Title = title + changed = true + } + } + if req.IconEmojiID != nil && next.IconEmojiID != *req.IconEmojiID { + next.IconEmojiID = *req.IconEmojiID + action.IconEmojiID = *req.IconEmojiID + action.IconEmojiIDSet = true + changed = true + } + if req.Closed != nil && next.Closed != *req.Closed { + next.Closed = *req.Closed + action.Closed = boolPtr(*req.Closed) + changed = true + } + if req.Hidden != nil && next.Hidden != *req.Hidden { + next.Hidden = *req.Hidden + action.Hidden = boolPtr(*req.Hidden) + changed = true + } + if !changed { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelNotModified + } + res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + ReplyTo: &domain.MessageReply{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, + MessageID: req.TopicID, + TopMessageID: req.TopicID, + }, + Action: &action, + Date: req.Date, + }) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + if _, err := s.db.Exec(ctx, ` +UPDATE channel_forum_topics +SET title = $3, + icon_emoji_id = $4, + closed = $5, + hidden = $6, + top_message_id = GREATEST(top_message_id, $7), + updated_at = now() +WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, + req.ChannelID, req.TopicID, next.Title, next.IconEmojiID, next.Closed, next.Hidden, res.Message.ID); err != nil { + return domain.EditChannelForumTopicResult{}, fmt.Errorf("update forum topic: %w", err) + } + topic, err = s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + return domain.EditChannelForumTopicResult{ + Channel: res.Channel, + Topic: topic, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + }, nil +} + +func (s *ChannelStore) UpdatePinnedForumTopic(ctx context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + if !channel.Forum { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelForumMissing + } + topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelAdminRequired + } + if topic.Pinned == req.Pinned { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelNotModified + } + pinnedOrder := 0 + if req.Pinned { + pinnedOrder = topic.PinnedOrder + if pinnedOrder == 0 { + pinnedOrder, err = s.nextForumTopicPinnedOrder(ctx, req.ChannelID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + } + } + if _, err := s.db.Exec(ctx, ` +UPDATE channel_forum_topics +SET pinned = $3, pinned_order = $4, updated_at = now() +WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, + req.ChannelID, req.TopicID, req.Pinned, pinnedOrder); err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, fmt.Errorf("update pinned forum topic: %w", err) + } + topic, err = s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.UpdateChannelForumTopicPinnedResult{Channel: channel, Topic: topic, Recipients: recipients}, nil +} + +func (s *ChannelStore) ReorderPinnedForumTopics(ctx context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ReorderChannelPinnedForumTopicsResult{}, err + } + if !channel.Forum { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelForumMissing + } + if !canPinChannelMessages(channel, member) { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelAdminRequired + } + seen := make(map[int]struct{}, len(req.Order)) + order := make([]int, 0, len(req.Order)) + for _, id := range req.Order { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, id) + if err != nil || !topic.Pinned { + if req.Force { + continue + } + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + seen[id] = struct{}{} + order = append(order, id) + } + for i, id := range order { + if _, err := s.db.Exec(ctx, ` +UPDATE channel_forum_topics +SET pinned_order = $3, updated_at = now() +WHERE channel_id = $1 AND topic_id = $2 AND pinned AND NOT deleted`, req.ChannelID, id, len(order)-i); err != nil { + return domain.ReorderChannelPinnedForumTopicsResult{}, fmt.Errorf("reorder pinned forum topics: %w", err) + } + } + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.ReorderChannelPinnedForumTopicsResult{Channel: channel, Order: order, Recipients: recipients}, nil +} + +func (s *ChannelStore) DeleteForumTopicHistory(ctx context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete forum topic history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete forum topic history: %w", err) + } + committed := false + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if !channel.Forum { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelForumMissing + } + topic, err := s.getForumTopic(ctx, tx, req.ChannelID, req.TopicID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + rows, err := tx.Query(ctx, ` +SELECT id +FROM channel_messages +WHERE channel_id = $1 AND NOT deleted AND (id = $2 OR reply_to_top_id = $2) +ORDER BY id DESC +LIMIT $3`, req.ChannelID, req.TopicID, domain.MaxDeleteHistoryBatch) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list forum topic delete ids: %w", err) + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + rows.Close() + deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + remaining := 0 + if err := tx.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_messages +WHERE channel_id = $1 AND NOT deleted AND (id = $2 OR reply_to_top_id = $2)`, req.ChannelID, req.TopicID).Scan(&remaining); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("count remaining forum topic messages: %w", err) + } + offset := 0 + if remaining > 0 { + offset = 1 + } else if _, err := tx.Exec(ctx, ` +UPDATE channel_forum_topics +SET deleted = true, updated_at = now() +WHERE channel_id = $1 AND topic_id = $2`, req.ChannelID, req.TopicID); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("mark forum topic deleted: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete forum topic history: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil +} + +func (s *ChannelStore) ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + if !channel.Forum { + return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelForumTopicsLimit { + limit = domain.MaxChannelForumTopicsLimit + } + query := strings.TrimSpace(strings.ToLower(filter.Query)) + countArgs := []any{filter.ChannelID, member.AvailableMinID, query} + countSQL := ` +SELECT COUNT(*)::int +FROM channel_forum_topics +WHERE channel_id = $1 AND NOT deleted AND topic_id > $2 + AND ($3 = '' OR POSITION($3 IN LOWER(title)) > 0)` + var total int + if err := s.db.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { + return domain.ChannelForumTopicList{}, fmt.Errorf("count forum topics: %w", err) + } + args := []any{filter.ChannelID, member.AvailableMinID, query} + where := `channel_id = $1 AND NOT deleted AND topic_id > $2 AND ($3 = '' OR POSITION($3 IN LOWER(title)) > 0)` + offsetID := filter.OffsetTopic + if offsetID == 0 { + offsetID = filter.OffsetID + } + if filter.OffsetDate != 0 { + args = append(args, filter.OffsetDate, offsetID) + where += fmt.Sprintf(" AND (date, topic_id) < ($%d, $%d)", len(args)-1, len(args)) + } else if offsetID != 0 { + args = append(args, offsetID) + where += fmt.Sprintf(" AND topic_id < $%d", len(args)) + } + args = append(args, limit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelForumTopicColumns+` +FROM channel_forum_topics +WHERE `+where+` +ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelForumTopicList{}, fmt.Errorf("list forum topics: %w", err) + } + defer rows.Close() + topics := make([]domain.ChannelForumTopic, 0, limit) + for rows.Next() { + topic, err := scanChannelForumTopic(rows) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + topics = append(topics, s.topicWithViewerCounters(ctx, viewerUserID, filter.ChannelID, topic, member.ReadInboxMaxID, member.AvailableMinID)) + } + if err := rows.Err(); err != nil { + return domain.ChannelForumTopicList{}, err + } + messages, err := s.forumTopicRootMessages(ctx, filter.ChannelID, topics, member.AvailableMinID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) + return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog, Topics: topics, Messages: messages, Count: total}, nil +} + +func (s *ChannelStore) GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + if !channel.Forum { + return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing + } + if len(ids) == 0 { + dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) + return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog}, nil + } + id32, _, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelForumTopicColumns+` +FROM channel_forum_topics +WHERE channel_id = $1 AND NOT deleted AND topic_id > $2 AND topic_id = ANY($3::int[]) +ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC`, channelID, member.AvailableMinID, id32) + if err != nil { + return domain.ChannelForumTopicList{}, fmt.Errorf("get forum topics by id: %w", err) + } + defer rows.Close() + topics := make([]domain.ChannelForumTopic, 0, len(id32)) + for rows.Next() { + topic, err := scanChannelForumTopic(rows) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + topics = append(topics, s.topicWithViewerCounters(ctx, viewerUserID, channelID, topic, member.ReadInboxMaxID, member.AvailableMinID)) + } + if err := rows.Err(); err != nil { + return domain.ChannelForumTopicList{}, err + } + messages, err := s.forumTopicRootMessages(ctx, channelID, topics, member.AvailableMinID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) + return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog, Topics: topics, Messages: messages, Count: len(topics)}, nil +} + +func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { + source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + root, err := s.getChannelMessage(ctx, s.db, filter.ChannelID, filter.RootMessageID) + if err != nil || root.Deleted || root.ID <= member.AvailableMinID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + target := source + availableMinID := member.AvailableMinID + extraChannels := []domain.Channel(nil) + rootID := root.ID + if source.Broadcast { + if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { + return domain.ChannelHistory{Channel: source}, nil + } + linked, err := getChannelByID(ctx, s.db, root.Discussion.ChannelID) + if err != nil { + return domain.ChannelHistory{Channel: source}, nil + } + target = linked + rootID = root.Discussion.MessageID + availableMinID = 0 + if linkedMember, err := s.getChannelMember(ctx, s.db, linked.ID, viewerUserID); err == nil && validateChannelMemberVisible(linkedMember) == nil { + availableMinID = linkedMember.AvailableMinID + } + extraChannels = append(extraChannels, source) + } + targetRoot, err := s.getChannelMessage(ctx, s.db, target.ID, rootID) + if err != nil || targetRoot.Deleted || targetRoot.ID <= availableMinID { + return domain.ChannelHistory{Channel: target, Channels: extraChannels}, nil + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelRepliesLimit { + limit = domain.MaxChannelRepliesLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + count, err := s.countChannelReplies(ctx, target.ID, rootID, availableMinID, filter) + if err != nil { + return domain.ChannelHistory{}, err + } + messages, err := s.queryChannelRepliesPage(ctx, target.ID, rootID, availableMinID, filter, limit) + if err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil { + return domain.ChannelHistory{}, err + } + topics := []domain.ChannelForumTopic(nil) + if target.Forum { + if topic, err := s.getForumTopic(ctx, s.db, target.ID, rootID); err == nil && !topic.Hidden { + topic = s.topicWithViewerCounters(ctx, viewerUserID, target.ID, topic, availableMinID, availableMinID) + topics = append(topics, topic) + } else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) { + return domain.ChannelHistory{}, err + } + } + return domain.ChannelHistory{Channel: target, Channels: extraChannels, Topics: topics, Messages: messages, Count: count}, nil +} + +func (s *ChannelStore) ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit { + limit = domain.MaxChannelUnreadMentionsLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + count, err := s.countChannelUnreadMentions(ctx, viewerUserID, filter, member.AvailableMinID) + if err != nil { + return domain.ChannelHistory{}, err + } + messages, err := s.queryChannelUnreadMentionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit) + if err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelHistory{}, err + } + return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil +} + +func (s *ChannelStore) ReadChannelMentions(ctx context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ReadChannelMentionsResult{}, fmt.Errorf("read channel mentions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ReadChannelMentionsResult{}, fmt.Errorf("begin read channel mentions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelMentionsResult{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch { + limit = domain.MaxChannelReadMentionsBatch + } + cleared, remaining, err := readChannelMentionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit) + if err != nil { + return domain.ReadChannelMentionsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelMentionsResult{}, fmt.Errorf("commit read channel mentions: %w", err) + } + committed = true + offset := 0 + if remaining > 0 { + offset = 1 + } + return domain.ReadChannelMentionsResult{ + Channel: channel, + Cleared: cleared, + Remaining: remaining, + Offset: offset, + ChannelPts: channel.Pts, + }, nil +} + +func (s *ChannelStore) ListChannelUnreadReactions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { + limit = domain.MaxChannelUnreadReactionsLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + count, err := s.countChannelUnreadReactions(ctx, viewerUserID, filter, member.AvailableMinID) + if err != nil { + return domain.ChannelHistory{}, err + } + messages, err := s.queryChannelUnreadReactionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit) + if err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelHistory{}, err + } + return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil +} + +func (s *ChannelStore) ReadChannelReactions(ctx context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ReadChannelReactionsResult{}, fmt.Errorf("read channel reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ReadChannelReactionsResult{}, fmt.Errorf("begin read channel reactions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelReactionsResult{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadReactionsBatch { + limit = domain.MaxChannelReadReactionsBatch + } + cleared, remaining, err := readChannelReactionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit) + if err != nil { + return domain.ReadChannelReactionsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelReactionsResult{}, fmt.Errorf("commit read channel reactions: %w", err) + } + committed = true + offset := 0 + if remaining > 0 { + offset = 1 + } + return domain.ReadChannelReactionsResult{ + Channel: channel, + Cleared: cleared, + Remaining: remaining, + Offset: offset, + ChannelPts: channel.Pts, + }, nil +} + +func (s *ChannelStore) GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { + source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelDiscussionMessage{}, err + } + msg, err := s.getChannelMessage(ctx, s.db, channelID, msgID) + if err != nil || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid + } + result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}} + target := source + targetMsg := msg + if source.Broadcast { + if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 { + return result, nil + } + linked, err := getChannelByID(ctx, s.db, msg.Discussion.ChannelID) + if err != nil { + return result, nil + } + linkedMsg, err := s.getChannelMessage(ctx, s.db, linked.ID, msg.Discussion.MessageID) + if err != nil || linkedMsg.Deleted { + return result, nil + } + target = linked + targetMsg = linkedMsg + result.DiscussionChannel = linked + result.Channels = []domain.Channel{source, linked} + } + messages := []domain.ChannelMessage{targetMsg} + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil { + return domain.ChannelDiscussionMessage{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil { + return domain.ChannelDiscussionMessage{}, err + } + readInbox, readOutbox := s.channelReadWatermarks(ctx, target.ID, viewerUserID) + result.Messages = messages + result.ReadInboxMaxID = readInbox + result.ReadOutboxMaxID = readOutbox + if messages[0].Replies != nil { + result.MaxID = messages[0].Replies.MaxID + } + result.UnreadCount = s.channelThreadUnreadCount(ctx, target.ID, targetMsg.ID, viewerUserID, readInbox) + return result, nil +} + +func (s *ChannelStore) ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { + var lastErr error + for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { + res, err := s.readChannelHistoryOnce(ctx, req) + if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { + return res, err + } + lastErr = err + } + return domain.ReadChannelHistoryResult{}, lastErr +} + +func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { + channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > channel.TopMessageID { + maxID = channel.TopMessageID + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("begin read channel history: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + var previous int + if err := tx.QueryRow(ctx, `SELECT read_inbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID).Scan(&previous); err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err) + } + changed := maxID > previous + var outboxUpdates []domain.ChannelReadOutboxUpdate + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_date = CASE WHEN read_inbox_max_id < $3 THEN $4 ELSE read_inbox_date END, + read_inbox_max_id = GREATEST(read_inbox_max_id, $3), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, maxID, req.Date); err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("update channel member read: %w", err) + } + msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) + if changed { + outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel, msg, req.UserID, previous, maxID) + if err != nil { + return domain.ReadChannelHistoryResult{}, err + } + } + if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, maxID, 0); err != nil { + return domain.ReadChannelHistoryResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("commit read channel history: %w", err) + } + committed = true + dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) + if err != nil { + return domain.ReadChannelHistoryResult{}, err + } + return domain.ReadChannelHistoryResult{ + ChannelID: req.ChannelID, + MaxID: maxID, + StillUnreadCount: dialog.UnreadCount, + Changed: changed, + Pts: channel.Pts, + Dialog: dialog, + OutboxUpdates: outboxUpdates, + }, nil +} + +func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) { + if maxID <= previous { + return nil, nil + } + lowerID := previous + if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages { + lowerID = maxID - domain.MaxChannelReadOutboxScanMessages + } + rows, err := tx.Query(ctx, ` +WITH latest_sender_messages AS ( + SELECT sender_user_id, MAX(id) AS max_id + FROM channel_messages + WHERE channel_id = $1 + AND id > $2 + AND id <= $3 + AND NOT deleted + AND sender_user_id <> $4 + GROUP BY sender_user_id + ORDER BY max_id DESC + LIMIT $5 +) +SELECT sender_user_id, max_id +FROM latest_sender_messages +ORDER BY sender_user_id ASC`, channel.ID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout) + if err != nil { + return nil, fmt.Errorf("list channel read outbox senders: %w", err) + } + defer rows.Close() + type candidate struct { + userID int64 + maxID int + } + candidates := make([]candidate, 0, domain.MaxChannelReadOutboxFanout) + for rows.Next() { + var item candidate + if err := rows.Scan(&item.userID, &item.maxID); err != nil { + return nil, err + } + candidates = append(candidates, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]domain.ChannelReadOutboxUpdate, 0, len(candidates)) + for _, item := range candidates { + var readOutboxMaxID, readInboxMaxID int + err := tx.QueryRow(ctx, ` +UPDATE channel_members +SET read_outbox_max_id = GREATEST(read_outbox_max_id, $3), + updated_at = now() +WHERE channel_id = $1 + AND user_id = $2 + AND status = 'active' + AND read_outbox_max_id < $3 +RETURNING read_outbox_max_id, read_inbox_max_id`, channel.ID, item.userID, item.maxID).Scan(&readOutboxMaxID, &readInboxMaxID) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return nil, fmt.Errorf("update channel sender read outbox: %w", err) + } + if err := upsertChannelDialogTx(ctx, tx, item.userID, channel, top, readInboxMaxID, readOutboxMaxID); err != nil { + return nil, err + } + out = append(out, domain.ChannelReadOutboxUpdate{UserID: item.userID, MaxID: readOutboxMaxID}) + } + return out, nil +} + +func (s *ChannelStore) ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + if req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid + } + msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid + } + result := domain.ChannelReadParticipantsResult{Channel: channel, Message: msg} + if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants { + return result, nil + } + if req.Date > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= req.Date { + return result, nil + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadParticipants { + limit = domain.MaxChannelReadParticipants + } + rows, err := s.db.Query(ctx, ` +SELECT user_id, read_inbox_date +FROM channel_members +WHERE channel_id = $1 + AND status = 'active' + AND user_id <> $2 + AND available_min_id < $3 + AND read_inbox_max_id >= $3 + AND read_inbox_date > 0 + AND NOT COALESCE((banned_rights->>'ViewMessages')::boolean, false) +ORDER BY read_inbox_date ASC, user_id ASC +LIMIT $4`, req.ChannelID, req.UserID, req.MessageID, limit) + if err != nil { + return domain.ChannelReadParticipantsResult{}, fmt.Errorf("list channel read participants: %w", err) + } + defer rows.Close() + for rows.Next() { + var item domain.ChannelReadParticipant + if err := rows.Scan(&item.UserID, &item.Date); err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + result.Participants = append(result.Participants, item) + } + if err := rows.Err(); err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + return result, nil +} + +func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { + channel, member, preview, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelDifference{}, err + } + if req.Pts < 0 || req.Pts > channel.Pts { + return domain.ChannelDifference{}, domain.ErrPersistentTimestamp + } + if !preview && member.AvailableMinPts > req.Pts { + req.Pts = minInt(member.AvailableMinPts, channel.Pts) + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { + limit = domain.MaxChannelDifferenceLimit + } + if channel.Pts-req.Pts > limit { + args := []any{req.ChannelID} + where := "channel_id = $1 AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + args = append(args, domain.MaxChannelDifferenceTooLongMessages) + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelDifference{}, fmt.Errorf("list channel too long messages: %w", err) + } + defer rows.Close() + diff := domain.ChannelDifference{ + Channel: channel, + Self: member, + Pts: channel.Pts, + Final: true, + TooLong: true, + Timeout: 30, + } + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelDifference{}, err + } + diff.NewMessages = append(diff.NewMessages, msg) + } + if err := rows.Err(); err != nil { + return domain.ChannelDifference{}, err + } + if preview { + diff.Dialog = previewChannelDialog(req.UserID, channel, member) + } else { + dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) + if err != nil { + return domain.ChannelDifference{}, err + } + diff.Dialog = dialog + } + return diff, nil + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text +FROM channel_update_events +WHERE channel_id = $1 AND pts > $2 +ORDER BY pts ASC +LIMIT $3`, req.ChannelID, req.Pts, limit) + if err != nil { + return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err) + } + defer rows.Close() + diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30} + userRefs := make(map[int64]struct{}) + channelRefs := make(map[int64]struct{}) + lastPts := req.Pts + for rows.Next() { + event, messageID, err := scanChannelEvent(rows) + if err != nil { + return domain.ChannelDifference{}, err + } + lastPts = event.Pts + if messageID != 0 && event.Message.ID == 0 { + msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID) + if err != nil { + return domain.ChannelDifference{}, err + } + event.Message = msg + } + visibleEvent, ok := domain.FilterChannelUpdateEventForAvailableMinID(event, member.AvailableMinID) + if !ok { + continue + } + event = visibleEvent + if preview && event.Type == domain.ChannelUpdateParticipant { + continue + } + collectChannelEventRefs(event, req.ChannelID, userRefs, channelRefs) + diff.Events = append(diff.Events, event) + diff.Pts = event.Pts + switch event.Type { + case domain.ChannelUpdateNewMessage: + diff.NewMessages = append(diff.NewMessages, event.Message) + default: + diff.OtherUpdates = append(diff.OtherUpdates, event) + } + } + if err := rows.Err(); err != nil { + return domain.ChannelDifference{}, err + } + if len(diff.Events) == 0 { + diff.Pts = lastPts + } else if lastPts > diff.Pts { + diff.Pts = lastPts + } + users, err := listUsersByIDs(ctx, s.db, mapKeysInt64(userRefs)) + if err != nil { + return domain.ChannelDifference{}, err + } + channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) + if err != nil { + return domain.ChannelDifference{}, err + } + diff.Users = users + diff.Channels = channels + if preview { + diff.Dialog = previewChannelDialog(req.UserID, channel, member) + } else { + dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) + if err != nil { + return domain.ChannelDifference{}, err + } + diff.Dialog = dialog + } + diff.Final = lastPts >= channel.Pts + return diff, nil +} + +func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { + if userID == 0 || afterChannelID < 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id +FROM channel_members +WHERE user_id = $1 + AND status = 'active' + AND channel_id > $2 +ORDER BY channel_id +LIMIT $3`, userID, afterChannelID, limit) + if err != nil { + return nil, fmt.Errorf("list active channel ids for user: %w", err) + } + defer rows.Close() + out := make([]int64, 0, limit) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return nil, err + } + out = append(out, channelID) + } + return out, rows.Err() +} + +func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { + if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { + return nil, err + } + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + rows, err := s.db.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' ORDER BY user_id LIMIT $2`, channelID, limit) + if err != nil { + return nil, fmt.Errorf("list active channel members: %w", err) + } + defer rows.Close() + out := make([]int64, 0, limit) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return nil, err + } + out = append(out, userID) + } + return out, rows.Err() +} + +func (s *ChannelStore) ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) { + if channelID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + rows, err := s.db.Query(ctx, ` +SELECT user_id +FROM channel_members +WHERE channel_id = $1 + AND status = 'active' + AND ( + role = 'creator' OR + (role = 'admin' AND ( + (admin_rights->>'InviteUsers')::boolean IS TRUE OR + (admin_rights->>'ChangeInfo')::boolean IS TRUE + )) + ) +ORDER BY user_id +LIMIT $2`, channelID, limit) + if err != nil { + return nil, fmt.Errorf("list channel invite admin members: %w", err) + } + defer rows.Close() + out := make([]int64, 0, limit) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return nil, err + } + out = append(out, userID) + } + return out, rows.Err() +} + +func (s *ChannelStore) FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) { + if channelID == 0 || len(userIDs) == 0 { + return nil, nil + } + candidates := uniqueChannelUserIDs(userIDs, 0) + if len(candidates) == 0 { + return nil, nil + } + out := make([]int64, 0, len(candidates)) + for start := 0; start < len(candidates); start += channelMemberFilterBatch { + end := start + channelMemberFilterBatch + if end > len(candidates) { + end = len(candidates) + } + rows, err := s.db.Query(ctx, ` +SELECT user_id +FROM channel_members +WHERE channel_id = $1 + AND user_id = ANY($2::bigint[]) + AND status = 'active' +ORDER BY user_id`, channelID, candidates[start:end]) + if err != nil { + return nil, fmt.Errorf("filter active channel members: %w", err) + } + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + rows.Close() + return nil, err + } + out = append(out, userID) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out, nil +} + +func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) { + var pts int + err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts) + return pts, err +} + +func (s *ChannelStore) MaxChannelMessageID(ctx context.Context, channelID int64) (int, error) { + var id int + err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id) + return id, err +} + +const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), +c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam, c.linked_chat_id, c.slowmode_seconds, c.default_banned_rights::text, +c.available_reactions::text, c.color_set, c.color, c.color_background_emoji_id, c.profile_color_set, c.profile_color, c.profile_color_background_emoji_id, c.emoji_status_document_id, c.emoji_status_until, +c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, +c.ttl_period, c.date, c.deleted, c.photo_id, c.photo_dc_id, c.photo_stripped` + +const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, +send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, +entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, +fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text` + +const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, +title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id, +read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, +unread_poll_votes_count` + +type rowScanner interface { + Scan(dest ...any) error +} + +func (s *ChannelStore) getChannelForMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, error) { + row := db.QueryRow(ctx, ` +SELECT `+channelColumns+`, + m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, + m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, + m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date +FROM channels c +JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 +WHERE c.id = $2 AND NOT c.deleted`, viewerUserID, channelID) + ch, member, err := scanChannelWithMember(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelPrivate + } + return domain.Channel{}, domain.ChannelMember{}, err + } + if err := validateChannelMemberVisible(member); err != nil { + return domain.Channel{}, domain.ChannelMember{}, err + } + return ch, member, nil +} + +func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) { + ch, member, err := s.getChannelForMember(ctx, db, viewerUserID, channelID) + if err == nil { + return ch, member, false, nil + } + if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + ch, err = getChannelByID(ctx, db, channelID) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + if !publicPreviewableChannel(ch) { + return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate + } + member, err = getPublicPreviewMember(ctx, db, viewerUserID, ch) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + return ch, member, true, nil +} + +func getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) { + member, err := scanChannelMember(db.QueryRow(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, + admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, + read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +FROM channel_members +WHERE channel_id = $1 AND user_id = $2`, ch.ID, viewerUserID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return publicPreviewMember(ch, viewerUserID, domain.ChannelMember{}, false), nil + } + return domain.ChannelMember{}, err + } + if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages { + return domain.ChannelMember{}, domain.ErrChannelUserBanned + } + return publicPreviewMember(ch, viewerUserID, member, true), nil +} + +func getChannelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) { + ch, err := scanChannel(db.QueryRow(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = $1 AND NOT c.deleted`, channelID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, domain.ErrChannelInvalid + } + return ch, err +} + +func listChannelsByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.Channel, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := db.Query(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = ANY($1::bigint[]) AND NOT c.deleted ORDER BY c.id ASC`, ids) + if err != nil { + return nil, fmt.Errorf("list channels by ids: %w", err) + } + defer rows.Close() + out := make([]domain.Channel, 0, len(ids)) + for rows.Next() { + ch, err := scanChannel(rows) + if err != nil { + return nil, err + } + out = append(out, ch) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func listUsersByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.User, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := db.Query(ctx, ` +SELECT id, access_hash, phone, first_name, last_name, username, country_code, verified, support +FROM users +WHERE id = ANY($1::bigint[]) +ORDER BY id ASC`, ids) + if err != nil { + return nil, fmt.Errorf("list users by ids: %w", err) + } + defer rows.Close() + out := make([]domain.User, 0, len(ids)) + for rows.Next() { + var u domain.User + if err := rows.Scan(&u.ID, &u.AccessHash, &u.Phone, &u.FirstName, &u.LastName, &u.Username, &u.CountryCode, &u.Verified, &u.Support); err != nil { + return nil, err + } + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) getChannelMember(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) { + row := db.QueryRow(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, + rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +FROM channel_members +WHERE channel_id = $1 AND user_id = $2`, channelID, userID) + member, err := scanChannelMember(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelMember{}, domain.ErrChannelPrivate + } + return member, err +} + +func (s *ChannelStore) getChannelDialog(ctx context.Context, db sqlcgen.DBTX, userID int64, channel domain.Channel) (domain.ChannelDialog, error) { + dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID} + var defaultSendAsType sql.NullString + var defaultSendAsID sql.NullInt64 + visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" + visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" + visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) + err := db.QueryRow(ctx, ` +SELECT `+visibleTopID+`, + COALESCE(d.top_message_date, c.date), + COALESCE(d.folder_id, 0), + `+visibleReadInbox+`, + COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), + `+visibleUnreadCount+`, + COALESCE(d.pinned, false), + COALESCE(d.pinned_order, 0), + COALESCE(d.unread_mark, m.unread_mark), + COALESCE(d.unread_mentions_count, 0), + COALESCE(d.unread_reactions_count, 0), + COALESCE(d.view_forum_as_messages, false), + d.default_send_as_peer_type, + d.default_send_as_peer_id +FROM channels c +JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE c.id = $2`, userID, channel.ID).Scan( + &dialog.TopMessageID, + &dialog.TopMessageDate, + &dialog.FolderID, + &dialog.ReadInboxMaxID, + &dialog.ReadOutboxMaxID, + &dialog.UnreadCount, + &dialog.Pinned, + &dialog.PinnedOrder, + &dialog.UnreadMark, + &dialog.UnreadMentions, + &dialog.UnreadReactions, + &dialog.ViewForumAsMessages, + &defaultSendAsType, + &defaultSendAsID, + ) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err) + } + if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 { + dialog.DefaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64} + } + if dialog.TopMessageID != 0 { + if msg, err := s.getChannelMessage(ctx, db, channel.ID, dialog.TopMessageID); err == nil { + dialog.TopMessageDate = msg.Date + } + } + return dialog, nil +} + +func (s *ChannelStore) getChannelMessage(ctx context.Context, db sqlcgen.DBTX, channelID int64, id int) (domain.ChannelMessage, error) { + if channelID == 0 || id == 0 { + return domain.ChannelMessage{}, pgx.ErrNoRows + } + msg, err := scanChannelMessage(db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, id)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelMessage{}, domain.ErrMessageIDInvalid + } + return msg, err +} + +func (s *ChannelStore) getForumTopic(ctx context.Context, db sqlcgen.DBTX, channelID int64, topicID int) (domain.ChannelForumTopic, error) { + if channelID == 0 || topicID == 0 { + return domain.ChannelForumTopic{}, domain.ErrMessageIDInvalid + } + topic, err := scanChannelForumTopic(db.QueryRow(ctx, ` +SELECT `+channelForumTopicColumns+` +FROM channel_forum_topics +WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, channelID, topicID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelForumTopic{}, domain.ErrMessageIDInvalid + } + return topic, err +} + +func (s *ChannelStore) forumTopicRootMessages(ctx context.Context, channelID int64, topics []domain.ChannelForumTopic, availableMinID int) ([]domain.ChannelMessage, error) { + if len(topics) == 0 { + return nil, nil + } + ids := make([]int, 0, len(topics)) + seen := make(map[int]struct{}, len(topics)) + for _, topic := range topics { + if topic.TopMessageID <= 0 { + continue + } + if _, ok := seen[topic.TopMessageID]; ok { + continue + } + seen[topic.TopMessageID] = struct{}{} + ids = append(ids, topic.TopMessageID) + } + id32, _, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return nil, err + } + if len(id32) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE channel_id = $1 AND id = ANY($2::int[]) AND id > $3 AND NOT deleted +ORDER BY id DESC`, channelID, id32, availableMinID) + if err != nil { + return nil, fmt.Errorf("list forum topic root messages: %w", err) + } + defer rows.Close() + messages := make([]domain.ChannelMessage, 0, len(id32)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return nil, err + } + messages = append(messages, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return messages, nil +} + +func (s *ChannelStore) nextForumTopicPinnedOrder(ctx context.Context, channelID int64) (int, error) { + var maxOrder int + if err := s.db.QueryRow(ctx, ` +SELECT COALESCE(MAX(pinned_order), 0)::int +FROM channel_forum_topics +WHERE channel_id = $1 AND pinned AND NOT deleted`, channelID).Scan(&maxOrder); err != nil { + return 0, fmt.Errorf("next forum topic pinned order: %w", err) + } + return maxOrder + 1, nil +} + +type channelReplyStatKey struct { + channelID int64 + rootID int +} + +type channelReactionMessageKey struct { + channelID int64 + messageID int +} + +type channelReactionCursor struct { + date int + userID int64 + emoticon string +} + +func emptyChannelMessageReactions(channel domain.Channel) domain.ChannelMessageReactions { + return domain.ChannelMessageReactions{ + CanSeeList: !channel.Broadcast || channel.Megagroup, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } +} + +func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) error { + if len(messages) == 0 { + return nil + } + channelsByID := make(map[int64]domain.Channel, len(channels)) + for _, ch := range channels { + if ch.ID != 0 { + channelsByID[ch.ID] = ch + } + } + indexes := make(map[channelReactionMessageKey][]int) + idsByChannel := make(map[int64][]int32) + for i := range messages { + if messages[i].ChannelID == 0 || messages[i].ID <= 0 { + continue + } + key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID} + if _, ok := indexes[key]; !ok { + idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID)) + } + indexes[key] = append(indexes[key], i) + } + for channelID, ids := range idsByChannel { + ch := channelsByID[channelID] + if ch.ID == 0 { + var err error + ch, err = getChannelByID(ctx, db, channelID) + if err != nil { + return err + } + channelsByID[channelID] = ch + } + rows, err := db.Query(ctx, ` +SELECT message_id, reaction_type, reaction_value, COUNT(*)::int, + COALESCE(MAX(CASE WHEN reacted_user_id = $3 THEN chosen_order ELSE 0 END), 0)::int, + COALESCE(MAX(reaction_date), 0)::int +FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = ANY($2::int[]) +GROUP BY message_id, reaction_type, reaction_value +ORDER BY message_id ASC, COUNT(*) DESC, COALESCE(MAX(reaction_date), 0) DESC, reaction_value ASC`, channelID, ids, viewerUserID) + if err != nil { + return fmt.Errorf("load channel message reaction counts: %w", err) + } + for rows.Next() { + var msgID int + var reactionType, reactionValue string + var count, chosenOrder, latestDate int + if err := rows.Scan(&msgID, &reactionType, &reactionValue, &count, &chosenOrder, &latestDate); err != nil { + rows.Close() + return err + } + _ = latestDate + key := channelReactionMessageKey{channelID: channelID, messageID: msgID} + for _, idx := range indexes[key] { + if messages[idx].Reactions == nil { + reactions := emptyChannelMessageReactions(ch) + messages[idx].Reactions = &reactions + } + messages[idx].Reactions.Results = append(messages[idx].Reactions.Results, domain.ChannelMessageReactionCount{ + Reaction: domain.MessageReaction{ + Type: domain.MessageReactionType(reactionType), + Emoticon: reactionValue, + }, + Count: count, + ChosenOrder: chosenOrder, + }) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + rows, err = db.Query(ctx, ` +SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, + big, unread, chosen_order, reaction_date +FROM ( + SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, + big, unread, chosen_order, reaction_date, + row_number() OVER ( + PARTITION BY message_id + ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_value ASC + ) AS rn + FROM channel_message_reactions + WHERE channel_id = $1 AND message_id = ANY($2::int[]) +) ranked +WHERE rn <= $3 +ORDER BY message_id ASC, reaction_date DESC, reacted_user_id DESC, reaction_value ASC`, channelID, ids, domain.MaxChannelMessageReactionRecent) + if err != nil { + return fmt.Errorf("load channel message recent reactions: %w", err) + } + for rows.Next() { + row, err := scanChannelMessagePeerReaction(rows, viewerUserID) + if err != nil { + rows.Close() + return err + } + key := channelReactionMessageKey{channelID: row.ChannelID, messageID: row.MessageID} + for _, idx := range indexes[key] { + if messages[idx].Reactions == nil { + reactions := emptyChannelMessageReactions(ch) + messages[idx].Reactions = &reactions + } + messages[idx].Reactions.Recent = append(messages[idx].Reactions.Recent, row) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + } + return nil +} + +func channelReactionOffset(row domain.ChannelMessagePeerReaction) string { + return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + row.Reaction.Emoticon +} + +func parseChannelReactionOffset(offset string) (channelReactionCursor, bool) { + parts := strings.SplitN(offset, ":", 3) + if len(parts) != 3 { + return channelReactionCursor{}, false + } + date, err := strconv.Atoi(parts[0]) + if err != nil || date < 0 { + return channelReactionCursor{}, false + } + userID, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || userID < 0 { + return channelReactionCursor{}, false + } + return channelReactionCursor{date: date, userID: userID, emoticon: parts[2]}, true +} + +func (s *ChannelStore) populateChannelMessageReplies(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channel domain.Channel, messages []domain.ChannelMessage) error { + if len(messages) == 0 || channel.ID == 0 { + return nil + } + indexes := make(map[channelReplyStatKey][]int) + rootsByChannel := make(map[int64][]int32) + readMaxByChannel := make(map[int64]int) + for i := range messages { + targetChannelID := channel.ID + rootID := messages[i].ID + replies := &domain.ChannelMessageReplies{} + if messages[i].Discussion != nil && messages[i].Discussion.ChannelID != 0 && messages[i].Discussion.MessageID != 0 { + targetChannelID = messages[i].Discussion.ChannelID + rootID = messages[i].Discussion.MessageID + replies.Comments = true + replies.ChannelID = messages[i].Discussion.ChannelID + } else if channel.Broadcast && channel.LinkedChatID != 0 && messages[i].Post { + replies.Comments = true + replies.ChannelID = channel.LinkedChatID + } + if _, ok := readMaxByChannel[targetChannelID]; !ok { + readInbox, _ := s.channelReadWatermarks(ctx, targetChannelID, viewerUserID) + readMaxByChannel[targetChannelID] = readInbox + } + replies.ReadMaxID = readMaxByChannel[targetChannelID] + key := channelReplyStatKey{channelID: targetChannelID, rootID: rootID} + if _, ok := indexes[key]; !ok { + rootsByChannel[targetChannelID] = append(rootsByChannel[targetChannelID], int32(rootID)) + } + indexes[key] = append(indexes[key], i) + if replies.Comments { + messages[i].Replies = replies + } + } + for channelID, roots := range rootsByChannel { + rows, err := db.Query(ctx, ` +SELECT reply_to_top_id, COUNT(*)::int, COALESCE(MAX(id), 0)::int, COALESCE((array_agg(pts ORDER BY id DESC))[1], 0)::int +FROM channel_messages +WHERE channel_id = $1 AND reply_to_top_id = ANY($2::int[]) AND NOT deleted +GROUP BY reply_to_top_id`, channelID, roots) + if err != nil { + return fmt.Errorf("load channel reply stats: %w", err) + } + for rows.Next() { + var rootID, count, maxID, repliesPts int + if err := rows.Scan(&rootID, &count, &maxID, &repliesPts); err != nil { + rows.Close() + return err + } + for _, idx := range indexes[channelReplyStatKey{channelID: channelID, rootID: rootID}] { + replies := messages[idx].Replies + if replies == nil { + replies = &domain.ChannelMessageReplies{ReadMaxID: readMaxByChannel[channelID]} + } + replies.Replies = count + replies.MaxID = maxID + replies.RepliesPts = repliesPts + messages[idx].Replies = replies + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + } + return nil +} + +func (s *ChannelStore) channelReadWatermarks(ctx context.Context, channelID, userID int64) (int, int) { + var inbox, outbox int + _ = s.db.QueryRow(ctx, `SELECT read_inbox_max_id, read_outbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, userID).Scan(&inbox, &outbox) + return inbox, outbox +} + +func (s *ChannelStore) channelThreadUnreadCount(ctx context.Context, channelID int64, rootID int, viewerUserID int64, readMaxID int) int { + var count int + _ = s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_messages +WHERE channel_id = $1 AND reply_to_top_id = $2 AND id > $3 AND sender_user_id <> $4 AND NOT deleted`, channelID, rootID, readMaxID, viewerUserID).Scan(&count) + return count +} + +func countChannelUnreadMessages(ctx context.Context, db sqlcgen.DBTX, userID, channelID int64, readMaxID, topID int) (int, error) { + if userID == 0 || channelID == 0 || topID <= readMaxID { + return 0, nil + } + var count int + if err := db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_messages +WHERE channel_id = $1 + AND id > $2 + AND id <= $3 + AND sender_user_id <> $4 + AND NOT deleted`, channelID, readMaxID, topID, userID).Scan(&count); err != nil { + return 0, fmt.Errorf("count channel unread messages: %w", err) + } + return count, nil +} + +func (s *ChannelStore) topicWithViewerCounters(ctx context.Context, viewerUserID, channelID int64, topic domain.ChannelForumTopic, readMaxID, availableMinID int) domain.ChannelForumTopic { + topic.UnreadCount = s.channelThreadUnreadCount(ctx, channelID, topic.TopicID, viewerUserID, readMaxID) + topic.UnreadMentionsCount = s.countChannelUnreadMentionsForTop(ctx, viewerUserID, channelID, topic.TopicID) + topic.UnreadReactionsCount = s.countChannelUnreadReactionsForTop(ctx, viewerUserID, channelID, topic.TopicID, availableMinID) + return topic +} + +func (s *ChannelStore) countChannelUnreadMentionsForTop(ctx context.Context, userID, channelID int64, topMsgID int) int { + var count int + _ = s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_unread_mentions +WHERE user_id = $1 AND channel_id = $2 AND top_message_id = $3`, userID, channelID, topMsgID).Scan(&count) + return count +} + +func (s *ChannelStore) countChannelUnreadReactionsForTop(ctx context.Context, userID, channelID int64, topMsgID, availableMinID int) int { + var count int + _ = s.db.QueryRow(ctx, ` +SELECT COUNT(DISTINCT r.message_id)::int +FROM channel_message_reactions r +JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id +WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > $4 + AND NOT cm.deleted + AND (cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3)`, userID, channelID, topMsgID, availableMinID).Scan(&count) + return count +} + +func (s *ChannelStore) countChannelReplies(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter) (int, error) { + where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) + var count int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_messages WHERE `+where, args...).Scan(&count); err != nil { + return 0, fmt.Errorf("count channel replies: %w", err) + } + return count, nil +} + +func (s *ChannelStore) countChannelUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (int, error) { + where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_unread_mentions um +JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id +WHERE `+where, args...).Scan(&count); err != nil { + return 0, fmt.Errorf("count channel unread mentions: %w", err) + } + return count, nil +} + +func (s *ChannelStore) queryChannelUnreadMentionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, limit) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + forward, err := s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, forwardLimit) + if err != nil { + return nil, err + } + backward, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true) + if err != nil { + return nil, err + } + out := append(forward, backward...) + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + items, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, limit+start, false) + if err != nil || start >= len(items) { + return nil, err + } + return items[start:], nil + } +} + +func (s *ChannelStore) queryChannelUnreadMentionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) + where, args = appendChannelUnreadMentionBackwardOffset(where, args, filter, includeOffset) + args = append(args, limit) + return s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "DESC") +} + +func (s *ChannelStore) queryChannelUnreadMentionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) + where, args = appendChannelUnreadMentionForwardOffset(where, args, filter) + args = append(args, limit) + out, err := s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "ASC") + if err != nil { + return nil, err + } + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil +} + +func (s *ChannelStore) queryChannelUnreadMentions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) { + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE channel_id = $2 + AND id = ANY(ARRAY( + SELECT cm.id + FROM channel_unread_mentions um + JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id + WHERE `+where+` + ORDER BY cm.id `+order+` + LIMIT $`+fmt.Sprint(len(args))+` + )::int[]) +ORDER BY id `+order, args...) + _ = channelID + if err != nil { + return nil, fmt.Errorf("list channel unread mentions: %w", err) + } + defer rows.Close() + out := make([]domain.ChannelMessage, 0) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return nil, err + } + out = append(out, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func channelUnreadMentionBaseWhere(userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (string, []any) { + args := []any{userID, filter.ChannelID} + where := "um.user_id = $1 AND um.channel_id = $2 AND NOT cm.deleted" + if availableMinID > 0 { + args = append(args, availableMinID) + where += fmt.Sprintf(" AND cm.id > $%d", len(args)) + } + if filter.TopMsgID > 0 { + args = append(args, filter.TopMsgID) + where += fmt.Sprintf(" AND um.top_message_id = $%d", len(args)) + } + if filter.MaxID > 0 { + args = append(args, filter.MaxID) + where += fmt.Sprintf(" AND cm.id < $%d", len(args)) + } + if filter.MinID > 0 { + args = append(args, filter.MinID) + where += fmt.Sprintf(" AND cm.id > $%d", len(args)) + } + return where, args +} + +func appendChannelUnreadMentionBackwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter, include bool) (string, []any) { + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + if include { + return where + fmt.Sprintf(" AND cm.message_date <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND cm.message_date < $%d", len(args)), args + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + if include { + return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args + } + return where, args +} + +func appendChannelUnreadMentionForwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter) (string, []any) { + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + return where + fmt.Sprintf(" AND cm.message_date >= $%d", len(args)), args + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args + } + return where, args +} + +func (s *ChannelStore) countChannelUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID int) (int, error) { + where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(DISTINCT cm.id)::int +FROM channel_message_reactions r +JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id +WHERE `+where, args...).Scan(&count); err != nil { + return 0, fmt.Errorf("count channel unread reactions: %w", err) + } + return count, nil +} + +func (s *ChannelStore) queryChannelUnreadReactionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return s.queryChannelUnreadReactionsForward(ctx, userID, filter, availableMinID, limit) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + forward, err := s.queryChannelUnreadReactionsForward(ctx, userID, filter, availableMinID, forwardLimit) + if err != nil { + return nil, err + } + backward, err := s.queryChannelUnreadReactionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true) + if err != nil { + return nil, err + } + out := append(forward, backward...) + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + items, err := s.queryChannelUnreadReactionsBackward(ctx, userID, filter, availableMinID, limit+start, false) + if err != nil || start >= len(items) { + return nil, err + } + return items[start:], nil + } +} + +func (s *ChannelStore) queryChannelUnreadReactionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) + where, args = appendChannelUnreadReactionBackwardOffset(where, args, filter, includeOffset) + args = append(args, limit) + return s.queryChannelUnreadReactions(ctx, filter.ChannelID, where, args, "DESC") +} + +func (s *ChannelStore) queryChannelUnreadReactionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) + where, args = appendChannelUnreadReactionForwardOffset(where, args, filter) + args = append(args, limit) + out, err := s.queryChannelUnreadReactions(ctx, filter.ChannelID, where, args, "ASC") + if err != nil { + return nil, err + } + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil +} + +func (s *ChannelStore) queryChannelUnreadReactions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) { + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE channel_id = $2 + AND id = ANY(ARRAY( + SELECT DISTINCT cm.id + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + WHERE `+where+` + ORDER BY cm.id `+order+` + LIMIT $`+fmt.Sprint(len(args))+` + )::int[]) +ORDER BY id `+order, args...) + _ = channelID + if err != nil { + return nil, fmt.Errorf("list channel unread reactions: %w", err) + } + defer rows.Close() + out := make([]domain.ChannelMessage, 0) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return nil, err + } + out = append(out, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func channelUnreadReactionBaseWhere(userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID int) (string, []any) { + args := []any{userID, filter.ChannelID} + where := "r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND NOT cm.deleted" + if availableMinID > 0 { + args = append(args, availableMinID) + where += fmt.Sprintf(" AND cm.id > $%d", len(args)) + } + if filter.TopMsgID > 0 { + args = append(args, filter.TopMsgID) + where += fmt.Sprintf(" AND (cm.id = $%d OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $%d)", len(args), len(args)) + } + if filter.MaxID > 0 { + args = append(args, filter.MaxID) + where += fmt.Sprintf(" AND cm.id < $%d", len(args)) + } + if filter.MinID > 0 { + args = append(args, filter.MinID) + where += fmt.Sprintf(" AND cm.id > $%d", len(args)) + } + return where, args +} + +func appendChannelUnreadReactionBackwardOffset(where string, args []any, filter domain.ChannelUnreadReactionsFilter, include bool) (string, []any) { + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + if include { + return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args + } + return where, args +} + +func appendChannelUnreadReactionForwardOffset(where string, args []any, filter domain.ChannelUnreadReactionsFilter) (string, []any) { + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args + } + return where, args +} + +func (s *ChannelStore) queryChannelRepliesPage(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return s.queryChannelRepliesForward(ctx, channelID, rootID, availableMinID, filter, limit) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + forward, err := s.queryChannelRepliesForward(ctx, channelID, rootID, availableMinID, filter, forwardLimit) + if err != nil { + return nil, err + } + backward, err := s.queryChannelRepliesBackward(ctx, channelID, rootID, availableMinID, filter, backwardLimit, true) + if err != nil { + return nil, err + } + out := append(forward, backward...) + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + items, err := s.queryChannelRepliesBackward(ctx, channelID, rootID, availableMinID, filter, limit+start, false) + if err != nil || start >= len(items) { + return nil, err + } + return items[start:], nil + } +} + +func (s *ChannelStore) queryChannelRepliesBackward(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) + where, args = appendChannelRepliesBackwardOffset(where, args, filter, includeOffset) + args = append(args, limit) + return s.queryChannelReplies(ctx, where, args, "DESC") +} + +func (s *ChannelStore) queryChannelRepliesForward(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) + where, args = appendChannelRepliesForwardOffset(where, args, filter) + args = append(args, limit) + out, err := s.queryChannelReplies(ctx, where, args, "ASC") + if err != nil { + return nil, err + } + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil +} + +func (s *ChannelStore) queryChannelReplies(ctx context.Context, where string, args []any, order string) ([]domain.ChannelMessage, error) { + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id `+order+` +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return nil, fmt.Errorf("list channel replies: %w", err) + } + defer rows.Close() + out := make([]domain.ChannelMessage, 0) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return nil, err + } + out = append(out, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func channelRepliesBaseWhere(channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter) (string, []any) { + args := []any{channelID, rootID} + where := "channel_id = $1 AND reply_to_top_id = $2 AND NOT deleted" + if availableMinID > 0 { + args = append(args, availableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + if filter.MaxID > 0 { + args = append(args, filter.MaxID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } + if filter.MinID > 0 { + args = append(args, filter.MinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + return where, args +} + +func appendChannelRepliesBackwardOffset(where string, args []any, filter domain.ChannelRepliesFilter, include bool) (string, []any) { + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + if include { + return where + fmt.Sprintf(" AND message_date <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND message_date < $%d", len(args)), args + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + if include { + return where + fmt.Sprintf(" AND id <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND id < $%d", len(args)), args + } + return where, args +} + +func appendChannelRepliesForwardOffset(where string, args []any, filter domain.ChannelRepliesFilter) (string, []any) { + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + return where + fmt.Sprintf(" AND message_date >= $%d", len(args)), args + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + return where + fmt.Sprintf(" AND id > $%d", len(args)), args + } + return where + " AND false", args +} + +type messageHistoryLoad int + +const ( + messageHistoryLoadBackward messageHistoryLoad = iota + messageHistoryLoadForward + messageHistoryLoadAround +) + +func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad { + if addOffset >= 0 { + return messageHistoryLoadBackward + } + if addOffset+limit > 0 { + return messageHistoryLoadAround + } + return messageHistoryLoadForward +} + +func channelMessageLess(a, b domain.ChannelMessage) bool { + if a.Date != b.Date { + return a.Date > b.Date + } + return a.ID > b.ID +} + +func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX, req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { + if req.ReplyTo == nil { + return nil, nil + } + if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { + return nil, err + } + peer := req.ReplyTo.Peer + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} + if peer.ID == 0 { + peer = channelPeer + } + if peer != channelPeer { + return nil, domain.ErrReplyMessageIDInvalid + } + if req.ReplyTo.MessageID == 0 { + if req.ReplyTo.TopMessageID <= 0 || !channel.Forum { + return nil, domain.ErrReplyMessageIDInvalid + } + topic, err := s.getForumTopic(ctx, db, req.ChannelID, req.ReplyTo.TopMessageID) + if err != nil { + return nil, domain.ErrReplyMessageIDInvalid + } + if topic.Hidden { + return nil, domain.ErrReplyMessageIDInvalid + } + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + return nil, domain.ErrChannelWriteForbidden + } + reply := cloneMessageReply(req.ReplyTo) + reply.MessageID = 0 + reply.Peer = channelPeer + reply.TopMessageID = topic.TopicID + reply.ForumTopic = true + return reply, nil + } + target, err := s.getChannelMessage(ctx, db, req.ChannelID, req.ReplyTo.MessageID) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) || errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrReplyMessageIDInvalid + } + return nil, err + } + if target.Deleted || target.ID <= member.AvailableMinID { + return nil, domain.ErrReplyMessageIDInvalid + } + reply := cloneMessageReply(req.ReplyTo) + reply.MessageID = target.ID + reply.Peer = channelPeer + reply.TopMessageID = target.ID + if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 { + reply.TopMessageID = target.ReplyTo.TopMessageID + } + if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID { + return nil, domain.ErrReplyMessageIDInvalid + } + if channel.Forum && reply.TopMessageID > 0 { + if topic, err := s.getForumTopic(ctx, db, req.ChannelID, reply.TopMessageID); err == nil && !topic.Hidden { + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + return nil, domain.ErrChannelWriteForbidden + } + reply.ForumTopic = true + } else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, err + } + } + return reply, nil +} + +func (s *ChannelStore) duplicateChannelMessage(ctx context.Context, channelID, userID, randomID int64) (domain.SendChannelMessageResult, bool, error) { + row := s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND sender_user_id = $2 AND random_id = $3`, channelID, userID, randomID) + msg, err := scanChannelMessage(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SendChannelMessageResult{}, false, nil + } + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + channel, err := getChannelByID(ctx, s.db, channelID) + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + event, err := s.eventForChannelMessage(ctx, channelID, msg.ID) + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + if event.Message.ID != 0 { + msg = event.Message + } + return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Duplicate: true}, true, nil +} + +func (s *ChannelStore) eventForChannelMessage(ctx context.Context, channelID int64, messageID int) (domain.ChannelUpdateEvent, error) { + row := s.db.QueryRow(ctx, ` +SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text +FROM channel_update_events +WHERE channel_id = $1 AND message_id = $2 AND event_type = $3 +ORDER BY pts ASC LIMIT 1`, channelID, messageID, string(domain.ChannelUpdateNewMessage)) + event, _, err := scanChannelEvent(row) + return event, err +} + +func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction, reserved *[]reservedChannelPts) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) { + msgID, err := s.msgIDs.NextChannelMessageID(ctx, channel.ID) + if err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service message id: %w", err) + } + pts, err := s.pts.NextChannelPts(ctx, channel.ID) + if err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service pts: %w", err) + } + reserveChannelPts(reserved, channel.ID, pts, 1) + msg := domain.ChannelMessage{ + ChannelID: channel.ID, + ID: msgID, + SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: date, + Post: channel.Broadcast, + Action: &action, + Pts: pts, + } + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: date, + Message: msg, + SenderUserID: senderUserID, + UserIDs: append([]int64(nil), action.UserIDs...), + } + if err := insertChannelMessageTx(ctx, tx, msg); err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, channel.ID, msgID, pts); err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("update channel service top: %w", err) + } + return msg, event, nil +} + +func (s *ChannelStore) insertParticipantEventTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, actorUserID int64, previous, participant domain.ChannelMember, date int, reserved *[]reservedChannelPts) (domain.ChannelUpdateEvent, domain.Channel, error) { + pts, err := s.pts.NextChannelPts(ctx, channel.ID) + if err != nil { + return domain.ChannelUpdateEvent{}, channel, fmt.Errorf("allocate channel participant pts: %w", err) + } + reserveChannelPts(reserved, channel.ID, pts, 1) + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateParticipant, + Pts: pts, + PtsCount: 1, + Date: date, + SenderUserID: actorUserID, + UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), + Previous: previous, + Participant: participant, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.ChannelUpdateEvent{}, channel, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET pts = $2, updated_at = now() WHERE id = $1`, channel.ID, pts); err != nil { + return domain.ChannelUpdateEvent{}, channel, fmt.Errorf("update channel participant pts: %w", err) + } + channel.Pts = pts + return event, channel, nil +} + +func (s *ChannelStore) deleteChannelMessagesTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int, reserved *[]reservedChannelPts) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) { + if len(ids) == 0 { + return nil, domain.ChannelUpdateEvent{}, channel, nil + } + id32, ordered, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + rows, err := tx.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted +ORDER BY id`, channel.ID, id32) + if err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("list channel messages for delete: %w", err) + } + byID := make(map[int]domain.ChannelMessage, len(ordered)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + rows.Close() + return nil, domain.ChannelUpdateEvent{}, channel, err + } + byID[msg.ID] = msg + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, domain.ChannelUpdateEvent{}, channel, err + } + rows.Close() + deleted := make([]int, 0, len(ordered)) + for _, id := range ordered { + msg, ok := byID[id] + if !ok { + continue + } + if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) { + return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired + } + deleted = append(deleted, id) + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channel.ID, + UserID: actorUserID, + Date: date, + Type: domain.ChannelAdminLogDeleteMessage, + Message: &msg, + Query: msg.Body, + }); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + } + if len(deleted) == 0 { + return nil, domain.ChannelUpdateEvent{}, channel, nil + } + pts, err := s.nextChannelPtsN(ctx, channel.ID, len(deleted)) + if err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("allocate channel delete pts: %w", err) + } + reserveChannelPts(reserved, channel.ID, pts, len(deleted)) + deleted32 := int32s(deleted) + if _, err := tx.Exec(ctx, ` +UPDATE channel_messages +SET deleted = true, pts = $3, updated_at = now() +WHERE channel_id = $1 AND id = ANY($2::int[])`, channel.ID, deleted32, pts); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("soft delete channel messages: %w", err) + } + if err := deleteChannelUnreadMentionsTx(ctx, tx, channel.ID, deleted); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + if err := refreshChannelUnreadReactionsCountsForMessagesTx(ctx, tx, channel.ID, deleted); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + topID, err := topNonDeletedChannelMessageID(ctx, tx, channel.ID) + if err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + if _, err := tx.Exec(ctx, ` +UPDATE channels +SET top_message_id = $2, pts = $3, updated_at = now() +WHERE id = $1`, channel.ID, topID, pts); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("update channel top after delete: %w", err) + } + channel.TopMessageID = topID + channel.Pts = pts + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateDeleteMessages, + Pts: pts, + PtsCount: len(deleted), + Date: date, + MessageIDs: append([]int(nil), deleted...), + SenderUserID: actorUserID, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + return deleted, event, channel, nil +} + +func (s *ChannelStore) nextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) { + if count <= 1 { + return s.pts.NextChannelPts(ctx, channelID) + } + if ranges, ok := s.pts.(store.ChannelPtsRangeAllocator); ok { + return ranges.NextChannelPtsN(ctx, channelID, count) + } + var pts int + var err error + for i := 0; i < count; i++ { + pts, err = s.pts.NextChannelPts(ctx, channelID) + if err != nil { + return 0, err + } + } + return pts, nil +} + +type reservedChannelPts struct { + channelID int64 + pts int + count int +} + +func reserveChannelPts(items *[]reservedChannelPts, channelID int64, pts, count int) { + if items == nil || channelID == 0 || pts == 0 { + return + } + if count <= 0 { + count = 1 + } + *items = append(*items, reservedChannelPts{channelID: channelID, pts: pts, count: count}) +} + +func (s *ChannelStore) recordChannelPtsGaps(ctx context.Context, items []reservedChannelPts, date int) { + if len(items) == 0 { + return + } + if date == 0 { + date = nowUnix() + } + for _, item := range items { + count := item.count + if count <= 0 { + count = 1 + } + _, _ = s.db.Exec(ctx, ` +INSERT INTO channel_update_events ( + channel_id, pts, pts_count, date, event_type, message_id, message_ids, sender_user_id, user_ids, payload +) VALUES ($1,$2,$3,$4,$5,0,'[]'::jsonb,0,'[]'::jsonb,'{}'::jsonb) +ON CONFLICT (channel_id, pts) DO NOTHING`, + item.channelID, item.pts, count, date, string(domain.ChannelUpdateNoop)) + } +} + +func validUniqueChannelMessageIDs(ids []int) ([]int32, []int, error) { + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return nil, nil, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return int32s(out), out, nil +} + +func topNonDeletedChannelMessageID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (int, error) { + var id int + if err := db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&id); err != nil { + return 0, fmt.Errorf("select channel top after delete: %w", err) + } + return id, nil +} + +func visibleChannelTopAfter(ctx context.Context, db sqlcgen.DBTX, channelID int64, availableMinID int, fallbackDate int) (int, int, error) { + var id, date int + err := db.QueryRow(ctx, ` +SELECT id, message_date +FROM channel_messages +WHERE channel_id = $1 AND id > $2 AND NOT deleted +ORDER BY id DESC +LIMIT 1`, channelID, availableMinID).Scan(&id, &date) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fallbackDate, nil + } + if err != nil { + return 0, 0, fmt.Errorf("select visible channel top: %w", err) + } + return id, date, nil +} + +func insertChannelTx(ctx context.Context, tx pgx.Tx, ch domain.Channel) error { + rights, err := marshalJSON(ch.DefaultBannedRights, "{}") + if err != nil { + return err + } + reactions, err := marshalJSON(ch.ReactionPolicy, "{}") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channels ( + id, access_hash, creator_user_id, title, about, username, broadcast, megagroup, forum, forum_tabs, + autotranslation, restricted_sponsored, broadcast_messages_allowed, send_paid_messages_stars, + noforwards, join_to_send, join_request, signatures, pre_history_hidden, participants_hidden, antispam, linked_chat_id, slowmode_seconds, default_banned_rights, available_reactions, + color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, emoji_status_document_id, emoji_status_until, + participants_count, admins_count, kicked_count, banned_count, top_message_id, pinned_message_id, pts, ttl_period, date, deleted +) VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43)`, + ch.ID, ch.AccessHash, ch.CreatorUserID, ch.Title, ch.About, ch.Username, ch.Broadcast, ch.Megagroup, ch.Forum, + ch.ForumTabs, ch.Autotranslation, ch.RestrictedSponsored, ch.BroadcastMessagesAllowed, ch.SendPaidMessagesStars, ch.NoForwards, ch.JoinToSend, ch.JoinRequest, ch.Signatures, ch.PreHistoryHidden, ch.ParticipantsHidden, ch.AntiSpam, ch.LinkedChatID, ch.SlowmodeSeconds, rights, reactions, + ch.Color.HasColor, ch.Color.Color, ch.Color.BackgroundEmojiID, ch.ProfileColor.HasColor, ch.ProfileColor.Color, ch.ProfileColor.BackgroundEmojiID, ch.EmojiStatus.DocumentID, ch.EmojiStatus.Until, + ch.ParticipantsCount, ch.AdminsCount, + ch.KickedCount, ch.BannedCount, ch.TopMessageID, ch.PinnedMessageID, ch.Pts, ch.TTLPeriod, ch.Date, ch.Deleted); err != nil { + return fmt.Errorf("insert channel: %w", err) + } + return nil +} + +func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error { + adminRights, err := marshalJSON(member.AdminRights, "{}") + if err != nil { + return err + } + bannedRights, err := marshalJSON(member.BannedRights, "{}") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_members ( + channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights, + rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) +ON CONFLICT (channel_id, user_id) DO UPDATE SET + inviter_user_id = EXCLUDED.inviter_user_id, + role = EXCLUDED.role, + status = EXCLUDED.status, + joined_at = EXCLUDED.joined_at, + left_at = EXCLUDED.left_at, + admin_rights = EXCLUDED.admin_rights, + banned_rights = EXCLUDED.banned_rights, + rank = EXCLUDED.rank, + available_min_id = GREATEST(channel_members.available_min_id, EXCLUDED.available_min_id), + available_min_pts = GREATEST(channel_members.available_min_pts, EXCLUDED.available_min_pts), + read_inbox_max_id = GREATEST(channel_members.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + updated_at = now()`, + member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status), + member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID, + member.AvailableMinPts, member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil { + return fmt.Errorf("upsert channel member: %w", err) + } + return upsertUserChannelMemberIndexTx(ctx, tx, channel, member) +} + +func upsertUserChannelMemberIndexTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error { + if channel.ID == 0 || member.UserID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +INSERT INTO user_channel_member_index ( + user_id, channel_id, status, megagroup, broadcast, deleted +) VALUES ($1,$2,$3,$4,$5,$6) +ON CONFLICT (user_id, channel_id) DO UPDATE SET + status = EXCLUDED.status, + megagroup = EXCLUDED.megagroup, + broadcast = EXCLUDED.broadcast, + deleted = EXCLUDED.deleted, + updated_at = now()`, + member.UserID, channel.ID, string(member.Status), channel.Megagroup, channel.Broadcast, channel.Deleted); err != nil { + return fmt.Errorf("upsert user channel member index: %w", err) + } + return nil +} + +func markUserChannelMemberIndexDeletedTx(ctx context.Context, tx pgx.Tx, channelID int64, deleted bool) error { + if channelID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +UPDATE user_channel_member_index +SET deleted = $2, updated_at = now() +WHERE channel_id = $1`, channelID, deleted); err != nil { + return fmt.Errorf("mark user channel member index deleted: %w", err) + } + return nil +} + +func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage) error { + entities, err := encodeMessageEntities(msg.Entities) + if err != nil { + return err + } + reply, err := marshalJSON(msg.ReplyTo, "{}") + if err != nil { + return err + } + forward, err := marshalJSON(msg.Forward, "{}") + if err != nil { + return err + } + action, err := marshalJSON(msg.Action, "{}") + if err != nil { + return err + } + media, err := encodeMessageMedia(msg.Media) + if err != nil { + return err + } + var sendAsType sql.NullString + var sendAsID sql.NullInt64 + if msg.SendAs != nil && msg.SendAs.ID != 0 { + sendAsType = sql.NullString{String: string(msg.SendAs.Type), Valid: true} + sendAsID = sql.NullInt64{Int64: msg.SendAs.ID, Valid: true} + } + if msg.From.Type == "" { + msg.From = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID} + } + replyMsgID, replyTopID := 0, 0 + replyPeerType := "" + replyPeerID := int64(0) + if msg.ReplyTo != nil { + replyMsgID = msg.ReplyTo.MessageID + replyTopID = msg.ReplyTo.TopMessageID + replyPeerType = string(msg.ReplyTo.Peer.Type) + replyPeerID = msg.ReplyTo.Peer.ID + } + discussionChannelID, discussionMessageID := int64(0), 0 + if msg.Discussion != nil { + discussionChannelID = msg.Discussion.ChannelID + discussionMessageID = msg.Discussion.MessageID + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_messages ( + channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, + send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, + body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27)`, + msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID, + sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards, + msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, + forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media); err != nil { + return fmt.Errorf("insert channel message: %w", err) + } + return nil +} + +func updateForumTopicTopMessageTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage) error { + if msg.ReplyTo == nil || !msg.ReplyTo.ForumTopic || msg.ReplyTo.TopMessageID <= 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_forum_topics +SET top_message_id = $3, + date = $4, + updated_at = now() +WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, + channelID, msg.ReplyTo.TopMessageID, msg.ID, msg.Date); err != nil { + return fmt.Errorf("update forum topic top message: %w", err) + } + return nil +} + +func insertChannelEventTx(ctx context.Context, tx pgx.Tx, event domain.ChannelUpdateEvent) error { + ids, err := marshalJSON(event.MessageIDs, "[]") + if err != nil { + return err + } + userIDs, err := marshalJSON(event.UserIDs, "[]") + if err != nil { + return err + } + payloadData := map[string]any{ + "message_id": event.Message.ID, + "pinned": event.Pinned, + } + if event.Message.ID != 0 { + payloadData["message"] = event.Message + } + if event.Previous.UserID != 0 { + payloadData["previous_participant"] = event.Previous + } + if event.Participant.UserID != 0 { + payloadData["participant"] = event.Participant + } + payload, err := marshalJSON(payloadData, "{}") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_update_events ( + channel_id, pts, pts_count, date, event_type, message_id, message_ids, sender_user_id, user_ids, payload +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + event.ChannelID, event.Pts, event.PtsCount, event.Date, string(event.Type), event.Message.ID, + ids, event.SenderUserID, userIDs, payload); err != nil { + return fmt.Errorf("insert channel event: %w", err) + } + return nil +} + +func (s *ChannelStore) insertChannelAdminLogTx(ctx context.Context, tx pgx.Tx, event domain.ChannelAdminLogEvent) error { + if event.ChannelID == 0 || event.UserID == 0 || event.Type == "" { + return nil + } + if event.Date == 0 { + event.Date = nowUnix() + } + id, err := nextChannelAdminLogIDTx(ctx, tx, event.ChannelID) + if err != nil { + return err + } + prevParticipant, err := marshalJSON(event.PrevParticipant, "{}") + if err != nil { + return err + } + newParticipant, err := marshalJSON(event.NewParticipant, "{}") + if err != nil { + return err + } + participant, err := marshalJSON(event.Participant, "{}") + if err != nil { + return err + } + message, err := marshalJSON(event.Message, "{}") + if err != nil { + return err + } + prevMessage, err := marshalJSON(event.PrevMessage, "{}") + if err != nil { + return err + } + newMessage, err := marshalJSON(event.NewMessage, "{}") + if err != nil { + return err + } + query := adminLogSearchText(event) + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_admin_log_events ( + channel_id, id, actor_user_id, event_date, event_type, prev_string, new_string, + prev_bool, new_bool, prev_int, new_int, prev_participant, new_participant, + participant, message, prev_message, new_message, query +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, + event.ChannelID, id, event.UserID, event.Date, string(event.Type), event.PrevString, event.NewString, + event.PrevBool, event.NewBool, event.PrevInt, event.NewInt, prevParticipant, newParticipant, + participant, message, prevMessage, newMessage, query); err != nil { + return fmt.Errorf("insert channel admin log: %w", err) + } + return nil +} + +func nextChannelAdminLogIDTx(ctx context.Context, tx pgx.Tx, channelID int64) (int64, error) { + var id int64 + if err := tx.QueryRow(ctx, ` +UPDATE channels +SET admin_log_seq = admin_log_seq + 1, updated_at = now() +WHERE id = $1 +RETURNING admin_log_seq`, channelID).Scan(&id); err != nil { + return 0, fmt.Errorf("allocate channel admin log id: %w", err) + } + return id, nil +} + +func upsertChannelDialogTx(ctx context.Context, tx pgx.Tx, userID int64, channel domain.Channel, top domain.ChannelMessage, readInboxMaxID, readOutboxMaxID int) error { + topDate := top.Date + if topDate == 0 { + topDate = channel.Date + } + unread, err := countChannelUnreadMessages(ctx, tx, userID, channel.ID, readInboxMaxID, channel.TopMessageID) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark +) VALUES ($1,$2,$3,$4,$5,$6,$7,false) +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), + top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), + read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), + unread_count = ( + SELECT COUNT(*)::int + FROM channel_messages msg + WHERE msg.channel_id = channel_dialogs.channel_id + AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) + AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) + AND NOT msg.deleted + AND msg.sender_user_id <> channel_dialogs.user_id + ), + unread_mark = false, + updated_at = now()`, + userID, channel.ID, channel.TopMessageID, topDate, readInboxMaxID, readOutboxMaxID, unread); err != nil { + return fmt.Errorf("upsert channel dialog: %w", err) + } + return nil +} + +func upsertChannelDialogsForMessageTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, selfReadUserID int64) error { + if channel.ID == 0 || top.ID == 0 { + return nil + } + if !shouldSynchronouslyUpsertChannelDialogs(channel) { + return nil + } + topDate := top.Date + if topDate == 0 { + topDate = channel.Date + } + if _, err := tx.Exec(ctx, ` +WITH active AS ( + SELECT + m.user_id, + CASE + WHEN m.user_id = $4 THEN GREATEST(m.read_inbox_max_id, $2) + ELSE m.read_inbox_max_id + END AS read_inbox_max_id, + CASE + WHEN m.user_id = $4 THEN GREATEST(m.read_outbox_max_id, $2) + ELSE m.read_outbox_max_id + END AS read_outbox_max_id + FROM channel_members m + WHERE m.channel_id = $1 + AND m.status = 'active' + AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) + AND $2 > m.available_min_id +) +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark +) +SELECT + user_id, $1, $2, $3, + read_inbox_max_id, read_outbox_max_id, + ( + SELECT COUNT(*)::int + FROM channel_messages msg + WHERE msg.channel_id = $1 + AND msg.id > active.read_inbox_max_id + AND msg.id <= $2 + AND NOT msg.deleted + AND msg.sender_user_id <> active.user_id + ), + false +FROM active +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), + top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), + read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), + unread_count = ( + SELECT COUNT(*)::int + FROM channel_messages msg + WHERE msg.channel_id = channel_dialogs.channel_id + AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) + AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) + AND NOT msg.deleted + AND msg.sender_user_id <> channel_dialogs.user_id + ), + unread_mark = CASE WHEN channel_dialogs.user_id = $4 THEN false ELSE channel_dialogs.unread_mark END, + updated_at = now()`, channel.ID, top.ID, topDate, selfReadUserID); err != nil { + return fmt.Errorf("upsert channel message dialogs: %w", err) + } + return nil +} + +func shouldSynchronouslyUpsertChannelDialogs(channel domain.Channel) bool { + if channel.Broadcast { + return false + } + return channel.ParticipantsCount > 0 && channel.ParticipantsCount <= domain.MaxSynchronousChannelDialogFanout +} + +func insertChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) error { + candidates := uniqueChannelUserIDs(userIDs, senderUserID) + if len(candidates) == 0 || msg.ID == 0 { + return nil + } + if len(candidates) > domain.MaxChannelMentionRecipients { + candidates = candidates[:domain.MaxChannelMentionRecipients] + } + topID := channelMentionTopID(msg) + if _, err := tx.Exec(ctx, ` +WITH input(user_id) AS ( + SELECT DISTINCT unnest($4::bigint[]) +), +active AS ( + SELECT i.user_id + FROM input i + JOIN channel_members m ON m.channel_id = $1 AND m.user_id = i.user_id + WHERE m.status = 'active' + AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) + AND $2 > m.available_min_id + AND $2 > m.read_inbox_max_id + LIMIT $6 +), +inserted AS ( + INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id) + SELECT user_id, $1, $2, $3 + FROM active + ON CONFLICT DO NOTHING + RETURNING user_id +) +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, unread_mentions_count +) +SELECT user_id, $1, $2, $5, 1 +FROM inserted +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), + top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), + unread_mentions_count = channel_dialogs.unread_mentions_count + 1, + updated_at = now()`, channelID, msg.ID, topID, candidates, msg.Date, domain.MaxChannelMentionRecipients); err != nil { + return fmt.Errorf("insert channel unread mentions: %w", err) + } + return nil +} + +func readChannelMentionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) { + var cleared, remaining int + if err := tx.QueryRow(ctx, ` +WITH target AS ( + SELECT user_id, channel_id, message_id + FROM channel_unread_mentions + WHERE user_id = $1 + AND channel_id = $2 + AND ($3 = 0 OR top_message_id = $3) + ORDER BY message_id DESC + LIMIT $4 +), +deleted AS ( + DELETE FROM channel_unread_mentions um + USING target t + WHERE um.user_id = t.user_id + AND um.channel_id = t.channel_id + AND um.message_id = t.message_id + RETURNING um.message_id +), +remaining_scoped AS ( + SELECT COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE user_id = $1 + AND channel_id = $2 + AND ($3 = 0 OR top_message_id = $3) +), +remaining_all AS ( + SELECT COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE user_id = $1 AND channel_id = $2 +), +updated_dialog AS ( + UPDATE channel_dialogs + SET unread_mentions_count = (SELECT count FROM remaining_all), + updated_at = now() + WHERE user_id = $1 AND channel_id = $2 +) +SELECT (SELECT COUNT(*)::int FROM deleted), (SELECT count FROM remaining_scoped)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil { + return 0, 0, fmt.Errorf("read channel mentions: %w", err) + } + return cleared, remaining, nil +} + +func readChannelReactionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) { + var cleared, remaining int + if err := tx.QueryRow(ctx, ` +WITH member_scope AS ( + SELECT available_min_id + FROM channel_members + WHERE user_id = $1 AND channel_id = $2 +), +target_messages AS ( + SELECT DISTINCT r.message_id + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + JOIN member_scope ms ON true + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > ms.available_min_id + AND NOT cm.deleted + AND ($3 = 0 OR cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3) + ORDER BY r.message_id DESC + LIMIT $4 +), +updated AS ( + UPDATE channel_message_reactions r + SET unread = false, + updated_at = now() + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.message_id IN (SELECT message_id FROM target_messages) + AND r.unread + RETURNING r.message_id +), +remaining_scoped AS ( + SELECT COUNT(DISTINCT r.message_id)::int AS count + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + JOIN member_scope ms ON true + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > ms.available_min_id + AND NOT cm.deleted + AND ($3 = 0 OR cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3) +), +remaining_all AS ( + SELECT COUNT(DISTINCT r.message_id)::int AS count + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + JOIN member_scope ms ON true + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > ms.available_min_id + AND NOT cm.deleted +), +updated_dialog AS ( + UPDATE channel_dialogs + SET unread_reactions_count = (SELECT count FROM remaining_all), + updated_at = now() + WHERE user_id = $1 AND channel_id = $2 +) +SELECT (SELECT COUNT(DISTINCT message_id)::int FROM updated), (SELECT count FROM remaining_scoped)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil { + return 0, 0, fmt.Errorf("read channel reactions: %w", err) + } + return cleared, remaining, nil +} + +func clearChannelUnreadReactionsForMessageIDsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, ids []int32) ([]int, error) { + if userID == 0 || channelID == 0 || len(ids) == 0 { + return nil, nil + } + rows, err := tx.Query(ctx, ` +UPDATE channel_message_reactions +SET unread = false, + updated_at = now() +WHERE sender_user_id = $1 + AND channel_id = $2 + AND message_id = ANY($3::int[]) + AND unread + AND reacted_user_id <> $1 +RETURNING message_id`, userID, channelID, ids) + if err != nil { + return nil, fmt.Errorf("clear visible channel unread reactions: %w", err) + } + clearedSet := make(map[int]struct{}) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + clearedSet[id] = struct{}{} + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + if len(clearedSet) == 0 { + return nil, nil + } + cleared := make([]int, 0, len(clearedSet)) + for id := range clearedSet { + cleared = append(cleared, id) + } + sort.Sort(sort.Reverse(sort.IntSlice(cleared))) + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { + return nil, err + } + return cleared, nil +} + +func refreshChannelUnreadReactionsCountTx(ctx context.Context, tx pgx.Tx, userID, channelID int64) error { + if userID == 0 || channelID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +WITH active AS ( + SELECT m.available_min_id + FROM channel_members m + WHERE m.user_id = $1 + AND m.channel_id = $2 + AND m.status = 'active' + AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) +), +counts AS ( + SELECT COUNT(DISTINCT r.message_id)::int AS count + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + JOIN active a ON true + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > a.available_min_id + AND NOT cm.deleted +) +INSERT INTO channel_dialogs (user_id, channel_id, unread_reactions_count) +SELECT $1, $2, counts.count +FROM active, counts +ON CONFLICT (user_id, channel_id) DO UPDATE SET + unread_reactions_count = EXCLUDED.unread_reactions_count, + updated_at = now()`, userID, channelID); err != nil { + return fmt.Errorf("refresh channel unread reactions count: %w", err) + } + return nil +} + +func deleteChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error { + if len(ids) == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +WITH deleted AS ( + DELETE FROM channel_unread_mentions + WHERE channel_id = $1 AND message_id = ANY($2::int[]) + RETURNING user_id +), +affected AS ( + SELECT DISTINCT user_id FROM deleted +), +counts AS ( + SELECT user_id, COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE channel_id = $1 + AND user_id IN (SELECT user_id FROM affected) + GROUP BY user_id +) +UPDATE channel_dialogs d +SET unread_mentions_count = COALESCE(c.count, 0), + updated_at = now() +FROM affected a +LEFT JOIN counts c ON c.user_id = a.user_id +WHERE d.channel_id = $1 AND d.user_id = a.user_id`, channelID, int32s(ids)); err != nil { + return fmt.Errorf("delete channel unread mentions: %w", err) + } + return nil +} + +func refreshChannelUnreadReactionsCountsForMessagesTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error { + if len(ids) == 0 { + return nil + } + rows, err := tx.Query(ctx, ` +SELECT DISTINCT sender_user_id +FROM channel_message_reactions +WHERE channel_id = $1 + AND message_id = ANY($2::int[]) + AND sender_user_id <> 0`, channelID, int32s(ids)) + if err != nil { + return fmt.Errorf("list channel unread reaction owners: %w", err) + } + defer rows.Close() + userIDs := make([]int64, 0) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return err + } + userIDs = append(userIDs, userID) + } + if err := rows.Err(); err != nil { + return err + } + for _, userID := range userIDs { + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { + return err + } + } + return nil +} + +func deleteChannelUnreadMentionsUpToTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, maxID int) error { + if maxID <= 0 { + return nil + } + var deleted int + if err := tx.QueryRow(ctx, ` +WITH deleted AS ( + DELETE FROM channel_unread_mentions + WHERE user_id = $1 AND channel_id = $2 AND message_id <= $3 + RETURNING message_id +), +remaining_all AS ( + SELECT COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE user_id = $1 AND channel_id = $2 +), +updated_dialog AS ( + UPDATE channel_dialogs + SET unread_mentions_count = (SELECT count FROM remaining_all), + updated_at = now() + WHERE user_id = $1 AND channel_id = $2 +) +SELECT COUNT(*)::int FROM deleted`, userID, channelID, maxID).Scan(&deleted); err != nil { + return fmt.Errorf("delete channel unread mentions up to: %w", err) + } + return nil +} + +func channelMentionTopID(msg domain.ChannelMessage) int { + if msg.ReplyTo == nil { + return 0 + } + if msg.ReplyTo.TopMessageID > 0 { + return msg.ReplyTo.TopMessageID + } + return msg.ReplyTo.MessageID +} + +func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember, error) { + var ch domain.Channel + var member domain.ChannelMember + var defaultRights, reactionPolicy, adminRights, bannedRights string + var role, status string + if err := row.Scan( + &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, + &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &defaultRights, + &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, + &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, + &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, + &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, + &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, + &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, + &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, + ); err != nil { + return domain.Channel{}, domain.ChannelMember{}, err + } + member.Role = domain.ChannelMemberRole(role) + member.Status = domain.ChannelMemberStatus(status) + _ = json.Unmarshal([]byte(defaultRights), &ch.DefaultBannedRights) + _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) + _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) + _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) + return ch, member, nil +} + +func scanChannel(row rowScanner) (domain.Channel, error) { + var ch domain.Channel + var rights, reactionPolicy string + if err := row.Scan( + &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, + &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, + &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, + &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, + &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, + &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, + ); err != nil { + return domain.Channel{}, err + } + _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) + _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) + return ch, nil +} + +func scanChannelWithViewerMember(row rowScanner) (domain.Channel, bool, error) { + var ch domain.Channel + var viewerMember bool + var rights, reactionPolicy string + if err := row.Scan( + &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, + &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, + &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, + &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, + &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, + &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, + &viewerMember, + ); err != nil { + return domain.Channel{}, false, err + } + _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) + _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) + return ch, viewerMember, nil +} + +func scanChannelMember(row rowScanner) (domain.ChannelMember, error) { + var member domain.ChannelMember + var adminRights, bannedRights string + var role, status string + if err := row.Scan( + &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, + &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, + &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, + ); err != nil { + return domain.ChannelMember{}, err + } + member.Role = domain.ChannelMemberRole(role) + member.Status = domain.ChannelMemberStatus(status) + _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) + _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) + return member, nil +} + +func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult { + ids := make([]int64, 0, len(changed)) + for id := range changed { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))} + for _, id := range ids { + out.Channels = append(out.Channels, changed[id]) + } + return out +} + +func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, error) { + var member domain.ChannelMember + var adminRights, bannedRights string + var role, status string + var count int + if err := row.Scan( + &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, + &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, + &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, + &count, + ); err != nil { + return domain.ChannelMember{}, 0, err + } + member.Role = domain.ChannelMemberRole(role) + member.Status = domain.ChannelMemberStatus(status) + _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) + _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) + return member, count, nil +} + +func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.Dialog, error) { + var ch domain.Channel + var rights, reactionPolicy string + var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int + var pinned, unreadMark, viewForumAsMessages bool + if err := row.Scan( + &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, + &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, + &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, + &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, + &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, + &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, + &topID, &topDate, + &folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, + ); err != nil { + return domain.Channel{}, domain.Dialog{}, err + } + _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) + _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) + dialog := domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}, + FolderID: folderID, + TopMessage: topID, + TopMessageDate: topDate, + ReadInboxMaxID: readInbox, + ReadOutboxMaxID: readOutbox, + UnreadCount: unreadCount, + UnreadMentions: unreadMentions, + UnreadReactions: unreadReactions, + Pinned: pinned, + PinnedOrder: pinnedOrder, + UnreadMark: unreadMark, + ViewForumAsMessages: viewForumAsMessages, + } + _ = userID + return ch, dialog, nil +} + +func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { + var msg domain.ChannelMessage + var fromType string + var sendAsType sql.NullString + var sendAsID sql.NullInt64 + var replyMsgID, replyTopID int + var replyPeerType string + var replyPeerID int64 + var discussionChannelID int64 + var discussionMessageID int + var entities, reply, forward, action string + var mediaJSON string + if err := row.Scan( + &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, + &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, + &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, + &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, + ); err != nil { + return domain.ChannelMessage{}, err + } + msg.From.Type = domain.PeerType(fromType) + if sendAsType.Valid && sendAsID.Valid { + msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} + } + parsedEntities, err := decodeMessageEntities(entities) + if err != nil { + return domain.ChannelMessage{}, err + } + msg.Entities = parsedEntities + msg.ReplyTo = channelMessageReplyFromColumns(decodeJSONPtr[domain.MessageReply](reply), replyMsgID, replyPeerType, replyPeerID, replyTopID) + msg.Forward = decodeJSONPtr[domain.MessageForward](forward) + if discussionChannelID != 0 && discussionMessageID != 0 { + msg.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} + } + msg.Action = decodeJSONPtr[domain.ChannelMessageAction](action) + msg.Media, err = decodeMessageMedia(mediaJSON) + if err != nil { + return domain.ChannelMessage{}, err + } + return msg, nil +} + +func scanChannelForumTopic(row rowScanner) (domain.ChannelForumTopic, error) { + var topic domain.ChannelForumTopic + if err := row.Scan( + &topic.ChannelID, + &topic.TopicID, + &topic.CreatorUserID, + &topic.Title, + &topic.IconColor, + &topic.IconEmojiID, + &topic.TitleMissing, + &topic.Closed, + &topic.Hidden, + &topic.Pinned, + &topic.PinnedOrder, + &topic.Date, + &topic.TopMessageID, + &topic.ReadInboxMaxID, + &topic.ReadOutboxMaxID, + &topic.UnreadCount, + &topic.UnreadMentionsCount, + &topic.UnreadReactionsCount, + &topic.UnreadPollVotesCount, + ); err != nil { + return domain.ChannelForumTopic{}, err + } + return topic, nil +} + +func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, error) { + var msg domain.ChannelMessage + var fromType string + var sendAsType sql.NullString + var sendAsID sql.NullInt64 + var replyMsgID, replyTopID int + var replyPeerType string + var replyPeerID int64 + var discussionChannelID int64 + var discussionMessageID int + var entities, reply, forward, action string + var count int + var mediaJSON string + if err := row.Scan( + &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, + &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, + &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, + &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &count, + ); err != nil { + return domain.ChannelMessage{}, 0, err + } + msg.From.Type = domain.PeerType(fromType) + if sendAsType.Valid && sendAsID.Valid { + msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} + } + parsedEntities, err := decodeMessageEntities(entities) + if err != nil { + return domain.ChannelMessage{}, 0, err + } + msg.Entities = parsedEntities + msg.ReplyTo = channelMessageReplyFromColumns(decodeJSONPtr[domain.MessageReply](reply), replyMsgID, replyPeerType, replyPeerID, replyTopID) + msg.Forward = decodeJSONPtr[domain.MessageForward](forward) + if discussionChannelID != 0 && discussionMessageID != 0 { + msg.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} + } + msg.Action = decodeJSONPtr[domain.ChannelMessageAction](action) + msg.Media, err = decodeMessageMedia(mediaJSON) + if err != nil { + return domain.ChannelMessage{}, 0, err + } + return msg, count, nil +} + +func scanChannelMessagePeerReaction(row rowScanner, viewerUserID int64) (domain.ChannelMessagePeerReaction, error) { + var out domain.ChannelMessagePeerReaction + var reactionType, reactionValue string + if err := row.Scan( + &out.ChannelID, + &out.MessageID, + &out.UserID, + &out.SenderUserID, + &reactionType, + &reactionValue, + &out.Big, + &out.Unread, + &out.ChosenOrder, + &out.Date, + ); err != nil { + return domain.ChannelMessagePeerReaction{}, err + } + out.My = out.UserID == viewerUserID + out.Reaction = domain.MessageReaction{ + Type: domain.MessageReactionType(reactionType), + Emoticon: reactionValue, + } + return out, nil +} + +func channelMessageReplyFromColumns(reply *domain.MessageReply, msgID int, peerType string, peerID int64, topID int) *domain.MessageReply { + if reply != nil { + if reply.MessageID == 0 { + reply.MessageID = msgID + } + if reply.TopMessageID == 0 { + reply.TopMessageID = topID + } + if reply.Peer.ID == 0 && peerType != "" && peerID != 0 { + reply.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} + } + if reply.MessageID <= 0 && reply.TopMessageID <= 0 { + return nil + } + return reply + } + if msgID <= 0 && topID <= 0 { + return nil + } + out := &domain.MessageReply{ + MessageID: msgID, + TopMessageID: topID, + } + if peerType != "" && peerID != 0 { + out.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} + } + return out +} + +func scanChannelEvent(row rowScanner) (domain.ChannelUpdateEvent, int, error) { + var event domain.ChannelUpdateEvent + var typ string + var messageID int + var messageIDs, userIDs, payload string + if err := row.Scan( + &event.ChannelID, &event.Pts, &event.PtsCount, &event.Date, &typ, &messageID, + &messageIDs, &event.SenderUserID, &userIDs, &payload, + ); err != nil { + return domain.ChannelUpdateEvent{}, 0, err + } + event.Type = domain.ChannelUpdateEventType(typ) + _ = json.Unmarshal([]byte(messageIDs), &event.MessageIDs) + _ = json.Unmarshal([]byte(userIDs), &event.UserIDs) + var data struct { + Pinned bool `json:"pinned"` + Message domain.ChannelMessage `json:"message"` + PreviousParticipant domain.ChannelMember `json:"previous_participant"` + Participant domain.ChannelMember `json:"participant"` + } + _ = json.Unmarshal([]byte(payload), &data) + event.Pinned = data.Pinned + if data.Message.ID != 0 { + event.Message = data.Message + } + event.Previous = data.PreviousParticipant + event.Participant = data.Participant + return event, messageID, nil +} + +func scanChannelAdminLogEvent(row rowScanner) (domain.ChannelAdminLogEvent, error) { + var event domain.ChannelAdminLogEvent + var typ string + var prevParticipant, newParticipant, participant, message, prevMessage, newMessage string + if err := row.Scan( + &event.ChannelID, &event.ID, &event.UserID, &event.Date, &typ, + &event.PrevString, &event.NewString, &event.PrevBool, &event.NewBool, + &event.PrevInt, &event.NewInt, &prevParticipant, &newParticipant, &participant, + &message, &prevMessage, &newMessage, &event.Query, + ); err != nil { + return domain.ChannelAdminLogEvent{}, err + } + event.Type = domain.ChannelAdminLogEventType(typ) + event.PrevParticipant = decodeJSONPtr[domain.ChannelMember](prevParticipant) + event.NewParticipant = decodeJSONPtr[domain.ChannelMember](newParticipant) + event.Participant = decodeJSONPtr[domain.ChannelMember](participant) + event.Message = decodeJSONPtr[domain.ChannelMessage](message) + event.PrevMessage = decodeJSONPtr[domain.ChannelMessage](prevMessage) + event.NewMessage = decodeJSONPtr[domain.ChannelMessage](newMessage) + return event, nil +} + +func channelDialogToDialog(dialog domain.ChannelDialog) domain.Dialog { + return domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, + FolderID: dialog.FolderID, + TopMessage: dialog.TopMessageID, + TopMessageDate: dialog.TopMessageDate, + ReadInboxMaxID: dialog.ReadInboxMaxID, + ReadOutboxMaxID: dialog.ReadOutboxMaxID, + UnreadCount: dialog.UnreadCount, + UnreadMentions: dialog.UnreadMentions, + UnreadReactions: dialog.UnreadReactions, + Pinned: dialog.Pinned, + PinnedOrder: dialog.PinnedOrder, + UnreadMark: dialog.UnreadMark, + ViewForumAsMessages: dialog.ViewForumAsMessages, + } +} + +func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool { + if filter.HasFolderID { + if filter.FolderID < domain.DialogCustomFolderMinID { + if dialog.FolderID != filter.FolderID { + return false + } + } else if filter.Folder == nil { + return false + } + } + if filter.PinnedOnly && !dialog.Pinned { + return false + } + if filter.ExcludePinned && dialog.Pinned { + return false + } + if !channelDialogAfterOffset(dialog, filter) { + return false + } + if filter.Folder == nil { + return true + } + folder := filter.Folder + if peerInDialogFolder(dialog.Peer, folder.ExcludePeers) { + return false + } + if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark { + return false + } + if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID { + return false + } + if peerInDialogFolder(dialog.Peer, folder.IncludePeers) || peerInDialogFolder(dialog.Peer, folder.PinnedPeers) { + return true + } + if channel.Megagroup && folder.Groups { + return true + } + if channel.Broadcast && folder.Broadcasts { + return true + } + return !folder.Groups && !folder.Broadcasts && len(folder.IncludePeers) == 0 +} + +func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { + if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { + if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer { + return false + } + return true + } + if filter.OffsetDate > 0 { + if dialog.TopMessageDate != filter.OffsetDate { + return dialog.TopMessageDate < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + if dialog.TopMessage != filter.OffsetID { + return dialog.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { + return dialog.Peer.ID < filter.OffsetPeer.ID + } + return false + } + if dialog.TopMessage != filter.OffsetID { + return dialog.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { + return dialog.Peer.ID < filter.OffsetPeer.ID + } + return false +} + +func peerInDialogFolder(peer domain.Peer, items []domain.DialogFolderPeer) bool { + for _, item := range items { + if item.Peer == peer { + return true + } + } + return false +} + +func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.DialogFolderPeer) []int64 { + total := len(primary) + for _, items := range rest { + total += len(items) + } + seen := make(map[int64]struct{}, minInt(total, domain.MaxDialogFolderPeers)) + out := make([]int64, 0, minInt(total, domain.MaxDialogFolderPeers)) + appendOne := func(items []domain.DialogFolderPeer) { + for _, item := range items { + if len(out) >= domain.MaxDialogFolderPeers { + return + } + if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { + continue + } + if _, ok := seen[item.Peer.ID]; ok { + continue + } + seen[item.Peer.ID] = struct{}{} + out = append(out, item.Peer.ID) + } + } + appendOne(primary) + for _, items := range rest { + appendOne(items) + } + return out +} + +func validateChannelMemberVisible(member domain.ChannelMember) error { + switch member.Status { + case domain.ChannelMemberActive: + if member.BannedRights.ViewMessages { + return domain.ErrChannelUserBanned + } + return nil + case domain.ChannelMemberBanned, domain.ChannelMemberKicked: + return domain.ErrChannelUserBanned + default: + return domain.ErrChannelPrivate + } +} + +func canPostChannel(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) +} + +func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool { + if channel.Broadcast { + return canPostChannel(member) + } + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return true + } + return !channel.DefaultBannedRights.SendMessages && !member.BannedRights.SendMessages +} + +func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool { + if member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) { + return true + } + return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers +} + +func isChannelAdmin(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin +} + +func canChangeChannelInfo(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) +} + +func canManageDiscussionBroadcast(member domain.ChannelMember) bool { + return canChangeChannelInfo(member) +} + +func canManageDiscussionGroup(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) +} + +func validDiscussionGroup(channel domain.Channel) bool { + return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted +} + +func canAddChannelAdmins(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins) +} + +func canBanChannelUsers(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers) +} + +func canExportChannelInvite(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) +} + +func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool { + if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) { + return true + } + return channel.Megagroup && !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages +} + +func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { + if topic.CreatorUserID == userID { + return true + } + return canPinChannelMessages(channel, member) +} + +func canEditChannelMessage(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) +} + +func canDeleteAnyChannelMessage(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages) +} + +func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int { + if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return 0 + } + next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds + if now >= next { + return 0 + } + return next - now +} + +func boolPtr(v bool) *bool { + return &v +} + +func channelInitialAvailableMinID(channel domain.Channel) int { + if channel.PreHistoryHidden { + return channel.TopMessageID + } + return 0 +} + +func channelInitialAvailableMinPts(channel domain.Channel) int { + return channel.Pts +} + +func publicPreviewableChannel(channel domain.Channel) bool { + return !channel.Deleted && + (channel.Broadcast || channel.Megagroup) && + strings.TrimSpace(channel.Username) != "" +} + +func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember { + member := domain.ChannelMember{ + ChannelID: channel.ID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberLeft, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + ReadOutboxMaxID: channel.TopMessageID, + } + if found { + member.InviterUserID = existing.InviterUserID + member.JoinedAt = existing.JoinedAt + member.LeftAt = existing.LeftAt + member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID) + member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts) + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID) + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID) + } + return member +} + +func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { + topMessageID := channel.TopMessageID + if topMessageID <= member.AvailableMinID { + topMessageID = 0 + } + return domain.ChannelDialog{ + UserID: userID, + ChannelID: channel.ID, + TopMessageID: topMessageID, + TopMessageDate: channel.Date, + ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), + ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), + } +} + +func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { + return rights == domain.ChannelAdminRights{} +} + +func adminRightsSubset(want, have domain.ChannelAdminRights) bool { + return (!want.ChangeInfo || have.ChangeInfo) && + (!want.PostMessages || have.PostMessages) && + (!want.EditMessages || have.EditMessages) && + (!want.DeleteMessages || have.DeleteMessages) && + (!want.BanUsers || have.BanUsers) && + (!want.InviteUsers || have.InviteUsers) && + (!want.PinMessages || have.PinMessages) && + (!want.AddAdmins || have.AddAdmins) && + (!want.ManageCall || have.ManageCall) && + (!want.Anonymous || have.Anonymous) +} + +func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { + return rights == domain.ChannelBannedRights{} +} + +func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType { + if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages { + return domain.ChannelAdminLogParticipantKick + } + if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages { + return domain.ChannelAdminLogParticipantUnkick + } + if !zeroChannelBannedRights(next.BannedRights) { + return domain.ChannelAdminLogParticipantBan + } + return domain.ChannelAdminLogParticipantUnban +} + +func adminLogEventTypesForFilter(filter domain.ChannelAdminLogFilter) []string { + if filter.Empty() { + return nil + } + types := make([]string, 0, 16) + add := func(enabled bool, typ domain.ChannelAdminLogEventType) { + if enabled { + types = append(types, string(typ)) + } + } + add(filter.Join, domain.ChannelAdminLogParticipantJoin) + add(filter.Leave, domain.ChannelAdminLogParticipantLeave) + add(filter.Invite || filter.Invites, domain.ChannelAdminLogParticipantInvite) + add(filter.Ban, domain.ChannelAdminLogParticipantBan) + add(filter.Unban, domain.ChannelAdminLogParticipantUnban) + add(filter.Kick, domain.ChannelAdminLogParticipantKick) + add(filter.Unkick, domain.ChannelAdminLogParticipantUnkick) + add(filter.Promote, domain.ChannelAdminLogParticipantPromote) + add(filter.Demote, domain.ChannelAdminLogParticipantDemote) + if filter.Info { + types = append(types, + string(domain.ChannelAdminLogChangeTitle), + string(domain.ChannelAdminLogChangeUsername), + string(domain.ChannelAdminLogChangeLinkedChat), + string(domain.ChannelAdminLogToggleSlowMode), + ) + } + if filter.Settings { + types = append(types, + string(domain.ChannelAdminLogToggleSignatures), + string(domain.ChannelAdminLogTogglePreHistoryHidden), + string(domain.ChannelAdminLogToggleAntiSpam), + string(domain.ChannelAdminLogToggleAutotranslation), + ) + } + add(filter.Forums || filter.Settings, domain.ChannelAdminLogToggleForum) + add(filter.Pinned, domain.ChannelAdminLogUpdatePinned) + add(filter.Edit, domain.ChannelAdminLogEditMessage) + add(filter.Delete, domain.ChannelAdminLogDeleteMessage) + add(filter.Send, domain.ChannelAdminLogSendMessage) + return types +} + +func adminLogSearchText(event domain.ChannelAdminLogEvent) string { + parts := []string{ + event.Query, + event.PrevString, + event.NewString, + } + for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { + if msg != nil { + parts = append(parts, msg.Body) + } + } + return strings.ToLower(strings.TrimSpace(strings.Join(parts, " "))) +} + +func adminLogLikePattern(query string) string { + query = strings.ReplaceAll(query, `\`, `\\`) + query = strings.ReplaceAll(query, `%`, `\%`) + query = strings.ReplaceAll(query, `_`, `\_`) + return "%" + query + "%" +} + +func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) { + var participants, admins, kicked, banned int + rows, err := tx.Query(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, + rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +FROM channel_members +WHERE channel_id = $1`, channel.ID) + if err != nil { + return domain.Channel{}, fmt.Errorf("list channel members for counts: %w", err) + } + defer rows.Close() + for rows.Next() { + member, err := scanChannelMember(rows) + if err != nil { + return domain.Channel{}, err + } + if member.Status == domain.ChannelMemberKicked { + kicked++ + } + if member.Status != domain.ChannelMemberActive { + continue + } + participants++ + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + admins++ + } + if !zeroChannelBannedRights(member.BannedRights) { + banned++ + } + } + if err := rows.Err(); err != nil { + return domain.Channel{}, err + } + if _, err := tx.Exec(ctx, ` +UPDATE channels +SET participants_count = $2, admins_count = $3, kicked_count = $4, banned_count = $5, updated_at = now() +WHERE id = $1`, channel.ID, participants, admins, kicked, banned); err != nil { + return domain.Channel{}, fmt.Errorf("refresh channel counts: %w", err) + } + channel.ParticipantsCount = participants + channel.AdminsCount = admins + channel.KickedCount = kicked + channel.BannedCount = banned + return channel, nil +} + +func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember { + return domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + Role: domain.ChannelRoleCreator, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + PostMessages: true, + EditMessages: true, + DeleteMessages: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + AddAdmins: true, + ManageCall: true, + }, + } +} + +func collectChannelEventRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { + if event.SenderUserID != 0 { + userRefs[event.SenderUserID] = struct{}{} + } + for _, id := range event.UserIDs { + if id != 0 { + userRefs[id] = struct{}{} + } + } + for _, member := range []domain.ChannelMember{event.Previous, event.Participant} { + if member.UserID != 0 { + userRefs[member.UserID] = struct{}{} + } + if member.InviterUserID != 0 { + userRefs[member.InviterUserID] = struct{}{} + } + } + collectChannelMessageRefs(event.Message, currentChannelID, userRefs, channelRefs) +} + +func collectChannelMessageRefs(msg domain.ChannelMessage, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { + if msg.SenderUserID != 0 { + userRefs[msg.SenderUserID] = struct{}{} + } + addPeerRef(msg.From, currentChannelID, userRefs, channelRefs) + if msg.SendAs != nil { + addPeerRef(*msg.SendAs, currentChannelID, userRefs, channelRefs) + } + if msg.Forward != nil { + addPeerRef(msg.Forward.From, currentChannelID, userRefs, channelRefs) + } + if msg.ReplyTo != nil { + addPeerRef(msg.ReplyTo.Peer, currentChannelID, userRefs, channelRefs) + } + if msg.Action != nil { + for _, id := range msg.Action.UserIDs { + if id != 0 { + userRefs[id] = struct{}{} + } + } + } +} + +func addPeerRef(peer domain.Peer, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { + switch peer.Type { + case domain.PeerTypeUser: + if peer.ID != 0 { + userRefs[peer.ID] = struct{}{} + } + case domain.PeerTypeChannel: + if peer.ID != 0 && peer.ID != currentChannelID { + channelRefs[peer.ID] = struct{}{} + } + } +} + +func mapKeysInt64(items map[int64]struct{}) []int64 { + if len(items) == 0 { + return nil + } + out := make([]int64, 0, len(items)) + for id := range items { + if id != 0 { + out = append(out, id) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func uniqueChannelUserIDs(ids []int64, exclude int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 || id == exclude { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func uniqueNonZeroInt64s(items ...int64) []int64 { + seen := make(map[int64]struct{}, len(items)) + out := make([]int64, 0, len(items)) + for _, item := range items { + if item == 0 { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} + +func channelMemberIDs(members []domain.ChannelMember) []int64 { + out := make([]int64, 0, len(members)) + for _, member := range members { + if member.UserID != 0 { + out = append(out, member.UserID) + } + } + return out +} + +func marshalJSON(v any, empty string) ([]byte, error) { + if v == nil { + return []byte(empty), nil + } + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + if string(raw) == "null" { + return []byte(empty), nil + } + return raw, nil +} + +func int64s(ids []int64) []int64 { + return append([]int64(nil), ids...) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func insertChannelInviteTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite) error { + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_invites ( + channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + expire_date, usage_limit, usage_count, requested_count, created_at, updated_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NULLIF($9,0),NULLIF($10,0),$11,$12,to_timestamp($13),to_timestamp($13))`, + invite.ChannelID, invite.InviteID, invite.Hash, invite.AdminUserID, invite.Title, + invite.Permanent, invite.Revoked, invite.RequestNeeded, invite.ExpireDate, + invite.UsageLimit, invite.UsageCount, invite.RequestedCount, invite.Date); err != nil { + return fmt.Errorf("insert channel invite: %w", err) + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_invite_hashes (hash, channel_id, invite_id) +VALUES ($1,$2,$3) +ON CONFLICT (hash) DO UPDATE SET channel_id = EXCLUDED.channel_id, invite_id = EXCLUDED.invite_id, updated_at = now()`, + invite.Hash, invite.ChannelID, invite.InviteID); err != nil { + return fmt.Errorf("insert channel invite hash: %w", err) + } + return nil +} + +func (s *ChannelStore) getInviteByHash(ctx context.Context, db sqlcgen.DBTX, hash string) (domain.Channel, domain.ChannelInvite, error) { + return s.getInviteByHashLocked(ctx, db, hash, false) +} + +func (s *ChannelStore) getInviteByHashForUpdate(ctx context.Context, tx pgx.Tx, hash string) (domain.Channel, domain.ChannelInvite, error) { + return s.getInviteByHashLocked(ctx, tx, hash, true) +} + +func (s *ChannelStore) getInviteByHashLocked(ctx context.Context, db sqlcgen.DBTX, hash string, forUpdate bool) (domain.Channel, domain.ChannelInvite, error) { + lockClause := "" + if forUpdate { + lockClause = " FOR UPDATE OF i" + } + row := db.QueryRow(ctx, ` +SELECT `+channelColumns+`, + i.channel_id, i.invite_id, i.hash, i.admin_user_id, i.title, i.permanent, i.revoked, i.request_needed, + COALESCE(i.expire_date, 0), COALESCE(i.usage_limit, 0), i.usage_count, i.requested_count, EXTRACT(EPOCH FROM i.created_at)::int +FROM channel_invite_hashes h +JOIN channel_invites i ON i.channel_id = h.channel_id AND i.invite_id = h.invite_id +JOIN channels c ON c.id = i.channel_id AND NOT c.deleted +WHERE h.hash = $1 AND NOT i.revoked`+lockClause, hash) + var ch domain.Channel + var invite domain.ChannelInvite + var rights, reactionPolicy string + if err := row.Scan( + &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, + &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, + &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, + &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, + &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, + &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, + &invite.ChannelID, &invite.InviteID, &invite.Hash, &invite.AdminUserID, &invite.Title, + &invite.Permanent, &invite.Revoked, &invite.RequestNeeded, &invite.ExpireDate, + &invite.UsageLimit, &invite.UsageCount, &invite.RequestedCount, &invite.Date, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, domain.ChannelInvite{}, domain.ErrInviteHashInvalid + } + return domain.Channel{}, domain.ChannelInvite{}, err + } + _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) + _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) + return ch, invite, nil +} + +func (s *ChannelStore) getInviteByChannelHash(ctx context.Context, db sqlcgen.DBTX, channelID int64, hash string, forUpdate bool) (domain.ChannelInvite, error) { + lockClause := "" + if forUpdate { + lockClause = " FOR UPDATE" + } + row := db.QueryRow(ctx, ` +SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, + EXTRACT(EPOCH FROM created_at)::int +FROM channel_invites +WHERE channel_id = $1 AND hash = $2`+lockClause, channelID, strings.TrimSpace(hash)) + invite, err := scanChannelInvite(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelInvite{}, domain.ErrInviteHashInvalid + } + return invite, err +} + +func (s *ChannelStore) getInviteByID(ctx context.Context, db sqlcgen.DBTX, channelID, inviteID int64, forUpdate bool) (domain.ChannelInvite, error) { + lockClause := "" + if forUpdate { + lockClause = " FOR UPDATE" + } + row := db.QueryRow(ctx, ` +SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, + EXTRACT(EPOCH FROM created_at)::int +FROM channel_invites +WHERE channel_id = $1 AND invite_id = $2`+lockClause, channelID, inviteID) + invite, err := scanChannelInvite(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelInvite{}, domain.ErrInviteHashInvalid + } + return invite, err +} + +func scanChannelInvite(row rowScanner) (domain.ChannelInvite, error) { + var invite domain.ChannelInvite + err := row.Scan( + &invite.ChannelID, + &invite.InviteID, + &invite.Hash, + &invite.AdminUserID, + &invite.Title, + &invite.Permanent, + &invite.Revoked, + &invite.RequestNeeded, + &invite.ExpireDate, + &invite.UsageLimit, + &invite.UsageCount, + &invite.RequestedCount, + &invite.Date, + ) + return invite, err +} + +func (s *ChannelStore) newPostgresReplacementInvite(old domain.ChannelInvite, date int) (domain.ChannelInvite, error) { + inviteID, err := randomPositiveInt64() + if err != nil { + return domain.ChannelInvite{}, err + } + hash, err := randomInviteHash() + if err != nil { + return domain.ChannelInvite{}, err + } + if date == 0 { + date = nowUnix() + } + return domain.ChannelInvite{ + ChannelID: old.ChannelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: old.AdminUserID, + Permanent: old.Permanent, + Date: date, + }, nil +} + +func (s *ChannelStore) getPendingInviteImporterTx(ctx context.Context, tx pgx.Tx, channelID, userID int64, forUpdate bool) (domain.ChannelInviteImporter, error) { + lockClause := "" + if forUpdate { + lockClause = " FOR UPDATE" + } + row := tx.QueryRow(ctx, ` +SELECT channel_id, invite_id, user_id, date, requested, approved_by, via_chatlist, about +FROM channel_invite_importers +WHERE channel_id = $1 AND user_id = $2 AND requested`+lockClause, channelID, userID) + var importer domain.ChannelInviteImporter + err := row.Scan(&importer.ChannelID, &importer.InviteID, &importer.UserID, &importer.Date, &importer.Requested, &importer.ApprovedBy, &importer.ViaChatlist, &importer.About) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelInviteImporter{}, domain.ErrHideRequesterMissing + } + return importer, err +} + +func deletePendingInviteImporterTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite, userID int64) error { + tag, err := tx.Exec(ctx, ` +DELETE FROM channel_invite_importers +WHERE channel_id = $1 AND user_id = $2 AND requested`, invite.ChannelID, userID) + if err != nil { + return fmt.Errorf("delete pending channel invite importer: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrHideRequesterMissing + } + if invite.InviteID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET requested_count = CASE WHEN requested_count > 0 THEN requested_count - 1 ELSE 0 END, + updated_at = now() +WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { + return fmt.Errorf("decrement channel invite requested count: %w", err) + } + return nil +} + +func decodeJSONPtr[T any](raw string) *T { + if raw == "" || raw == "{}" || raw == "null" { + return nil + } + var out T + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil + } + return &out +} + +func randomChannelAccessHash() (int64, error) { + return randomPositiveInt64() +} + +func randomPositiveInt64() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, fmt.Errorf("rand int64: %w", err) + } + return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil +} + +func randomInviteHash() (string, error) { + var b [18]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("rand invite hash: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func nowUnix() int { + return int(time.Now().Unix()) +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func isRetryablePostgresTxError(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false + } + return pgErr.Code == "40P01" || pgErr.Code == "40001" +} + +type pgChannelIDAllocator struct { + db sqlcgen.DBTX +} + +func (a pgChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) { + current, err := a.CurrentChannelID(ctx) + if err != nil { + return 0, err + } + return current + 1, nil +} + +func (a pgChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) { + var id int64 + err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&id) + return id, err +} + +type pgChannelPtsAllocator struct { + db sqlcgen.DBTX +} + +func (a pgChannelPtsAllocator) NextChannelPts(ctx context.Context, channelID int64) (int, error) { + current, err := a.CurrentChannelPts(ctx, channelID) + if err != nil { + return 0, err + } + return current + 1, nil +} + +func (a pgChannelPtsAllocator) NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) { + if count <= 0 { + count = 1 + } + current, err := a.CurrentChannelPts(ctx, channelID) + if err != nil { + return 0, err + } + return current + count, nil +} + +func (a pgChannelPtsAllocator) CurrentChannelPts(ctx context.Context, channelID int64) (int, error) { + var pts int + err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts) + return pts, err +} + +type pgChannelMessageIDAllocator struct { + db sqlcgen.DBTX +} + +func (a pgChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) { + current, err := a.CurrentChannelMessageID(ctx, channelID) + if err != nil { + return 0, err + } + return current + 1, nil +} + +func (a pgChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) { + var id int + err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id) + return id, err +} diff --git a/internal/store/postgres/channel_integration_test.go b/internal/store/postgres/channel_integration_test.go new file mode 100644 index 00000000..785a628a --- /dev/null +++ b/internal/store/postgres/channel_integration_test.go @@ -0,0 +1,3165 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestChannelStoreSendMessageFansOutDialogRows(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 31, + Phone: "+1777" + suffix + "01", + FirstName: "ChannelOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 32, + Phone: "+1777" + suffix + "02", + FirstName: "ChannelFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Dialog Top " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 901, + Message: "first visible channel text", + Date: 1700000301, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + var friendTop, friendReadInbox, friendUnread int + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, read_inbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, friend.ID).Scan(&friendTop, &friendReadInbox, &friendUnread); err != nil { + t.Fatalf("read friend dialog row after send: %v", err) + } + if friendTop != sent.Message.ID || friendReadInbox != 0 || friendUnread != 2 { + t.Fatalf("friend dialog row top=%d read=%d unread=%d, want top %d read 0 unread 2", friendTop, friendReadInbox, friendUnread, sent.Message.ID) + } + + var ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread int + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerTop, &ownerReadInbox, &ownerReadOutbox, &ownerUnread); err != nil { + t.Fatalf("read owner dialog row after send: %v", err) + } + if ownerTop != sent.Message.ID || ownerReadInbox != sent.Message.ID || ownerReadOutbox != sent.Message.ID || ownerUnread != 0 { + t.Fatalf("owner dialog row top=%d read_in=%d read_out=%d unread=%d, want sent/read/unread 0 for %d", ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread, sent.Message.ID) + } + + var ownerMemberReadInbox, ownerMemberReadOutbox int + if err := pool.QueryRow(ctx, ` +SELECT read_inbox_max_id, read_outbox_max_id +FROM channel_members +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberReadInbox, &ownerMemberReadOutbox); err != nil { + t.Fatalf("read owner member row after send: %v", err) + } + if ownerMemberReadInbox != sent.Message.ID || ownerMemberReadOutbox != 0 { + t.Fatalf("owner member read_in=%d read_out=%d, want read_in %d and read_out unchanged before peer read", ownerMemberReadInbox, ownerMemberReadOutbox, sent.Message.ID) + } + + dialogs, err := channels.ListChannelDialogs(ctx, friend.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list friend dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].TopMessage != sent.Message.ID { + t.Fatalf("friend dialogs = %+v, want top message %d", dialogs.Dialogs, sent.Message.ID) + } + if len(dialogs.Messages) != 1 || dialogs.Messages[0].Body != "first visible channel text" { + t.Fatalf("friend dialog messages = %+v, want latest channel text", dialogs.Messages) + } + if dialogs.Dialogs[0].UnreadCount != 2 { + t.Fatalf("friend unread = %d, want create service + latest text", dialogs.Dialogs[0].UnreadCount) + } + + read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: friend.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000302, + }) + if err != nil { + t.Fatalf("read history: %v", err) + } + if read.Dialog.UnreadCount != 0 || read.Dialog.ReadInboxMaxID != sent.Message.ID { + t.Fatalf("read dialog = %+v, want fully read through latest", read.Dialog) + } + if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != owner.ID || read.OutboxUpdates[0].MaxID != sent.Message.ID { + t.Fatalf("read outbox updates = %+v, want owner max id %d", read.OutboxUpdates, sent.Message.ID) + } + ownerView, err := channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("get owner channel after read: %v", err) + } + if ownerView.Dialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("owner dialog read_outbox = %d, want %d", ownerView.Dialog.ReadOutboxMaxID, sent.Message.ID) + } + if changed, err := channels.SetChannelDialogPinned(ctx, owner.ID, channelID, true); err != nil || !changed { + t.Fatalf("set owner channel pinned = changed %v err %v, want changed", changed, err) + } + if err := channels.ReorderChannelPinnedDialogs(ctx, owner.ID, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: channelID}, + }, true); err != nil { + t.Fatalf("reorder owner channel pinned: %v", err) + } + if changed, err := channels.SetChannelDialogUnreadMark(ctx, owner.ID, channelID, true); err != nil || !changed { + t.Fatalf("set owner channel unread mark = changed %v err %v, want changed", changed, err) + } + if err := channels.EditChannelPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{ + {Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, FolderID: domain.DialogArchiveFolderID}, + }); err != nil { + t.Fatalf("edit owner channel folder: %v", err) + } + ownerDialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner channel dialogs after settings: %v", err) + } + if len(ownerDialogs.Dialogs) != 1 || !ownerDialogs.Dialogs[0].Pinned || ownerDialogs.Dialogs[0].PinnedOrder != 1 || !ownerDialogs.Dialogs[0].UnreadMark || ownerDialogs.Dialogs[0].FolderID != domain.DialogArchiveFolderID { + t.Fatalf("owner channel dialog settings = %+v, want pinned/unread/archive", ownerDialogs.Dialogs) + } + unreadMarks, err := channels.ListChannelUnreadMarked(ctx, owner.ID) + if err != nil { + t.Fatalf("list owner channel unread marks: %v", err) + } + if len(unreadMarks) != 1 || unreadMarks[0].ID != channelID || unreadMarks[0].Type != domain.PeerTypeChannel { + t.Fatalf("channel unread marks = %+v, want channel", unreadMarks) + } + + readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ + UserID: owner.ID, + ChannelID: channelID, + MessageID: sent.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 1700000303, + }) + if err != nil { + t.Fatalf("list message read participants: %v", err) + } + if len(readers.Participants) != 1 || readers.Participants[0].UserID != friend.ID || readers.Participants[0].Date != 1700000302 { + t.Fatalf("read participants = %+v, want friend read date", readers.Participants) + } + + cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: friend.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000302, + }) + if err != nil { + t.Fatalf("local clear history: %v", err) + } + if cleared.AvailableMinID != sent.Message.ID { + t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID) + } + staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: friend.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000303, + }) + if err != nil { + t.Fatalf("stale local clear history: %v", err) + } + if staleClear.AvailableMinID != sent.Message.ID { + t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID) + } + afterClear, err := channels.GetChannel(ctx, friend.ID, channelID) + if err != nil { + t.Fatalf("get channel after clear: %v", err) + } + if afterClear.Dialog.TopMessageID != 0 { + t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog) + } + + next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 902, + Message: "after local clear", + Date: 1700000304, + }) + if err != nil { + t.Fatalf("send after clear: %v", err) + } + afterNext, err := channels.GetChannel(ctx, friend.ID, channelID) + if err != nil { + t.Fatalf("get channel after next: %v", err) + } + if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 { + t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID) + } +} + +func TestChannelStoreReadOutboxDoesNotRegressSenderDialogUnread(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 35, + Phone: "+1777" + suffix + "17", + FirstName: "ReadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 36, + Phone: "+1777" + suffix + "18", + FirstName: "ReadMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Read Outbox " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000340, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + ownerMsg, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9341, + Message: "owner message before member reply", + Date: 1700000341, + }) + if err != nil { + t.Fatalf("send owner message: %v", err) + } + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: ownerMsg.Message.ID, + Date: 1700000342, + }); err != nil { + t.Fatalf("member read owner message: %v", err) + } + memberMsg, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: member.ID, + ChannelID: channelID, + RandomID: 9342, + Message: "member reply should stay read for sender", + Date: 1700000343, + }) + if err != nil { + t.Fatalf("send member message: %v", err) + } + + var storedReadInbox, storedUnread int + if err := pool.QueryRow(ctx, ` +SELECT read_inbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil { + t.Fatalf("read member dialog after self send: %v", err) + } + if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 { + t.Fatalf("member dialog after self send read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID) + } + + read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: channelID, + MaxID: memberMsg.Message.ID, + Date: 1700000344, + }) + if err != nil { + t.Fatalf("owner read member message: %v", err) + } + if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != member.ID || read.OutboxUpdates[0].MaxID != memberMsg.Message.ID { + t.Fatalf("read outbox updates = %+v, want member max id %d", read.OutboxUpdates, memberMsg.Message.ID) + } + if err := pool.QueryRow(ctx, ` +SELECT read_inbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil { + t.Fatalf("read member dialog after owner read: %v", err) + } + if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 { + t.Fatalf("member dialog after owner read read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID) + } +} + +func TestChannelStoreChannelUnreadExcludesOwnOutgoing(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 39, + Phone: "+1777" + suffix + "19", + FirstName: "OwnUnreadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Own Unread " + suffix, + Megagroup: true, + Date: 1700000350, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9351, + Message: "own outgoing should not be unread", + Date: 1700000351, + }) + if err != nil { + t.Fatalf("send owner message: %v", err) + } + readBeforeOwnMessage := sent.Message.ID - 1 + if _, err := pool.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_max_id = $3, unread_mark = false +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, readBeforeOwnMessage); err != nil { + t.Fatalf("regress owner member read watermark: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channel_dialogs +SET read_inbox_max_id = $3, unread_count = 0, unread_mark = false +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, readBeforeOwnMessage); err != nil { + t.Fatalf("regress owner dialog unread: %v", err) + } + + dialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs) + } + if dialogs.Dialogs[0].UnreadCount != 0 { + t.Fatalf("owner dialog unread = %d, want own outgoing excluded", dialogs.Dialogs[0].UnreadCount) + } + unreadOnly, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ + Folder: &domain.DialogFolder{ExcludeRead: true, Groups: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread-only channel dialogs: %v", err) + } + for _, dialog := range unreadOnly.Dialogs { + if dialog.Peer.ID == channelID { + t.Fatalf("unread-only dialogs include own-outgoing-only channel: %+v", unreadOnly.Dialogs) + } + } + if _, err := pool.Exec(ctx, ` +UPDATE channel_dialogs +SET unread_count = 99 +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID); err != nil { + t.Fatalf("corrupt owner dialog unread before read repair: %v", err) + } + + read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000352, + }) + if err != nil { + t.Fatalf("read owner channel history: %v", err) + } + if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 { + t.Fatalf("read result = %+v, want no unread own outgoing messages", read) + } + var storedUnread int + if err := pool.QueryRow(ctx, ` +SELECT unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&storedUnread); err != nil { + t.Fatalf("read stored owner unread: %v", err) + } + if storedUnread != 0 { + t.Fatalf("stored owner unread = %d, want repaired to 0", storedUnread) + } +} + +func TestChannelStoreConcurrentSendAndReadHistoryDoNotSurfaceDeadlock(t *testing.T) { + pool := testPool(t) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 37, + Phone: "+1777" + suffix + "21", + FirstName: "ConcurrentOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 38, + Phone: "+1777" + suffix + "22", + FirstName: "ConcurrentMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(context.Background(), "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(context.Background(), "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Send Read Race " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000450, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 1700000451, + Message: "seed", + Date: 1700000451, + }) + if err != nil { + t.Fatalf("seed send: %v", err) + } + + for i := 0; i < 20; i++ { + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go func(iter int) { + defer wg.Done() + <-start + _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: member.ID, + ChannelID: channelID, + RandomID: int64(1700000500 + iter), + Message: fmt.Sprintf("race send %d", iter), + Date: 1700000500 + iter, + }) + errs <- err + }(i) + go func() { + defer wg.Done() + <-start + _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: first.Message.ID, + Date: 1700000600, + }) + errs <- err + }() + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent send/read iteration %d: %v", i, err) + } + } + } +} + +func TestChannelStoreJoinInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 131, + Phone: "+1777" + suffix + "11", + FirstName: "JoinOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 132, + Phone: "+1777" + suffix + "12", + FirstName: "JoinFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Join Watermark " + suffix, + Megagroup: true, + Date: 1700000320, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 905, + Message: "before join", + Date: 1700000321, + }) + if err != nil { + t.Fatalf("send existing message: %v", err) + } + joined, err := channels.JoinChannel(ctx, channelID, friend.ID, 1700000322) + if err != nil { + t.Fatalf("join channel: %v", err) + } + if _, err := channels.JoinChannel(ctx, channelID, friend.ID, 1700000323); !errors.Is(err, domain.ErrUserAlreadyParticipant) { + t.Fatalf("duplicate join err = %v, want ErrUserAlreadyParticipant", err) + } + if len(joined.Members) != 1 || joined.Members[0].ReadInboxMaxID != joined.Message.ID { + t.Fatalf("joined member = %+v message=%+v, want read watermark at self join service", joined.Members, joined.Message) + } + view, err := channels.GetChannel(ctx, friend.ID, channelID) + if err != nil { + t.Fatalf("get joined channel: %v", err) + } + if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { + t.Fatalf("joined view dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) + } + readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ + UserID: owner.ID, + ChannelID: channelID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 1700000323, + }) + if err != nil { + t.Fatalf("list read participants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after join = %+v, want none from initial watermark", readers.Participants) + } + future, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 906, + Message: "after join", + Date: 1700000323, + }) + if err != nil { + t.Fatalf("send future message: %v", err) + } + after, err := channels.GetChannel(ctx, friend.ID, channelID) + if err != nil { + t.Fatalf("get channel after future: %v", err) + } + if after.Dialog.TopMessageID != future.Message.ID || after.Dialog.UnreadCount != 1 { + t.Fatalf("joined dialog after future = %+v, want top %d unread 1", after.Dialog, future.Message.ID) + } +} + +func TestChannelStoreJoinRejectsKickedMember(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 33, + Phone: "+1777" + suffix + "21", + FirstName: "BanOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 34, + Phone: "+1777" + suffix + "22", + FirstName: "BanMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + helper, err := users.Create(ctx, domain.User{ + AccessHash: 35, + Phone: "+1777" + suffix + "23", + FirstName: "BanHelper", + }) + if err != nil { + t.Fatalf("create helper: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID, helper.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Ban Join " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID, helper.ID}, + Date: 1700000305, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ + UserID: owner.ID, + ChannelID: channelID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, + BannedRights: domain.ChannelBannedRights{ + ViewMessages: true, + UntilDate: 1700001300, + }, + Date: 1700000306, + }); err != nil { + t.Fatalf("kick member: %v", err) + } + if _, err := channels.JoinChannel(ctx, channelID, member.ID, 1700000307); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("kicked JoinChannel err = %v, want ErrChannelUserBanned", err) + } + if _, err := channels.InviteToChannel(ctx, channelID, helper.ID, []int64{member.ID}, 1700000308); !errors.Is(err, domain.ErrUserKicked) { + t.Fatalf("helper InviteToChannel kicked err = %v, want ErrUserKicked", err) + } + restored, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{member.ID}, 1700000309) + if err != nil { + t.Fatalf("owner InviteToChannel kicked: %v", err) + } + if len(restored.Members) != 1 || restored.Members[0].Status != domain.ChannelMemberActive || restored.Members[0].BannedRights != (domain.ChannelBannedRights{}) { + t.Fatalf("restored members = %+v, want active unbanned member", restored.Members) + } + if restored.Channel.ParticipantsCount != 3 || restored.Channel.KickedCount != 0 { + t.Fatalf("restored counts = participants:%d kicked:%d, want 3/0", restored.Channel.ParticipantsCount, restored.Channel.KickedCount) + } + if _, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{member.ID}, 1700000310); !errors.Is(err, domain.ErrUserAlreadyParticipant) { + t.Fatalf("duplicate InviteToChannel err = %v, want ErrUserAlreadyParticipant", err) + } +} + +func TestChannelStoreInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1888" + suffix + "01", + FirstName: "InviteOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + invited, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1888" + suffix + "02", + FirstName: "InviteMember", + }) + if err != nil { + t.Fatalf("create invited: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, invited.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Invite Watermark " + suffix, + Megagroup: true, + Date: 1700000320, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 902, + Message: "already visible before invite", + Date: 1700000321, + }) + if err != nil { + t.Fatalf("send existing channel message: %v", err) + } + if _, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{invited.ID}, 1700000322); err != nil { + t.Fatalf("invite to channel: %v", err) + } + + view, err := channels.GetChannel(ctx, invited.ID, channelID) + if err != nil { + t.Fatalf("get invited channel: %v", err) + } + if view.Self.ReadInboxMaxID != first.Message.ID || view.Dialog.ReadInboxMaxID != first.Message.ID { + t.Fatalf("invited read watermark self/dialog = %d/%d, want existing top %d", view.Self.ReadInboxMaxID, view.Dialog.ReadInboxMaxID, first.Message.ID) + } + if view.Dialog.UnreadCount != 1 { + t.Fatalf("invited unread = %d, want only invite service message unread", view.Dialog.UnreadCount) + } +} + +func TestChannelStoreImportInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 51, + Phone: "+1888" + suffix + "11", + FirstName: "ImportOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + joiner, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1888" + suffix + "12", + FirstName: "ImportJoiner", + }) + if err != nil { + t.Fatalf("create joiner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, joiner.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Import Watermark " + suffix, + Megagroup: true, + Date: 1700000330, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 912, + Message: "already visible before import", + Date: 1700000331, + }) + if err != nil { + t.Fatalf("send existing channel message: %v", err) + } + invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "join", + Date: 1700000332, + }) + if err != nil { + t.Fatalf("export invite: %v", err) + } + joined, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: joiner.ID, + Hash: invite.Invite.Hash, + Date: 1700000333, + }) + if err != nil { + t.Fatalf("import invite: %v", err) + } + if len(joined.Members) != 1 || joined.Members[0].ReadInboxMaxID != joined.Message.ID || joined.Members[0].ReadOutboxMaxID != joined.Message.ID { + t.Fatalf("imported member = %+v message=%+v, want read watermarks at self join service", joined.Members, joined.Message) + } + view, err := channels.GetChannel(ctx, joiner.ID, channelID) + if err != nil { + t.Fatalf("get imported channel: %v", err) + } + if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { + t.Fatalf("imported view dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) + } + readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ + UserID: owner.ID, + ChannelID: channelID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 1700000334, + }) + if err != nil { + t.Fatalf("list read participants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after import = %+v, want none from initial watermark", readers.Participants) + } +} + +func TestChannelStoreImportInviteRequestNeededAndUsageLimitErrors(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 61, + Phone: "+1888" + suffix + "21", + FirstName: "InviteErrorOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + first, err := users.Create(ctx, domain.User{ + AccessHash: 62, + Phone: "+1888" + suffix + "22", + FirstName: "InviteErrorFirst", + }) + if err != nil { + t.Fatalf("create first: %v", err) + } + second, err := users.Create(ctx, domain.User{ + AccessHash: 63, + Phone: "+1888" + suffix + "23", + FirstName: "InviteErrorSecond", + }) + if err != nil { + t.Fatalf("create second: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, first.ID, second.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Invite Errors " + suffix, + Megagroup: true, + Date: 1700000340, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + requested, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "approval", + RequestNeeded: true, + Date: 1700000341, + }) + if err != nil { + t.Fatalf("export request-needed invite: %v", err) + } + if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: first.ID, + Hash: requested.Invite.Hash, + Date: 1700000342, + }); !errors.Is(err, domain.ErrInviteRequestSent) { + t.Fatalf("import request-needed err = %v, want ErrInviteRequestSent", err) + } + limited, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "one", + UsageLimit: 1, + Date: 1700000343, + }) + if err != nil { + t.Fatalf("export limited invite: %v", err) + } + if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: first.ID, + Hash: limited.Invite.Hash, + Date: 1700000344, + }); err != nil { + t.Fatalf("first import limited invite: %v", err) + } + if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: second.ID, + Hash: limited.Invite.Hash, + Date: 1700000345, + }); !errors.Is(err, domain.ErrUsersTooMuch) { + t.Fatalf("second import limited invite err = %v, want ErrUsersTooMuch", err) + } +} + +func TestChannelStorePendingJoinRequestsSummaryAndInviteAdmins(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + userIDs := make([]int64, 0, 11) + createUser := func(label string, accessHash int64, phoneSuffix int) domain.User { + t.Helper() + user, err := users.Create(ctx, domain.User{ + AccessHash: accessHash, + Phone: fmt.Sprintf("+1889%s%02d", suffix, phoneSuffix), + FirstName: label, + }) + if err != nil { + t.Fatalf("create %s: %v", label, err) + } + userIDs = append(userIDs, user.ID) + return user + } + owner := createUser("PendingOwner", 71, 1) + inviteAdmin := createUser("PendingInviteAdmin", 72, 2) + plainMember := createUser("PendingPlainMember", 73, 3) + changeAdmin := createUser("PendingChangeAdmin", 74, 4) + requesters := make([]domain.User, 0, domain.MaxChannelPendingJoinRecentRequesters+2) + for i := 0; i < domain.MaxChannelPendingJoinRecentRequesters+2; i++ { + requesters = append(requesters, createUser("PendingRequester", int64(80+i), 10+i)) + } + + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Pending Summary " + suffix, + Megagroup: true, + MemberUserIDs: []int64{inviteAdmin.ID, plainMember.ID, changeAdmin.ID}, + Date: 1700000360, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: inviteAdmin.ID, + AdminRights: domain.ChannelAdminRights{ + InviteUsers: true, + }, + Date: 1700000361, + }); err != nil { + t.Fatalf("promote invite admin: %v", err) + } + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: changeAdmin.ID, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 1700000362, + }); err != nil { + t.Fatalf("promote change-info admin: %v", err) + } + invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "approval", + RequestNeeded: true, + Date: 1700000363, + }) + if err != nil { + t.Fatalf("export invite: %v", err) + } + for i, requester := range requesters { + _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: requester.ID, + Hash: invite.Invite.Hash, + Date: 1700000370 + i, + }) + if !errors.Is(err, domain.ErrInviteRequestSent) { + t.Fatalf("import pending %d err = %v, want ErrInviteRequestSent", i, err) + } + } + pending, err := channels.PendingJoinRequests(ctx, channelID, 99) + if err != nil { + t.Fatalf("pending join requests: %v", err) + } + if pending.Count != len(requesters) || len(pending.RecentRequesters) != domain.MaxChannelPendingJoinRecentRequesters { + t.Fatalf("pending summary = %+v, want bounded recent with full count", pending) + } + if pending.RecentRequesters[0] != requesters[len(requesters)-1].ID || + pending.RecentRequesters[len(pending.RecentRequesters)-1] != requesters[2].ID { + t.Fatalf("recent requesters = %+v, want newest first", pending.RecentRequesters) + } + admins, err := channels.ListChannelInviteAdminMemberIDs(ctx, channelID, 0) + if err != nil { + t.Fatalf("invite admins: %v", err) + } + want := []int64{owner.ID, inviteAdmin.ID, changeAdmin.ID} + if !reflect.DeepEqual(admins, want) { + t.Fatalf("invite admins = %+v, want %+v", admins, want) + } +} + +func TestChannelStoreImportInviteUsageLimitSeesConcurrentIncrement(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 64, + Phone: "+1888" + suffix + "31", + FirstName: "InviteLimitOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + joiner, err := users.Create(ctx, domain.User{ + AccessHash: 65, + Phone: "+1888" + suffix + "32", + FirstName: "InviteLimitJoiner", + }) + if err != nil { + t.Fatalf("create joiner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, joiner.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Invite Limit Race " + suffix, + Megagroup: true, + Date: 1700000350, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "single", + UsageLimit: 1, + Date: 1700000351, + }) + if err != nil { + t.Fatalf("export limited invite: %v", err) + } + + lockTx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin lock tx: %v", err) + } + defer func() { _ = lockTx.Rollback(ctx) }() + if _, err := lockTx.Exec(ctx, ` +UPDATE channel_invites +SET usage_count = usage_limit +WHERE channel_id = $1 AND invite_id = $2`, channelID, invite.Invite.InviteID); err != nil { + t.Fatalf("lock and update invite usage: %v", err) + } + + importCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + errCh := make(chan error, 1) + go func() { + _, err := channels.ImportInvite(importCtx, domain.ImportChannelInviteRequest{ + UserID: joiner.ID, + Hash: invite.Invite.Hash, + Date: 1700000352, + }) + errCh <- err + }() + time.Sleep(100 * time.Millisecond) + if err := lockTx.Commit(ctx); err != nil { + t.Fatalf("commit lock tx: %v", err) + } + err = <-errCh + if !errors.Is(err, domain.ErrUsersTooMuch) { + t.Fatalf("concurrent import err = %v, want ErrUsersTooMuch after seeing committed usage_count", err) + } + if _, err := channels.GetChannel(ctx, joiner.ID, channelID); !errors.Is(err, domain.ErrChannelPrivate) { + t.Fatalf("joiner channel after rejected import err = %v, want ErrChannelPrivate", err) + } +} + +func TestChannelStoreListDialogsUsesDateAndOffset(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 35, + Phone: "+1777" + suffix + "03", + FirstName: "DialogPageOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) > 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + older, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Older Dialog " + suffix, + Megagroup: true, + Date: 1700000310, + }) + if err != nil { + t.Fatalf("create older channel: %v", err) + } + channelIDs = append(channelIDs, older.Channel.ID) + newer, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Newer Dialog " + suffix, + Megagroup: true, + Date: 1700000320, + }) + if err != nil { + t.Fatalf("create newer channel: %v", err) + } + channelIDs = append(channelIDs, newer.Channel.ID) + + first, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 1}) + if err != nil { + t.Fatalf("list first channel dialogs: %v", err) + } + if len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != newer.Channel.ID { + t.Fatalf("first page dialogs = %+v, want newer channel by top date", first.Dialogs) + } + + next, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ + OffsetDate: first.Dialogs[0].TopMessageDate, + OffsetID: first.Dialogs[0].TopMessage, + HasOffsetPeer: true, + OffsetPeer: first.Dialogs[0].Peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("list next channel dialogs: %v", err) + } + if len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != older.Channel.ID { + t.Fatalf("next page dialogs = %+v, want older channel without repeating offset peer", next.Dialogs) + } +} + +func TestChannelStoreDifferenceStartsAtMemberAvailableMinPts(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1778" + suffix + "01", + FirstName: "PtsOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1778" + suffix + "02", + FirstName: "PtsMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + joiner, err := users.Create(ctx, domain.User{ + AccessHash: 43, + Phone: "+1778" + suffix + "03", + FirstName: "PtsJoiner", + }) + if err != nil { + t.Fatalf("create joiner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID, joiner.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "PTS Floor " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000350, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + promoted, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: member.ID, + AdminRights: domain.ChannelAdminRights{ + InviteUsers: true, + }, + Date: 1700000351, + }) + if err != nil { + t.Fatalf("edit admin: %v", err) + } + joined, err := channels.JoinChannel(ctx, channelID, joiner.ID, 1700000352) + if err != nil { + t.Fatalf("join channel: %v", err) + } + if len(joined.Members) != 1 || joined.Members[0].AvailableMinPts != promoted.Event.Pts { + t.Fatalf("joined members = %+v, want available_min_pts %d", joined.Members, promoted.Event.Pts) + } + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: joiner.ID, + ChannelID: channelID, + Pts: 0, + Limit: 100, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if diff.Pts != joined.Channel.Pts { + t.Fatalf("diff pts = %d, want current channel pts %d", diff.Pts, joined.Channel.Pts) + } + for _, msg := range diff.NewMessages { + if msg.Pts <= promoted.Event.Pts { + t.Fatalf("diff leaks pre-join message %+v at or before available_min_pts %d", msg, promoted.Event.Pts) + } + } + for _, event := range diff.OtherUpdates { + if event.Pts <= promoted.Event.Pts { + t.Fatalf("diff leaks pre-join event %+v at or before available_min_pts %d", event, promoted.Event.Pts) + } + } +} + +func TestChannelStorePublicPreviewDifferenceAllowsNonMember(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 241, + Phone: "+1778" + suffix + "41", + FirstName: "PreviewDiffOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := users.Create(ctx, domain.User{ + AccessHash: 242, + Phone: "+1778" + suffix + "42", + FirstName: "PreviewDiffViewer", + }) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, viewer.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Preview Difference " + suffix, + Broadcast: true, + Date: 1700000370, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: channelID, + Username: "preview_diff_" + suffix, + }); err != nil { + t.Fatalf("update username: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 1700000371, + Message: "public preview difference", + Date: 1700000371, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: viewer.ID, + ChannelID: channelID, + Pts: created.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("list public preview difference: %v", err) + } + if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "public preview difference" { + t.Fatalf("preview diff = %+v, want one public preview message at current pts", diff) + } + if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID { + t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog) + } +} + +func TestChannelStoreListDialogsDerivesRecipientTopWithoutWriteFanout(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1777" + suffix + "31", + FirstName: "DialogTopOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1777" + suffix + "32", + FirstName: "DialogTopMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Dialog Top " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000330, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000331, + }); err != nil { + t.Fatalf("read initial service message: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9331, + Message: "recipient top without write fanout", + Date: 1700000332, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list recipient channel dialogs: %v", err) + } + if len(list.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want one channel dialog", list.Dialogs) + } + dialog := list.Dialogs[0] + if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date || dialog.UnreadCount != 1 { + t.Fatalf("recipient dialog = %+v, want top sent message and unread=1", dialog) + } + if len(list.Messages) != 1 || list.Messages[0].ID != sent.Message.ID { + t.Fatalf("dialog messages = %+v, want sent top message", list.Messages) + } +} + +func TestChannelStoreBroadcastUnreadDerivesDespiteStaleCache(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 421, + Phone: "+1777" + suffix + "33", + FirstName: "BroadcastUnreadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 422, + Phone: "+1777" + suffix + "34", + FirstName: "BroadcastUnreadMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Broadcast Unread " + suffix, + Broadcast: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000333, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000334, + }); err != nil { + t.Fatalf("read initial broadcast service message: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9333, + Message: "broadcast unread derives despite stale cache", + Date: 1700000335, + }) + if err != nil { + t.Fatalf("send broadcast message: %v", err) + } + + var storedUnread int + if err := pool.QueryRow(ctx, ` +SELECT unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedUnread); err != nil { + t.Fatalf("read stale broadcast dialog cache: %v", err) + } + if storedUnread != 0 { + t.Fatalf("stored broadcast unread cache = %d, want no send fanout", storedUnread) + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list broadcast channel dialogs: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != sent.Message.ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("broadcast dialogs = %+v, want sent top and dynamic unread=1", list.Dialogs) + } + + unreadOnly, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{ + Folder: &domain.DialogFolder{ExcludeRead: true, Broadcasts: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread-only broadcast dialogs: %v", err) + } + if len(unreadOnly.Dialogs) != 1 || unreadOnly.Dialogs[0].Peer.ID != channelID { + t.Fatalf("unread-only broadcast dialogs = %+v, want stale-cache channel included", unreadOnly.Dialogs) + } + + view, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("get broadcast channel: %v", err) + } + if view.Dialog.UnreadCount != 1 || view.Dialog.TopMessageID != sent.Message.ID { + t.Fatalf("broadcast view dialog = %+v, want dynamic unread=1", view.Dialog) + } + dialogs, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get broadcast channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 1 { + t.Fatalf("get broadcast dialogs = %+v, want dynamic unread=1", dialogs.Dialogs) + } +} + +func TestChannelStoreLargeMegagroupUnreadDerivesDespiteStaleCache(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 431, + Phone: "+1777" + suffix + "35", + FirstName: "LargeUnreadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 432, + Phone: "+1777" + suffix + "36", + FirstName: "LargeUnreadMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Large Unread " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000336, + }) + if err != nil { + t.Fatalf("create large megagroup: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000337, + }); err != nil { + t.Fatalf("read initial large service message: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channels +SET participants_count = $2 +WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != nil { + t.Fatalf("mark megagroup as over synchronous fanout threshold: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9336, + Message: "large megagroup unread derives despite stale cache", + Date: 1700000338, + }) + if err != nil { + t.Fatalf("send large megagroup message: %v", err) + } + + var storedTop, storedUnread int + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop, &storedUnread); err != nil { + t.Fatalf("read stale large dialog cache: %v", err) + } + if storedTop == sent.Message.ID || storedUnread != 0 { + t.Fatalf("stored large dialog cache top=%d unread=%d, want stale top and unread=0", storedTop, storedUnread) + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list large channel dialogs: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != sent.Message.ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("large dialogs = %+v, want sent top and dynamic unread=1", list.Dialogs) + } + ownerView, err := channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("get large channel for sender: %v", err) + } + if ownerView.Dialog.UnreadCount != 0 { + t.Fatalf("large sender dialog = %+v, want own outgoing excluded from dynamic unread", ownerView.Dialog) + } + + unreadOnly, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{ + Folder: &domain.DialogFolder{ExcludeRead: true, Groups: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread-only large dialogs: %v", err) + } + if len(unreadOnly.Dialogs) != 1 || unreadOnly.Dialogs[0].Peer.ID != channelID { + t.Fatalf("unread-only large dialogs = %+v, want stale-cache channel included", unreadOnly.Dialogs) + } + + view, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("get large channel: %v", err) + } + if view.Dialog.UnreadCount != 1 || view.Dialog.TopMessageID != sent.Message.ID { + t.Fatalf("large view dialog = %+v, want dynamic unread=1", view.Dialog) + } + dialogs, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get large channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 1 { + t.Fatalf("get large dialogs = %+v, want dynamic unread=1", dialogs.Dialogs) + } + + cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000339, + }) + if err != nil { + t.Fatalf("local clear large history: %v", err) + } + if cleared.AvailableMinID != sent.Message.ID { + t.Fatalf("large local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID) + } + afterClear, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("get large channel after local clear: %v", err) + } + if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 { + t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog) + } +} + +func TestChannelStoreLargeMegagroupUnreadSkipsDeletedHole(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 441, + Phone: "+1777" + suffix + "37", + FirstName: "DeletedHoleOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 442, + Phone: "+1777" + suffix + "38", + FirstName: "DeletedHoleMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Deleted Hole " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000339, + }) + if err != nil { + t.Fatalf("create deleted-hole megagroup: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000340, + }); err != nil { + t.Fatalf("read initial deleted-hole service message: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channels +SET participants_count = $2 +WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != nil { + t.Fatalf("mark deleted-hole megagroup over threshold: %v", err) + } + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9339, + Message: "deleted unread hole", + Date: 1700000341, + }) + if err != nil { + t.Fatalf("send first large message: %v", err) + } + second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9340, + Message: "remaining unread message", + Date: 1700000342, + }) + if err != nil { + t.Fatalf("send second large message: %v", err) + } + deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{first.Message.ID}, + Date: 1700000343, + }) + if err != nil { + t.Fatalf("delete non-top unread message: %v", err) + } + if len(deleted.DeletedIDs) != 1 || deleted.DeletedIDs[0] != first.Message.ID { + t.Fatalf("deleted ids = %+v, want first message only", deleted.DeletedIDs) + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list deleted-hole dialogs: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != second.Message.ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("deleted-hole dialogs = %+v, want only non-deleted unread top counted", list.Dialogs) + } +} + +func TestChannelStoreListDialogsSeeksBeyondQueryWindow(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 36, + Phone: "+1777" + suffix + "04", + FirstName: "DialogSeekOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + count := channelDialogQueryLimit + 5 + ids := make([]int64, count) + baseID := owner.ID * 1000 + for i := range ids { + ids[i] = baseID + int64(i+1) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + if _, err := pool.Exec(ctx, ` +INSERT INTO channels ( + id, access_hash, creator_user_id, title, broadcast, megagroup, + participants_count, admins_count, top_message_id, pts, date +) +SELECT id, id + 900000, $2, 'Bulk Dialog ' || ord, false, true, 1, 1, 1, 1, (1700000400 + ord)::int +FROM unnest($1::bigint[]) WITH ORDINALITY AS t(id, ord)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channels: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO channel_members (channel_id, user_id, role, status, joined_at) +SELECT id, $2, 'creator', 'active', 1700000400 +FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channel members: %v", err) + } + + channels := NewChannelStore(pool) + var cursor domain.Dialog + var sixth domain.ChannelDialogList + for page := 0; page < 6; page++ { + filter := domain.DialogFilter{Limit: 100} + if page > 0 { + filter.OffsetDate = cursor.TopMessageDate + filter.OffsetID = cursor.TopMessage + filter.HasOffsetPeer = true + filter.OffsetPeer = cursor.Peer + } + got, err := channels.ListChannelDialogs(ctx, owner.ID, filter) + if err != nil { + t.Fatalf("list channel dialogs page %d: %v", page+1, err) + } + if len(got.Dialogs) == 0 { + t.Fatalf("page %d unexpectedly empty after cursor %+v", page+1, cursor) + } + cursor = got.Dialogs[len(got.Dialogs)-1] + if page == 5 { + sixth = got + } + } + if len(sixth.Dialogs) != 5 { + t.Fatalf("sixth page len = %d, want remaining 5 beyond query window", len(sixth.Dialogs)) + } + if sixth.Dialogs[0].Peer.ID != ids[4] || sixth.Dialogs[4].Peer.ID != ids[0] { + t.Fatalf("sixth page dialogs = %+v, want oldest five descending by date", sixth.Dialogs) + } + + included, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ + Folder: &domain.DialogFolder{ + IncludePeers: []domain.DialogFolderPeer{{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ids[0]}, + }}, + }, + Limit: 10, + }) + if err != nil { + t.Fatalf("list included channel dialog beyond query window: %v", err) + } + if len(included.Dialogs) != 1 || included.Dialogs[0].Peer.ID != ids[0] { + t.Fatalf("included dialogs = %+v, want oldest included channel beyond query window", included.Dialogs) + } +} + +func TestChannelStoreListDialogsFolderFiltersBeforeQueryLimit(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 37, + Phone: "+1777" + suffix + "05", + FirstName: "DialogFolderOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + count := channelDialogQueryLimit + 5 + ids := make([]int64, count) + baseID := owner.ID*1000 + 100000 + for i := range ids { + ids[i] = baseID + int64(i+1) + } + archivedID := ids[0] + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + if _, err := pool.Exec(ctx, ` +INSERT INTO channels ( + id, access_hash, creator_user_id, title, broadcast, megagroup, + participants_count, admins_count, top_message_id, pts, date +) +SELECT id, id + 910000, $2, 'Folder Dialog ' || ord, false, true, 1, 1, 1, 1, (1700000500 + ord)::int +FROM unnest($1::bigint[]) WITH ORDINALITY AS t(id, ord)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channels: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO channel_members (channel_id, user_id, role, status, joined_at) +SELECT id, $2, 'creator', 'active', 1700000500 +FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channel members: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, top_message_date) +VALUES ($1, $2, $3, 1, 1700000500)`, owner.ID, archivedID, domain.DialogArchiveFolderID); err != nil { + t.Fatalf("archive oldest channel dialog: %v", err) + } + + archive, err := NewChannelStore(pool).ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ + HasFolderID: true, + FolderID: domain.DialogArchiveFolderID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list archive channel dialogs: %v", err) + } + if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != archivedID { + t.Fatalf("archive dialogs = %+v, want archived channel beyond first query window", archive.Dialogs) + } +} + +func TestChannelStoreEditAboutPersistsAndChecksPermission(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 131, + Phone: "+1888" + suffix + "01", + FirstName: "AboutOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 132, + Phone: "+1888" + suffix + "02", + FirstName: "AboutMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "About " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000600, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + if _, err := channels.EditChannelAbout(ctx, domain.EditChannelAboutRequest{ + UserID: member.ID, + ChannelID: channelID, + About: "member cannot edit", + Date: 1700000601, + }); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("EditChannelAbout by member err = %v, want ErrChannelAdminRequired", err) + } + + updated, err := channels.EditChannelAbout(ctx, domain.EditChannelAboutRequest{ + UserID: owner.ID, + ChannelID: channelID, + About: "owner about", + Date: 1700000602, + }) + if err != nil { + t.Fatalf("EditChannelAbout by owner: %v", err) + } + if updated.About != "owner about" { + t.Fatalf("updated about = %q, want owner about", updated.About) + } + view, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("GetChannel by member: %v", err) + } + if view.Channel.About != "owner about" { + t.Fatalf("member view about = %q, want owner about", view.Channel.About) + } +} + +func TestChannelStoreSendMessageResolvesReplyTopID(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1778" + suffix + "01", + FirstName: "ReplyOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1778" + suffix + "02", + FirstName: "ReplyFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Reply Top " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000350, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + root, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 911, + Message: "root", + Date: 1700000351, + }) + if err != nil { + t.Fatalf("send root: %v", err) + } + reply, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: friend.ID, + ChannelID: channelID, + RandomID: 912, + Message: "reply", + ReplyTo: &domain.MessageReply{MessageID: root.Message.ID, QuoteText: "root"}, + Date: 1700000352, + }) + if err != nil { + t.Fatalf("send reply: %v", err) + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + if reply.Message.ReplyTo == nil || reply.Message.ReplyTo.Peer != channelPeer || reply.Message.ReplyTo.TopMessageID != root.Message.ID { + t.Fatalf("reply metadata = %+v, want channel peer and top id %d", reply.Message.ReplyTo, root.Message.ID) + } + nested, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 913, + Message: "nested", + ReplyTo: &domain.MessageReply{MessageID: reply.Message.ID}, + Date: 1700000353, + }) + if err != nil { + t.Fatalf("send nested reply: %v", err) + } + if nested.Message.ReplyTo == nil || nested.Message.ReplyTo.TopMessageID != root.Message.ID { + t.Fatalf("nested reply metadata = %+v, want inherited top id %d", nested.Message.ReplyTo, root.Message.ID) + } + _, err = channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 914, + Message: "bad quote offset", + ReplyTo: &domain.MessageReply{ + MessageID: root.Message.ID, + QuoteText: "root", + QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1, + }, + Date: 1700000354, + }) + if !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err) + } +} + +func TestChannelStoreHistorySupportsOffsetDateOnly(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 37, + Phone: "+1778" + suffix + "03", + FirstName: "HistoryDateOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "History Date " + suffix, + Megagroup: true, + Date: 1700000360, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 921, + Message: "old", + Date: 1700000361, + }); err != nil { + t.Fatalf("send old: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 922, + Message: "new", + Date: 1700000362, + }); err != nil { + t.Fatalf("send new: %v", err) + } + + history, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ + ChannelID: channelID, + OffsetDate: 1700000362, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel history: %v", err) + } + if len(history.Messages) != 2 || history.Messages[0].Body != "old" || history.Messages[1].Action == nil { + t.Fatalf("history = %+v, want only messages older than offset date", history.Messages) + } +} + +func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 39, + Phone: "+1778" + suffix + "01", + FirstName: "SnapshotOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 40, + Phone: "+1778" + suffix + "02", + FirstName: "SnapshotFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Snapshot Diff " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000380, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 941, + Message: "original", + Date: 1700000381, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + ID: sent.Message.ID, + Message: "first edit", + EditDate: 1700000382, + }); err != nil { + t.Fatalf("first edit: %v", err) + } + if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + ID: sent.Message.ID, + Message: "second edit", + EditDate: 1700000383, + }); err != nil { + t.Fatalf("second edit: %v", err) + } + duplicate, found, err := channels.duplicateChannelMessage(ctx, channelID, owner.ID, sent.Message.RandomID) + if err != nil { + t.Fatalf("duplicate channel message: %v", err) + } + if !found || !duplicate.Duplicate || duplicate.Event.Type != domain.ChannelUpdateNewMessage || duplicate.Message.Body != "original" || duplicate.Event.Message.Body != "original" { + t.Fatalf("duplicate after edit = %+v found=%v, want original new-message snapshot", duplicate, found) + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: friend.ID, + ChannelID: channelID, + Pts: created.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "original" { + t.Fatalf("new messages = %+v, want original send snapshot", diff.NewMessages) + } + if len(diff.OtherUpdates) != 2 { + t.Fatalf("other updates = %+v, want two edit snapshots", diff.OtherUpdates) + } + if diff.OtherUpdates[0].Message.Body != "first edit" || diff.OtherUpdates[1].Message.Body != "second edit" { + t.Fatalf("edit snapshots = %q/%q, want first edit/second edit", diff.OtherUpdates[0].Message.Body, diff.OtherUpdates[1].Message.Body) + } +} + +func TestChannelStoreSendFailureBeforePtsAllocationDoesNotRecordNoopGap(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1888" + suffix + "01", + FirstName: "NoopOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + outsider, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1888" + suffix + "02", + FirstName: "NoopOutsider", + }) + if err != nil { + t.Fatalf("create outsider: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, outsider.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Noop Gap " + suffix, + Megagroup: true, + Date: 1700000400, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + _, err = channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: outsider.ID, + ChannelID: channelID, + RandomID: 991, + Message: "outsider should fail", + Date: 1700000401, + }) + if err == nil { + t.Fatal("SendChannelMessage outsider unexpectedly succeeded") + } + + var gapRows int + if err := pool.QueryRow(ctx, ` +SELECT count(*)::int +FROM channel_update_events +WHERE channel_id = $1 AND pts = 2`, channelID).Scan(&gapRows); err != nil { + t.Fatalf("count events after failed send: %v", err) + } + if gapRows != 0 { + t.Fatalf("events after failed send = %d, want no pts allocation before member validation", gapRows) + } + + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 992, + Message: "after noop gap", + Date: 1700000402, + }) + if err != nil { + t.Fatalf("send owner after gap: %v", err) + } + if sent.Event.Pts != 2 { + t.Fatalf("next channel pts = %d, want 2 after failed send before pts allocation", sent.Event.Pts) + } + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: owner.ID, + ChannelID: channelID, + Pts: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if diff.Pts != 2 || len(diff.Events) != 1 || diff.Events[0].Type != domain.ChannelUpdateNewMessage || diff.Events[0].Pts != 2 { + t.Fatalf("diff after failed send = %+v, want only message pts=2", diff) + } +} + +func TestChannelStoreDifferenceTooLongSnapshot(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 45, + Phone: "+1889" + suffix + "01", + FirstName: "TooLongOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 46, + Phone: "+1889" + suffix + "02", + FirstName: "TooLongFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "TooLong Snapshot " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000410, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + lastPts := created.Event.Pts + for i := 0; i < 12; i++ { + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: int64(10_000 + i), + Message: "too long snapshot", + Date: 1700000411 + i, + }) + if err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + lastPts = sent.Event.Pts + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: friend.ID, + ChannelID: channelID, + Pts: 0, + Limit: 3, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if !diff.TooLong || !diff.Final || diff.Pts != lastPts { + t.Fatalf("diff = %+v, want tooLong final snapshot at pts %d", diff, lastPts) + } + if len(diff.NewMessages) == 0 || len(diff.NewMessages) > domain.MaxChannelDifferenceTooLongMessages { + t.Fatalf("tooLong snapshot messages = %d, want bounded latest messages", len(diff.NewMessages)) + } + if diff.Dialog.TopMessageID == 0 || diff.Dialog.UnreadCount == 0 { + t.Fatalf("tooLong dialog = %+v, want current dialog state", diff.Dialog) + } +} + +func TestChannelStoreAdminLogFiltersAndSearch(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 51, + Phone: "+1999" + suffix + "01", + FirstName: "AdminLogOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1999" + suffix + "02", + FirstName: "AdminLogFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + invited, err := users.Create(ctx, domain.User{ + AccessHash: 53, + Phone: "+1999" + suffix + "03", + FirstName: "AdminLogInvited", + }) + if err != nil { + t.Fatalf("create invited: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, invited.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Admin Log " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000500, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: friend.ID, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + InviteUsers: true, + PinMessages: true, + }, + Rank: "ops", + Date: 1700000501, + }); err != nil { + t.Fatalf("edit admin: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 501, + Message: "needle admin log body", + Date: 1700000502, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := channels.UpdatePinnedMessage(ctx, domain.UpdateChannelPinnedMessageRequest{ + UserID: friend.ID, + ChannelID: channelID, + MessageID: sent.Message.ID, + Pinned: true, + Date: 1700000503, + }); err != nil { + t.Fatalf("pin message: %v", err) + } + if _, err := channels.InviteToChannel(ctx, channelID, friend.ID, []int64{invited.ID}, 1700000504); err != nil { + t.Fatalf("invite to channel: %v", err) + } + + searched, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: owner.ID, + ChannelID: channelID, + Query: "needle", + Limit: 10, + }) + if err != nil { + t.Fatalf("search admin log: %v", err) + } + if len(searched.Events) == 0 { + t.Fatalf("search admin log returned no events, want message body match") + } + + pinned, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: owner.ID, + ChannelID: channelID, + Filter: domain.ChannelAdminLogFilter{Pinned: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("pinned admin log: %v", err) + } + if len(pinned.Events) != 1 || pinned.Events[0].Type != domain.ChannelAdminLogUpdatePinned || pinned.Events[0].Message == nil { + t.Fatalf("pinned events = %+v, want one update_pinned with message", pinned.Events) + } + + byFriend, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: owner.ID, + ChannelID: channelID, + AdminUserIDs: []int64{friend.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("friend admin log: %v", err) + } + if len(byFriend.Events) == 0 { + t.Fatalf("friend admin log returned no events, want pin/invite") + } + for _, event := range byFriend.Events { + if event.UserID != friend.ID { + t.Fatalf("friend admin log event actor = %d, want %d in %+v", event.UserID, friend.ID, byFriend.Events) + } + } + + if _, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: invited.ID, + ChannelID: channelID, + Limit: 10, + }); err != domain.ErrChannelAdminRequired { + t.Fatalf("member admin log err = %v, want ErrChannelAdminRequired", err) + } +} + +func TestChannelStoreDeleteHistoryForEveryoneBatchesHugeMaxID(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 71, + Phone: "+1998" + suffix + "01", + FirstName: "BulkChannelOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 72, + Phone: "+1998" + suffix + "02", + FirstName: "BulkChannelFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Bulk Delete " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000600, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + total := domain.MaxDeleteHistoryBatch + 2 + if _, err := pool.Exec(ctx, ` +WITH src AS ( + SELECT generate_series(2, $3::int + 1) AS id +), +msgs AS ( + INSERT INTO channel_messages ( + channel_id, + id, + random_id, + sender_user_id, + from_peer_type, + from_peer_id, + message_date, + body, + entities, + pts + ) + SELECT + $1::bigint, + id, + 920000000 + id, + $2::bigint, + 'user', + $2::bigint, + 1700000600 + id, + 'bulk channel history', + '[]'::jsonb, + id + FROM src + RETURNING id, message_date +) +INSERT INTO channel_update_events ( + channel_id, + pts, + pts_count, + date, + event_type, + message_id, + sender_user_id, + payload +) +SELECT + $1::bigint, + id, + 1, + message_date, + 'new_channel_message', + id, + $2::bigint, + '{}'::jsonb +FROM msgs +`, channelID, owner.ID, total); err != nil { + t.Fatalf("seed bulk channel messages: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channels +SET top_message_id = $2, + pts = $2, + updated_at = now() +WHERE id = $1`, channelID, total+1); err != nil { + t.Fatalf("update channel bulk top: %v", err) + } + + first, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: channelID, + MaxID: int(^uint(0) >> 1), + ForEveryone: true, + Date: 1700000700, + }) + if err != nil { + t.Fatalf("DeleteChannelHistory first batch: %v", err) + } + wantFirstPts := total + 1 + domain.MaxDeleteHistoryBatch + if first.Offset != 1 || first.Event.Pts != wantFirstPts || first.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch { + t.Fatalf("first batch = %+v, want offset=1 pts=%d pts_count=%d", first, wantFirstPts, domain.MaxDeleteHistoryBatch) + } + var remaining int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&remaining); err != nil { + t.Fatalf("count remaining after first batch: %v", err) + } + if remaining != 3 { + t.Fatalf("remaining after first batch = %d, want create service + two oldest messages", remaining) + } + + second, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: channelID, + MaxID: int(^uint(0) >> 1), + ForEveryone: true, + Date: 1700000701, + }) + if err != nil { + t.Fatalf("DeleteChannelHistory second batch: %v", err) + } + if second.Offset != 0 || second.Event.Pts != wantFirstPts+3 || second.Event.PtsCount != 3 || len(second.DeletedIDs) != 3 { + t.Fatalf("second batch = %+v, want final offset=0 pts=%d pts_count=3", second, wantFirstPts+3) + } +} + +func TestChannelStoreCommonChannelsOnlySharedMegagroups(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 51, Phone: "+1888" + suffix + "01", FirstName: "CommonOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 52, Phone: "+1888" + suffix + "02", FirstName: "CommonFriend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + other, err := users.Create(ctx, domain.User{AccessHash: 53, Phone: "+1888" + suffix + "03", FirstName: "CommonOther"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, other.ID}) + }) + + channels := NewChannelStore(pool) + create := func(title string, broadcast bool, memberIDs []int64, date int) domain.CreateChannelResult { + t.Helper() + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: title, + Broadcast: broadcast, + Megagroup: !broadcast, + MemberUserIDs: memberIDs, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channelIDs = append(channelIDs, created.Channel.ID) + return created + } + first := create("common one "+suffix, false, []int64{friend.ID}, 1700000800) + second := create("common two "+suffix, false, []int64{friend.ID}, 1700000801) + create("broadcast excluded "+suffix, true, []int64{friend.ID}, 1700000802) + left := create("left excluded "+suffix, false, []int64{friend.ID}, 1700000803) + if _, err := channels.LeaveChannel(ctx, left.Channel.ID, friend.ID, 1700000804); err != nil { + t.Fatalf("leave channel: %v", err) + } + create("not shared "+suffix, false, []int64{other.ID}, 1700000805) + + page, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: owner.ID, + TargetUserID: friend.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list common channels: %v", err) + } + if page.Count != 2 || len(page.Channels) != 2 || page.Channels[0].ID != first.Channel.ID || page.Channels[1].ID != second.Channel.ID { + t.Fatalf("common channels = %+v, want two shared megagroups in id order", page) + } + + next, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: owner.ID, + TargetUserID: friend.ID, + MaxID: first.Channel.ID, + Limit: 1, + }) + if err != nil { + t.Fatalf("list common channels after max id: %v", err) + } + if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].ID != second.Channel.ID { + t.Fatalf("paged common channels = %+v, want second channel with full count", next) + } + + countOnly, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: owner.ID, + TargetUserID: friend.ID, + CountOnly: true, + }) + if err != nil { + t.Fatalf("count common channels: %v", err) + } + if countOnly.Count != 2 || len(countOnly.Channels) != 0 { + t.Fatalf("count-only common channels = %+v, want count without channels", countOnly) + } +} + +func TestChannelStoreLeftChannelsReturnsPagedLeftMemberships(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1889" + suffix + "01", FirstName: "LeftOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1889" + suffix + "02", FirstName: "LeftFriend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + create := func(title string, broadcast bool, date int) domain.CreateChannelResult { + t.Helper() + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: title, + Broadcast: broadcast, + Megagroup: !broadcast, + MemberUserIDs: []int64{friend.ID}, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channelIDs = append(channelIDs, created.Channel.ID) + return created + } + older := create("older left "+suffix, false, 1700000810) + newer := create("newer left "+suffix, true, 1700000811) + create("active excluded "+suffix, false, 1700000812) + if _, err := channels.LeaveChannel(ctx, older.Channel.ID, friend.ID, 1700000813); err != nil { + t.Fatalf("leave older channel: %v", err) + } + if _, err := channels.LeaveChannel(ctx, newer.Channel.ID, friend.ID, 1700000814); err != nil { + t.Fatalf("leave newer channel: %v", err) + } + + page, err := channels.ListLeftChannels(ctx, friend.ID, 0, 1) + if err != nil { + t.Fatalf("list left channels: %v", err) + } + if page.Count != 2 || len(page.Channels) != 1 || page.Channels[0].Channel.ID != newer.Channel.ID || page.Channels[0].Self.Status != domain.ChannelMemberLeft { + t.Fatalf("first left page = %+v, want newest left channel and full count", page) + } + next, err := channels.ListLeftChannels(ctx, friend.ID, 1, 1) + if err != nil { + t.Fatalf("list next left channels: %v", err) + } + if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].Channel.ID != older.Channel.ID { + t.Fatalf("second left page = %+v, want older left channel", next) + } + empty, err := channels.ListLeftChannels(ctx, friend.ID, 2, 1) + if err != nil { + t.Fatalf("list empty left page: %v", err) + } + if empty.Count != 2 || len(empty.Channels) != 0 { + t.Fatalf("empty left page = %+v, want full count and no chats", empty) + } + if _, err := channels.ListLeftChannels(ctx, friend.ID, domain.MaxLeftChannelsOffset+1, 1); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("huge offset err = %v, want ErrChannelInvalid", err) + } +} + +func TestChannelStoreDiscussionGroupLinksAreBidirectional(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1890" + suffix + "01", FirstName: "DiscussionOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + create := func(title string, broadcast bool, date int) domain.CreateChannelResult { + t.Helper() + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: title, + Broadcast: broadcast, + Megagroup: !broadcast, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channelIDs = append(channelIDs, created.Channel.ID) + return created + } + broadcast := create("discussion broadcast "+suffix, true, 1700000820) + firstGroup := create("discussion first "+suffix, false, 1700000821) + secondGroup := create("discussion second "+suffix, false, 1700000822) + + candidates, err := channels.ListDiscussionGroups(ctx, owner.ID, 10) + if err != nil { + t.Fatalf("list discussion groups: %v", err) + } + if len(candidates) < 2 || candidates[0].ID != secondGroup.Channel.ID || candidates[1].ID != firstGroup.Channel.ID { + t.Fatalf("discussion groups = %+v, want newest creator megagroups", candidates) + } + + linked, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, firstGroup.Channel.ID) + if err != nil { + t.Fatalf("link first discussion group: %v", err) + } + if len(linked.Channels) != 2 { + t.Fatalf("linked changed channels = %+v, want broadcast and group", linked.Channels) + } + gotBroadcast, err := channels.GetChannelByID(ctx, broadcast.Channel.ID) + if err != nil { + t.Fatalf("get linked broadcast: %v", err) + } + gotFirst, err := channels.GetChannelByID(ctx, firstGroup.Channel.ID) + if err != nil { + t.Fatalf("get linked first group: %v", err) + } + if gotBroadcast.LinkedChatID != firstGroup.Channel.ID || gotFirst.LinkedChatID != broadcast.Channel.ID { + t.Fatalf("first link = broadcast %d group %d, want bidirectional", gotBroadcast.LinkedChatID, gotFirst.LinkedChatID) + } + + replaced, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, secondGroup.Channel.ID) + if err != nil { + t.Fatalf("replace discussion group: %v", err) + } + if len(replaced.Channels) != 3 { + t.Fatalf("replace changed channels = %+v, want broadcast old group new group", replaced.Channels) + } + gotBroadcast, _ = channels.GetChannelByID(ctx, broadcast.Channel.ID) + gotFirst, _ = channels.GetChannelByID(ctx, firstGroup.Channel.ID) + gotSecond, err := channels.GetChannelByID(ctx, secondGroup.Channel.ID) + if err != nil { + t.Fatalf("get linked second group: %v", err) + } + if gotBroadcast.LinkedChatID != secondGroup.Channel.ID || gotSecond.LinkedChatID != broadcast.Channel.ID || gotFirst.LinkedChatID != 0 { + t.Fatalf("replace link = broadcast %d first %d second %d, want old cleared and new bidirectional", + gotBroadcast.LinkedChatID, gotFirst.LinkedChatID, gotSecond.LinkedChatID) + } + + if _, err := channels.SetDiscussionGroup(ctx, owner.ID, 0, secondGroup.Channel.ID); err != nil { + t.Fatalf("unlink from group side: %v", err) + } + gotBroadcast, _ = channels.GetChannelByID(ctx, broadcast.Channel.ID) + gotSecond, _ = channels.GetChannelByID(ctx, secondGroup.Channel.ID) + if gotBroadcast.LinkedChatID != 0 || gotSecond.LinkedChatID != 0 { + t.Fatalf("unlink = broadcast %d group %d, want both cleared", gotBroadcast.LinkedChatID, gotSecond.LinkedChatID) + } + if _, err := channels.SetDiscussionGroup(ctx, owner.ID, 0, secondGroup.Channel.ID); !errors.Is(err, domain.ErrLinkNotModified) { + t.Fatalf("repeat unlink err = %v, want ErrLinkNotModified", err) + } + if _, err := channels.SetPreHistoryHidden(ctx, owner.ID, firstGroup.Channel.ID, true); err != nil { + t.Fatalf("hide first group prehistory: %v", err) + } + if _, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, firstGroup.Channel.ID); !errors.Is(err, domain.ErrMegagroupPrehistoryHidden) { + t.Fatalf("hidden prehistory err = %v, want ErrMegagroupPrehistoryHidden", err) + } +} + +func TestChannelStoreReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 81, + Phone: "+1891" + suffix + "01", + FirstName: "ReactionOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 82, + Phone: "+1891" + suffix + "02", + FirstName: "ReactionFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Visible Reaction " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000900, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 90_001, + Message: "react to this", + Date: 1700000901, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: friend.ID, + ChannelID: channelID, + MessageID: sent.Message.ID, + Reactions: []domain.MessageReaction{{ + Type: domain.MessageReactionEmoji, + Emoticon: "\U0001f525", + }}, + Date: 1700000902, + }); err != nil { + t.Fatalf("set channel reaction: %v", err) + } + dialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 1 { + t.Fatalf("owner dialogs = %+v, want one unread reaction", dialogs.Dialogs) + } + unread, err := channels.ListChannelUnreadReactions(ctx, owner.ID, domain.ChannelUnreadReactionsFilter{ + ChannelID: channelID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread reactions: %v", err) + } + if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID || unread.Messages[0].Reactions == nil || !hasUnreadChannelReactionPG(*unread.Messages[0].Reactions) { + t.Fatalf("unread reactions = %+v, want unread sent message", unread.Messages) + } + + read, err := channels.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{sent.Message.ID}, + }) + if err != nil { + t.Fatalf("read channel message contents: %v", err) + } + if !reflect.DeepEqual(read.ClearedUnreadReactionMessageIDs, []int{sent.Message.ID}) { + t.Fatalf("cleared reaction ids = %+v, want [%d]", read.ClearedUnreadReactionMessageIDs, sent.Message.ID) + } + if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || hasUnreadChannelReactionPG(*read.Messages[0].Reactions) { + t.Fatalf("read messages = %+v, want returned reaction marked read", read.Messages) + } + unreadAfter, err := channels.ListChannelUnreadReactions(ctx, owner.ID, domain.ChannelUnreadReactionsFilter{ + ChannelID: channelID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread reactions after read contents: %v", err) + } + if len(unreadAfter.Messages) != 0 { + t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter.Messages) + } + dialogsAfter, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner dialogs after read contents: %v", err) + } + if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 { + t.Fatalf("owner dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs) + } + var stillUnread bool + if err := pool.QueryRow(ctx, ` +SELECT unread +FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, + channelID, sent.Message.ID, friend.ID).Scan(&stillUnread); err != nil { + t.Fatalf("read reaction row: %v", err) + } + if stillUnread { + t.Fatal("reaction row still unread after read contents") + } +} + +func hasUnreadChannelReactionPG(reactions domain.ChannelMessageReactions) bool { + for _, recent := range reactions.Recent { + if recent.Unread { + return true + } + } + return false +} diff --git a/internal/store/postgres/contact.go b/internal/store/postgres/contact.go new file mode 100644 index 00000000..a453b112 --- /dev/null +++ b/internal/store/postgres/contact.go @@ -0,0 +1,393 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "hash/fnv" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// ContactStore 用 PostgreSQL 实现 store.ContactStore。 +type ContactStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewContactStore 基于 pgx 连接池(或事务)创建 ContactStore。 +func NewContactStore(db sqlcgen.DBTX) *ContactStore { + return &ContactStore{db: db, q: sqlcgen.New(db)} +} + +func (s *ContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) { + rows, err := s.q.ListContactsByUser(ctx, userID) + if err != nil { + return domain.ContactList{}, fmt.Errorf("list contacts: %w", err) + } + out := domain.ContactList{Contacts: make([]domain.Contact, 0, len(rows))} + for _, row := range rows { + contact, err := contactFromListRow(row) + if err != nil { + return domain.ContactList{}, err + } + out.Contacts = append(out.Contacts, contact) + } + out.Hash = contactListHash(out.Contacts) + return out, nil +} + +func (s *ContactStore) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) { + row, err := s.q.GetContact(ctx, sqlcgen.GetContactParams{ + UserID: userID, + ContactUserID: contactUserID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Contact{}, false, nil + } + return domain.Contact{}, false, fmt.Errorf("get contact: %w", err) + } + contact, err := contactFromGetRow(row) + if err != nil { + return domain.Contact{}, false, err + } + return contact, true, nil +} + +func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { + entities, err := encodeMessageEntities(input.NoteEntities) + if err != nil { + return domain.Contact{}, err + } + row, err := s.q.UpsertContact(ctx, sqlcgen.UpsertContactParams{ + UserID: userID, + ContactUserID: input.ContactUserID, + ContactPhone: input.Phone, + ContactFirstName: input.FirstName, + ContactLastName: input.LastName, + Note: input.Note, + NoteEntities: entities, + }) + if err != nil { + return domain.Contact{}, fmt.Errorf("upsert contact: %w", err) + } + contact, err := contactFromUpsertRow(row) + if err != nil { + return domain.Contact{}, err + } + return contact, nil +} + +const upsertContactsManySQL = ` +WITH input AS ( + SELECT + $1::bigint AS user_id, + i.contact_user_id, + i.contact_phone, + i.contact_first_name, + i.contact_last_name, + i.note, + i.note_entities_json::jsonb AS note_entities, + i.ord + FROM unnest( + $2::bigint[], + $3::text[], + $4::text[], + $5::text[], + $6::text[], + $7::text[] + ) WITH ORDINALITY AS i(contact_user_id, contact_phone, contact_first_name, contact_last_name, note, note_entities_json, ord) +), +reverse AS ( + SELECT + i.contact_user_id, + EXISTS ( + SELECT 1 + FROM contacts c + WHERE c.user_id = i.contact_user_id + AND c.contact_user_id = i.user_id + )::boolean AS mutual + FROM input i +), +upserted AS ( + INSERT INTO contacts ( + user_id, + contact_user_id, + contact_phone, + contact_first_name, + contact_last_name, + note, + note_entities, + mutual + ) + SELECT + i.user_id, + i.contact_user_id, + i.contact_phone, + i.contact_first_name, + i.contact_last_name, + i.note, + i.note_entities, + r.mutual + FROM input i + JOIN reverse r ON r.contact_user_id = i.contact_user_id + ON CONFLICT (user_id, contact_user_id) DO UPDATE SET + contact_phone = EXCLUDED.contact_phone, + contact_first_name = EXCLUDED.contact_first_name, + contact_last_name = EXCLUDED.contact_last_name, + note = EXCLUDED.note, + note_entities = EXCLUDED.note_entities, + mutual = contacts.mutual OR EXCLUDED.mutual, + updated_at = now() + RETURNING * +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = true, + updated_at = now() + FROM upserted u + WHERE c.user_id = u.contact_user_id + AND c.contact_user_id = $1::bigint + AND NOT c.mutual + RETURNING c.user_id +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed +FROM upserted c +JOIN users u ON u.id = c.contact_user_id +JOIN input i ON i.contact_user_id = c.contact_user_id +ORDER BY i.ord +` + +func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) { + if len(inputs) == 0 { + return nil, nil + } + contactUserIDs := make([]int64, 0, len(inputs)) + phones := make([]string, 0, len(inputs)) + firstNames := make([]string, 0, len(inputs)) + lastNames := make([]string, 0, len(inputs)) + notes := make([]string, 0, len(inputs)) + noteEntities := make([]string, 0, len(inputs)) + for _, input := range inputs { + if input.ContactUserID == 0 { + continue + } + raw, err := encodeMessageEntities(input.NoteEntities) + if err != nil { + return nil, err + } + contactUserIDs = append(contactUserIDs, input.ContactUserID) + phones = append(phones, input.Phone) + firstNames = append(firstNames, input.FirstName) + lastNames = append(lastNames, input.LastName) + notes = append(notes, input.Note) + noteEntities = append(noteEntities, string(raw)) + } + if len(contactUserIDs) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, upsertContactsManySQL, userID, contactUserIDs, phones, firstNames, lastNames, notes, noteEntities) + if err != nil { + return nil, fmt.Errorf("upsert contacts many: %w", err) + } + defer rows.Close() + out := make([]domain.Contact, 0, len(contactUserIDs)) + for rows.Next() { + var ( + contactUserID int64 + mutual bool + contactPhone string + contactFirstName string + contactLastName string + note string + noteEntitiesJSON string + id int64 + accessHash int64 + phone string + firstName string + lastName string + username string + countryCode string + verified bool + support bool + lastSeenAt int64 + reverseMutualChanged bool + ) + if err := rows.Scan( + &contactUserID, + &mutual, + &contactPhone, + &contactFirstName, + &contactLastName, + ¬e, + ¬eEntitiesJSON, + &id, + &accessHash, + &phone, + &firstName, + &lastName, + &username, + &countryCode, + &verified, + &support, + &lastSeenAt, + &reverseMutualChanged, + ); err != nil { + return nil, fmt.Errorf("scan upsert contacts many: %w", err) + } + _ = reverseMutualChanged + entities, err := decodeMessageEntities(noteEntitiesJSON) + if err != nil { + return nil, fmt.Errorf("decode contact note entities: %w", err) + } + out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate upsert contacts many: %w", err) + } + return out, nil +} + +func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) { + raw, err := encodeMessageEntities(entities) + if err != nil { + return domain.Contact{}, false, err + } + row, err := s.q.UpdateContactNote(ctx, sqlcgen.UpdateContactNoteParams{ + UserID: userID, + ContactUserID: contactUserID, + Note: note, + NoteEntities: raw, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Contact{}, false, nil + } + return domain.Contact{}, false, fmt.Errorf("update contact note: %w", err) + } + contact, err := contactFromUpdateNoteRow(row) + if err != nil { + return domain.Contact{}, false, err + } + return contact, true, nil +} + +func (s *ContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) { + if len(contactUserIDs) == 0 { + return 0, nil + } + count, err := s.q.DeleteContacts(ctx, sqlcgen.DeleteContactsParams{ + UserID: userID, + ContactUserIds: contactUserIDs, + }) + if err != nil { + return 0, fmt.Errorf("delete contacts: %w", err) + } + return int(count), nil +} + +func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, error) { + entities, err := decodeMessageEntities(row.NoteEntitiesJson) + if err != nil { + return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) + } + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil +} + +func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) { + entities, err := decodeMessageEntities(row.NoteEntitiesJson) + if err != nil { + return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) + } + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil +} + +func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) { + entities, err := decodeMessageEntities(row.NoteEntitiesJson) + if err != nil { + return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) + } + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil +} + +func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) { + entities, err := decodeMessageEntities(row.NoteEntitiesJson) + if err != nil { + return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) + } + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil +} + +func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support bool, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual bool) domain.Contact { + return domain.Contact{ + User: domain.User{ + ID: id, + AccessHash: accessHash, + Phone: phone, + FirstName: firstName, + LastName: lastName, + Username: username, + CountryCode: countryCode, + Verified: verified, + Support: support, + LastSeenAt: lastSeenAt, + Contact: true, + Mutual: mutual, + }, + FirstName: contactFirstName, + LastName: contactLastName, + Phone: contactPhone, + Note: note, + NoteEntities: noteEntities, + Mutual: mutual, + } +} + +func contactListHash(contacts []domain.Contact) int64 { + if len(contacts) == 0 { + return 0 + } + h := fnv.New64a() + var buf [16]byte + for _, c := range contacts { + binary.LittleEndian.PutUint64(buf[:8], uint64(c.User.ID)) + if c.Mutual { + buf[8] = 1 + } else { + buf[8] = 0 + } + _, _ = h.Write(buf[:9]) + _, _ = h.Write([]byte(c.FirstName)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(c.LastName)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(c.Phone)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(c.Note)) + _, _ = h.Write([]byte{0}) + } + return int64(h.Sum64()) +} diff --git a/internal/store/postgres/contact_dialog_integration_test.go b/internal/store/postgres/contact_dialog_integration_test.go new file mode 100644 index 00000000..9cd9838e --- /dev/null +++ b/internal/store/postgres/contact_dialog_integration_test.go @@ -0,0 +1,279 @@ +package postgres + +import ( + "context" + "testing" + + contactsapp "telesrv/internal/app/contacts" + "telesrv/internal/domain" +) + +func TestContactProfilesOwnerScopedRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1900"+suffix+"01", "Owner", "One") + altOwner := createTestUser(t, ctx, users, "+1900"+suffix+"02", "AltOwner", "Two") + friend := createTestUser(t, ctx, users, "+1900"+suffix+"03", "Canonical", "Friend") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, altOwner.ID, friend.ID}) + }) + + contactStore := NewContactStore(pool) + contacts := contactsapp.NewService(contactStore, users) + if _, err := contacts.AddContact(ctx, owner.ID, domain.ContactInput{ + ContactUserID: friend.ID, + Phone: "10001", + FirstName: "OwnerRemark", + LastName: "A", + Note: "first note", + }); err != nil { + t.Fatalf("owner add contact: %v", err) + } + if _, err := contacts.AddContact(ctx, altOwner.ID, domain.ContactInput{ + ContactUserID: friend.ID, + Phone: "20002", + FirstName: "AltRemark", + LastName: "B", + Note: "alt note", + }); err != nil { + t.Fatalf("alt owner add contact: %v", err) + } + + ownerList, _, err := contacts.GetContacts(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("owner contacts: %v", err) + } + altList, _, err := contacts.GetContacts(ctx, altOwner.ID, 0) + if err != nil { + t.Fatalf("alt contacts: %v", err) + } + if got := ownerList.Contacts[0].User.FirstName; got != "OwnerRemark" { + t.Fatalf("owner contact first name = %q, want owner remark", got) + } + if got := altList.Contacts[0].User.FirstName; got != "AltRemark" { + t.Fatalf("alt contact first name = %q, want alt remark", got) + } + if ownerList.Hash == 0 || altList.Hash == 0 || ownerList.Hash == altList.Hash { + t.Fatalf("contact hashes owner=%d alt=%d, want non-zero owner-specific hashes", ownerList.Hash, altList.Hash) + } + + beforeHash := ownerList.Hash + updated, err := contacts.UpdateContactNote(ctx, owner.ID, friend.ID, "fresh note", []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: 5}, + }) + if err != nil { + t.Fatalf("update contact note: %v", err) + } + if updated.Note != "fresh note" || len(updated.NoteEntities) != 1 { + t.Fatalf("updated contact = %+v, want note with entity", updated) + } + ownerList, _, err = contacts.GetContacts(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("owner contacts after note: %v", err) + } + if ownerList.Hash == beforeHash { + t.Fatalf("owner contact hash did not change after note update: %d", ownerList.Hash) + } + altList, _, err = contacts.GetContacts(ctx, altOwner.ID, 0) + if err != nil { + t.Fatalf("alt contacts after owner note: %v", err) + } + if altList.Contacts[0].Note != "alt note" { + t.Fatalf("alt contact note = %q, want isolated alt note", altList.Contacts[0].Note) + } + + if _, err := contacts.AddContact(ctx, friend.ID, domain.ContactInput{ + ContactUserID: owner.ID, + FirstName: "OwnerBack", + }); err != nil { + t.Fatalf("friend reciprocal add: %v", err) + } + ownerContact, found, err := contactStore.Get(ctx, owner.ID, friend.ID) + if err != nil { + t.Fatalf("get owner contact: %v", err) + } + if !found || !ownerContact.Mutual { + t.Fatalf("owner contact = %+v found=%v, want mutual after reciprocal add", ownerContact, found) + } + friendContact, found, err := contactStore.Get(ctx, friend.ID, owner.ID) + if err != nil { + t.Fatalf("get friend contact: %v", err) + } + if !found || !friendContact.Mutual { + t.Fatalf("friend contact = %+v found=%v, want mutual after reciprocal add", friendContact, found) + } + + deleted, err := contacts.DeleteContacts(ctx, owner.ID, []int64{friend.ID}) + if err != nil { + t.Fatalf("delete owner contact: %v", err) + } + if deleted != 1 { + t.Fatalf("deleted = %d, want 1", deleted) + } + if _, found, err := contactStore.Get(ctx, owner.ID, friend.ID); err != nil || found { + t.Fatalf("owner contact after delete found=%v err=%v, want not found", found, err) + } + friendContact, found, err = contactStore.Get(ctx, friend.ID, owner.ID) + if err != nil { + t.Fatalf("get friend contact after delete: %v", err) + } + if !found || friendContact.Mutual { + t.Fatalf("friend contact after delete = %+v found=%v, want reverse mutual cleared", friendContact, found) + } +} + +func TestDialogUserViewUsesContactProfileAndDialogFlags(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + ownerA := createTestUser(t, ctx, users, "+1910"+suffix+"01", "OwnerA", "") + ownerB := createTestUser(t, ctx, users, "+1910"+suffix+"02", "OwnerB", "") + friend := createTestUser(t, ctx, users, "+1910"+suffix+"03", "Shared", "Friend") + other := createTestUser(t, ctx, users, "+1910"+suffix+"04", "Other", "Peer") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{ownerA.ID, ownerB.ID, friend.ID, other.ID}) + }) + + contacts := contactsapp.NewService(NewContactStore(pool), users) + if _, err := contacts.AddContact(ctx, ownerA.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "RemarkA"}); err != nil { + t.Fatalf("owner A add contact: %v", err) + } + if _, err := contacts.AddContact(ctx, ownerB.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "RemarkB"}); err != nil { + t.Fatalf("owner B add contact: %v", err) + } + + messages := NewMessageStore(pool) + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: friend.ID, + RecipientUserID: ownerA.ID, + RandomID: 301, + Message: "to owner A", + Date: 1700000301, + }); err != nil { + t.Fatalf("send to owner A: %v", err) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: friend.ID, + RecipientUserID: ownerB.ID, + RandomID: 302, + Message: "to owner B", + Date: 1700000302, + }); err != nil { + t.Fatalf("send to owner B: %v", err) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: other.ID, + RecipientUserID: ownerA.ID, + RandomID: 303, + Message: "second dialog", + Date: 1700000303, + }); err != nil { + t.Fatalf("send second dialog: %v", err) + } + + dialogs := NewDialogStore(pool) + listA, err := dialogs.ListByUser(ctx, ownerA.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list owner A dialogs: %v", err) + } + listB, err := dialogs.ListByUser(ctx, ownerB.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list owner B dialogs: %v", err) + } + userA, ok := findDialogUserByID(listA.Users, friend.ID) + if !ok || userA.FirstName != "RemarkA" || !userA.Contact { + t.Fatalf("owner A dialog user = %+v found=%v, want RemarkA contact", userA, ok) + } + userB, ok := findDialogUserByID(listB.Users, friend.ID) + if !ok || userB.FirstName != "RemarkB" || !userB.Contact { + t.Fatalf("owner B dialog user = %+v found=%v, want RemarkB contact", userB, ok) + } + + friendPeer := domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID} + otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} + if changed, err := dialogs.SetPinned(ctx, ownerA.ID, friendPeer, true); err != nil || !changed { + t.Fatalf("pin friend changed=%v err=%v, want changed", changed, err) + } + if changed, err := dialogs.SetPinned(ctx, ownerA.ID, otherPeer, true); err != nil || !changed { + t.Fatalf("pin other changed=%v err=%v, want changed", changed, err) + } + if err := dialogs.ReorderPinned(ctx, ownerA.ID, []domain.Peer{otherPeer, friendPeer}, true); err != nil { + t.Fatalf("reorder pinned: %v", err) + } + pinned, err := dialogs.ListByUser(ctx, ownerA.ID, domain.DialogFilter{PinnedOnly: true, Limit: 10}) + if err != nil { + t.Fatalf("list pinned dialogs: %v", err) + } + if len(pinned.Dialogs) != 2 || pinned.Dialogs[0].Peer != otherPeer || pinned.Dialogs[0].PinnedOrder != 1 || pinned.Dialogs[1].Peer != friendPeer || pinned.Dialogs[1].PinnedOrder != 2 { + t.Fatalf("pinned dialogs = %+v, want other then friend with stable order", pinned.Dialogs) + } + + if changed, err := dialogs.SetUnreadMark(ctx, ownerA.ID, friendPeer, true); err != nil || !changed { + t.Fatalf("mark unread changed=%v err=%v, want changed", changed, err) + } + marks, err := dialogs.ListUnreadMarked(ctx, ownerA.ID) + if err != nil { + t.Fatalf("list unread marks: %v", err) + } + if !containsPeer(marks, friendPeer) { + t.Fatalf("unread marks = %+v, want friend peer", marks) + } + if _, err := dialogs.MarkRead(ctx, ownerA.ID, friendPeer, 0); err != nil { + t.Fatalf("mark read: %v", err) + } + peerDialogs, err := dialogs.ListByPeers(ctx, ownerA.ID, []domain.Peer{friendPeer}) + if err != nil { + t.Fatalf("list peer dialogs after read: %v", err) + } + if len(peerDialogs.Dialogs) != 1 || peerDialogs.Dialogs[0].UnreadMark { + t.Fatalf("peer dialog after read = %+v, want unread mark cleared", peerDialogs.Dialogs) + } + + if changed, err := dialogs.SetPeerSettingsBarHidden(ctx, ownerA.ID, friendPeer); err != nil || !changed { + t.Fatalf("hide peer settings bar changed=%v err=%v, want changed", changed, err) + } + hidden, err := dialogs.PeerSettingsBarHidden(ctx, ownerA.ID, friendPeer) + if err != nil { + t.Fatalf("peer settings bar hidden: %v", err) + } + if !hidden { + t.Fatal("peer settings bar hidden = false, want true") + } +} + +func createTestUser(t *testing.T, ctx context.Context, users *UserStore, phone, firstName, lastName string) domain.User { + t.Helper() + user, err := users.Create(ctx, domain.User{ + AccessHash: int64(len(phone) + len(firstName)*100 + len(lastName)*1000), + Phone: phone, + FirstName: firstName, + LastName: lastName, + }) + if err != nil { + t.Fatalf("create user %s: %v", phone, err) + } + return user +} + +func findDialogUserByID(users []domain.User, id int64) (domain.User, bool) { + for _, user := range users { + if user.ID == id { + return user, true + } + } + return domain.User{}, false +} + +func containsPeer(peers []domain.Peer, want domain.Peer) bool { + for _, peer := range peers { + if peer == want { + return true + } + } + return false +} diff --git a/internal/store/postgres/contiguous_pts_integration_test.go b/internal/store/postgres/contiguous_pts_integration_test.go new file mode 100644 index 00000000..f01a9061 --- /dev/null +++ b/internal/store/postgres/contiguous_pts_integration_test.go @@ -0,0 +1,127 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestMaxContiguousPtsStopsAtHole 用真实 PG 验证 RecentUserPts 窗口查询 + 连续计算: +// 存在在途空洞时只报告最大连续 pts,补洞后回升。 +func TestMaxContiguousPtsStopsAtHole(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + suffix := randomSuffix(t) + owner, err := NewUserStore(pool).Create(ctx, domain.User{ + AccessHash: 1, + Phone: "+1556" + suffix + "01", + FirstName: "Contig", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + events := NewUpdateEventStore(pool) + appendPts := func(pts int) { + if err := events.Append(ctx, owner.ID, domain.UpdateEvent{ + Type: domain.UpdateEventNoop, + Pts: pts, + PtsCount: 1, + Date: 1700000000 + pts, + }); err != nil { + t.Fatalf("append pts=%d: %v", pts, err) + } + } + + for _, p := range []int{1, 2, 3, 5, 6} { // pts=4 为在途空洞 + appendPts(p) + } + if _, err := pool.Exec(ctx, "DELETE FROM user_update_watermarks WHERE user_id = $1", owner.ID); err != nil { + t.Fatalf("delete watermark fallback row: %v", err) + } + got, err := events.MaxContiguousPts(ctx, owner.ID) + if err != nil { + t.Fatalf("MaxContiguousPts: %v", err) + } + if got != 3 { + t.Fatalf("contiguous = %d, want 3(止于 pts=4 空洞,最大已提交为 6)", got) + } + + appendPts(4) // 在途事务提交/补洞 + got, err = events.MaxContiguousPts(ctx, owner.ID) + if err != nil { + t.Fatalf("MaxContiguousPts after fill: %v", err) + } + if got != 6 { + t.Fatalf("contiguous after fill = %d, want 6", got) + } +} + +func TestAppendWithDispatchWritesEventAndOutboxAtomically(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + suffix := randomSuffix(t) + owner, err := NewUserStore(pool).Create(ctx, domain.User{ + AccessHash: 2, + Phone: "+1557" + suffix + "01", + FirstName: "Dispatch", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = $1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + event := domain.UpdateEvent{ + UserID: owner.ID, + Type: domain.UpdateEventDialogPinned, + Pts: 1, + PtsCount: 1, + Date: 1700000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + Settings: domain.PeerSettings{ + ShareContact: true, + }, + Bool: true, + } + var excludeAuthKeyID [8]byte + excludeAuthKeyID[0] = 9 + if err := NewUpdateEventStore(pool).AppendWithDispatch(ctx, owner.ID, event, excludeAuthKeyID, 77); err != nil { + t.Fatalf("AppendWithDispatch: %v", err) + } + + got, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, 0, 10) + if err != nil { + t.Fatalf("ListAfter: %v", err) + } + if len(got) != 1 || got[0].Type != event.Type || got[0].Peer != event.Peer || !got[0].Bool { + t.Fatalf("events = %+v, want dialog pinned event", got) + } + + var outbox struct { + pts int + eventType string + excludeAuthKeyID int64 + excludeSessionID int64 + } + if err := pool.QueryRow(ctx, ` + SELECT pts, event_type, exclude_auth_key_id, exclude_session_id + FROM dispatch_outbox + WHERE target_user_id = $1 + `, owner.ID).Scan(&outbox.pts, &outbox.eventType, &outbox.excludeAuthKeyID, &outbox.excludeSessionID); err != nil { + t.Fatalf("query dispatch outbox: %v", err) + } + if outbox.pts != 1 || outbox.eventType != string(domain.UpdateEventDialogPinned) || outbox.excludeAuthKeyID != authKeyIDToInt64(excludeAuthKeyID) || outbox.excludeSessionID != 77 { + t.Fatalf("outbox = %+v, want event dispatch excluding current session", outbox) + } +} diff --git a/internal/store/postgres/counter_source.go b/internal/store/postgres/counter_source.go new file mode 100644 index 00000000..b8345da1 --- /dev/null +++ b/internal/store/postgres/counter_source.go @@ -0,0 +1,78 @@ +package postgres + +import ( + "context" + "fmt" + + "telesrv/internal/store/postgres/sqlcgen" +) + +// MessageBoxCounterSource 从 message_boxes durable log 恢复某 owner 的当前最大 box_id。 +type MessageBoxCounterSource struct { + q *sqlcgen.Queries +} + +// NewMessageBoxCounterSource 创建 Redis BoxIDAllocator 的 PG 恢复源。 +func NewMessageBoxCounterSource(db sqlcgen.DBTX) *MessageBoxCounterSource { + return &MessageBoxCounterSource{q: sqlcgen.New(db)} +} + +func (s *MessageBoxCounterSource) Current(ctx context.Context, userID int64) (int, error) { + v, err := s.q.MaxMessageBoxID(ctx, userID) + if err != nil { + return 0, fmt.Errorf("max message box id: %w", err) + } + return int(v), nil +} + +// ChannelIDCounterSource 从 channels durable 表恢复全局 channel id。 +type ChannelIDCounterSource struct { + db sqlcgen.DBTX +} + +// NewChannelIDCounterSource 创建 Redis ChannelIDAllocator 的 PG 恢复源。 +func NewChannelIDCounterSource(db sqlcgen.DBTX) *ChannelIDCounterSource { + return &ChannelIDCounterSource{db: db} +} + +func (s *ChannelIDCounterSource) Current(ctx context.Context, _ int64) (int, error) { + var id int + if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&id); err != nil { + return 0, fmt.Errorf("max channel id: %w", err) + } + return id, nil +} + +// ChannelPtsCounterSource 从 channel_update_events 恢复某 channel 的当前最大 pts。 +type ChannelPtsCounterSource struct { + db sqlcgen.DBTX +} + +func NewChannelPtsCounterSource(db sqlcgen.DBTX) *ChannelPtsCounterSource { + return &ChannelPtsCounterSource{db: db} +} + +func (s *ChannelPtsCounterSource) Current(ctx context.Context, channelID int64) (int, error) { + var pts int + if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts); err != nil { + return 0, fmt.Errorf("max channel pts: %w", err) + } + return pts, nil +} + +// ChannelMessageIDCounterSource 从 channel_messages 恢复某 channel 的当前最大 message id。 +type ChannelMessageIDCounterSource struct { + db sqlcgen.DBTX +} + +func NewChannelMessageIDCounterSource(db sqlcgen.DBTX) *ChannelMessageIDCounterSource { + return &ChannelMessageIDCounterSource{db: db} +} + +func (s *ChannelMessageIDCounterSource) Current(ctx context.Context, channelID int64) (int, error) { + var id int + if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id); err != nil { + return 0, fmt.Errorf("max channel message id: %w", err) + } + return id, nil +} diff --git a/internal/store/postgres/dialog.go b/internal/store/postgres/dialog.go new file mode 100644 index 00000000..8046040e --- /dev/null +++ b/internal/store/postgres/dialog.go @@ -0,0 +1,722 @@ +package postgres + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// DialogStore 用 PostgreSQL 实现 store.DialogStore。 +type DialogStore struct { + q *sqlcgen.Queries +} + +// NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。 +func NewDialogStore(db sqlcgen.DBTX) *DialogStore { + return &DialogStore{q: sqlcgen.New(db)} +} + +func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + offsetPeerID := int64(0) + if filter.HasOffsetPeer { + offsetPeerID = filter.OffsetPeer.ID + } + folderParams := dialogFolderQueryParams(filter.Folder) + summaryRows, err := s.q.ListDialogSummaryByUser(ctx, sqlcgen.ListDialogSummaryByUserParams{ + UserID: userID, + HasFolderID: filter.HasFolderID, + FolderID: pgInt32NonNegative(filter.FolderID), + FolderExcludeArchived: folderParams.excludeArchived, + FolderExcludeRead: folderParams.excludeRead, + FolderExcludePeerTypes: folderParams.excludeTypes, + FolderExcludePeerIds: folderParams.excludeIDs, + FolderIncludePeerTypes: folderParams.includeTypes, + FolderIncludePeerIds: folderParams.includeIDs, + FolderPinnedPeerTypes: folderParams.pinnedTypes, + FolderPinnedPeerIds: folderParams.pinnedIDs, + FolderContacts: folderParams.contacts, + FolderNonContacts: folderParams.nonContacts, + PinnedOnly: filter.PinnedOnly, + ExcludePinned: filter.ExcludePinned, + }) + if err != nil { + return domain.DialogList{}, fmt.Errorf("list dialog summary: %w", err) + } + summary := make([]domain.Dialog, 0, len(summaryRows)) + for _, row := range summaryRows { + summary = append(summary, domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + FolderID: int(row.FolderID), + TopMessage: int(row.TopMessageID), + TopMessageDate: int(row.TopMessageDate), + ReadInboxMaxID: int(row.ReadInboxMaxID), + ReadOutboxMaxID: int(row.ReadOutboxMaxID), + UnreadCount: int(row.UnreadCount), + UnreadMentions: int(row.UnreadMentionsCount), + UnreadReactions: int(row.UnreadReactionsCount), + Pinned: row.Pinned, + PinnedOrder: int(row.PinnedOrder), + UnreadMark: row.UnreadMark, + PeerSettingsBarHidden: row.HiddenPeerSettingsBar, + }) + } + out := domain.DialogList{ + Dialogs: make([]domain.Dialog, 0, limit), + Count: len(summary), + Hash: dialogListHash(summary), + } + if len(summary) == 0 { + return out, nil + } + rows, err := s.q.ListDialogsByUser(ctx, sqlcgen.ListDialogsByUserParams{ + UserID: userID, + LimitCount: int32(limit), + HasFolderID: filter.HasFolderID, + FolderID: pgInt32NonNegative(filter.FolderID), + FolderExcludeArchived: folderParams.excludeArchived, + FolderExcludeRead: folderParams.excludeRead, + FolderExcludePeerTypes: folderParams.excludeTypes, + FolderExcludePeerIds: folderParams.excludeIDs, + FolderIncludePeerTypes: folderParams.includeTypes, + FolderIncludePeerIds: folderParams.includeIDs, + FolderPinnedPeerTypes: folderParams.pinnedTypes, + FolderPinnedPeerIds: folderParams.pinnedIDs, + FolderContacts: folderParams.contacts, + FolderNonContacts: folderParams.nonContacts, + PinnedOnly: filter.PinnedOnly, + ExcludePinned: filter.ExcludePinned, + OffsetID: pgInt32NonNegative(filter.OffsetID), + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + HasOffsetPeer: filter.HasOffsetPeer, + OffsetPeerID: offsetPeerID, + }) + if err != nil { + return domain.DialogList{}, fmt.Errorf("list dialogs: %w", err) + } + out.Messages = make([]domain.Message, 0, len(rows)) + out.Users = make([]domain.User, 0, len(rows)) + seenUsers := map[int64]struct{}{} + for _, row := range rows { + dialog := domain.Dialog{ + Peer: domain.Peer{ + Type: domain.PeerType(row.PeerType), + ID: row.PeerID, + }, + FolderID: int(row.FolderID), + TopMessage: int(row.TopMessageID), + TopMessageDate: int(row.TopMessageDate), + ReadInboxMaxID: int(row.ReadInboxMaxID), + ReadOutboxMaxID: int(row.ReadOutboxMaxID), + UnreadCount: int(row.UnreadCount), + UnreadMentions: int(row.UnreadMentionsCount), + UnreadReactions: int(row.UnreadReactionsCount), + Pinned: row.Pinned, + PinnedOrder: int(row.PinnedOrder), + UnreadMark: row.UnreadMark, + PeerSettingsBarHidden: row.HiddenPeerSettingsBar, + } + out.Dialogs = append(out.Dialogs, dialog) + if row.PeerUserID != 0 { + if _, ok := seenUsers[row.PeerUserID]; !ok { + seenUsers[row.PeerUserID] = struct{}{} + out.Users = append(out.Users, domain.User{ + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + LastSeenAt: int(row.PeerLastSeenAt), + Contact: row.PeerContact, + Mutual: row.PeerMutual, + }) + } + } + if row.MessageID != 0 { + entities, err := decodeMessageEntities(row.MessageEntitiesJson) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err) + } + out.Messages = append(out.Messages, domain.Message{ + ID: int(row.MessageID), + OwnerUserID: row.UserID, + Peer: dialog.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID}, + Date: int(row.MessageDate), + Out: row.MessageOutgoing, + Body: row.MessageBody, + Entities: entities, + }) + } + } + return out, nil +} + +func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + if len(peers) == 0 { + return domain.DialogList{}, nil + } + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + for _, peer := range peers { + if peer.Type == "" || peer.ID == 0 { + continue + } + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + if len(peerTypes) == 0 { + return domain.DialogList{}, nil + } + rows, err := s.q.ListDialogsByPeers(ctx, sqlcgen.ListDialogsByPeersParams{ + UserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + }) + if err != nil { + return domain.DialogList{}, fmt.Errorf("list dialogs by peers: %w", err) + } + out := domain.DialogList{ + Dialogs: make([]domain.Dialog, 0, len(rows)), + Messages: make([]domain.Message, 0, len(rows)), + Users: make([]domain.User, 0, len(rows)), + } + seenUsers := map[int64]struct{}{} + for _, row := range rows { + dialog := domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + FolderID: int(row.FolderID), + TopMessage: int(row.TopMessageID), + TopMessageDate: int(row.TopMessageDate), + ReadInboxMaxID: int(row.ReadInboxMaxID), + ReadOutboxMaxID: int(row.ReadOutboxMaxID), + UnreadCount: int(row.UnreadCount), + UnreadMentions: int(row.UnreadMentionsCount), + UnreadReactions: int(row.UnreadReactionsCount), + Pinned: row.Pinned, + PinnedOrder: int(row.PinnedOrder), + UnreadMark: row.UnreadMark, + PeerSettingsBarHidden: row.HiddenPeerSettingsBar, + } + out.Dialogs = append(out.Dialogs, dialog) + if row.PeerUserID != 0 { + if _, ok := seenUsers[row.PeerUserID]; !ok { + seenUsers[row.PeerUserID] = struct{}{} + out.Users = append(out.Users, domain.User{ + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + LastSeenAt: int(row.PeerLastSeenAt), + Contact: row.PeerContact, + Mutual: row.PeerMutual, + }) + } + } + if row.MessageID != 0 { + entities, err := decodeMessageEntities(row.MessageEntitiesJson) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err) + } + out.Messages = append(out.Messages, domain.Message{ + ID: int(row.MessageID), + OwnerUserID: row.UserID, + Peer: dialog.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID}, + Date: int(row.MessageDate), + Out: row.MessageOutgoing, + Body: row.MessageBody, + Entities: entities, + }) + } + } + out.Count = len(out.Dialogs) + out.Hash = dialogListHash(out.Dialogs) + return out, nil +} + +func (s *DialogStore) Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error { + if err := s.q.UpsertDialog(ctx, sqlcgen.UpsertDialogParams{ + UserID: userID, + PeerType: string(dialog.Peer.Type), + PeerID: dialog.Peer.ID, + TopMessageID: int32(dialog.TopMessage), + TopMessageDate: int32(dialog.TopMessageDate), + ReadInboxMaxID: int32(dialog.ReadInboxMaxID), + ReadOutboxMaxID: int32(dialog.ReadOutboxMaxID), + UnreadCount: int32(dialog.UnreadCount), + UnreadMentionsCount: int32(dialog.UnreadMentions), + UnreadReactionsCount: int32(dialog.UnreadReactions), + Pinned: dialog.Pinned, + UnreadMark: dialog.UnreadMark, + }); err != nil { + return fmt.Errorf("upsert dialog: %w", err) + } + return nil +} + +func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error { + data, err := json.Marshal(draft) + if err != nil { + return fmt.Errorf("marshal dialog draft: %w", err) + } + if err := s.q.UpsertDialogDraft(ctx, sqlcgen.UpsertDialogDraftParams{ + UserID: userID, + PeerType: string(draft.Peer.Type), + PeerID: draft.Peer.ID, + TopMessageID: int32(draft.TopMessageID), + Date: int32(draft.Date), + DraftJson: data, + }); err != nil { + return fmt.Errorf("upsert dialog draft: %w", err) + } + return nil +} + +func (s *DialogStore) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) { + changed, err := s.q.DeleteDialogDraft(ctx, sqlcgen.DeleteDialogDraftParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + TopMessageID: int32(topMessageID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("delete dialog draft: %w", err) + } + return changed, nil +} + +func (s *DialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + rows, err := s.q.ListDialogDrafts(ctx, sqlcgen.ListDialogDraftsParams{ + UserID: userID, + LimitCount: int32(clampDialogDraftLimit(limit)), + }) + if err != nil { + return nil, fmt.Errorf("list dialog drafts: %w", err) + } + return decodeDialogDrafts(rows) +} + +func (s *DialogStore) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + rows, err := s.q.ClearDialogDrafts(ctx, sqlcgen.ClearDialogDraftsParams{ + UserID: userID, + LimitCount: int32(clampDialogDraftLimit(limit)), + }) + if err != nil { + return nil, fmt.Errorf("clear dialog drafts: %w", err) + } + return decodeDialogDrafts(rows) +} + +func (s *DialogStore) MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) { + row, err := s.q.MarkDialogRead(ctx, sqlcgen.MarkDialogReadParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + MaxID: pgInt32NonNegative(maxID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID}, nil + } + return domain.ReadHistoryResult{}, fmt.Errorf("mark dialog read: %w", err) + } + return domain.ReadHistoryResult{ + OwnerUserID: row.UserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + MaxID: int(row.ReadInboxMaxID), + StillUnreadCount: int(row.UnreadCount), + Changed: row.Changed, + }, nil +} + +func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { + changed, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + Pinned: pinned, + }) + if err != nil { + return false, fmt.Errorf("set dialog pinned: %w", err) + } + return changed, nil +} + +func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error { + peerTypes, peerIDs := peerArrays(order) + if force { + if err := s.q.ClearPinnedDialogsNotInOrder(ctx, sqlcgen.ClearPinnedDialogsNotInOrderParams{ + UserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + }); err != nil { + return fmt.Errorf("clear pinned dialogs not in order: %w", err) + } + } + if len(peerTypes) == 0 { + return nil + } + if err := s.q.ReorderPinnedDialogs(ctx, sqlcgen.ReorderPinnedDialogsParams{ + UserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + }); err != nil { + return fmt.Errorf("reorder pinned dialogs: %w", err) + } + return nil +} + +func (s *DialogStore) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { + changed, err := s.q.SetDialogUnreadMark(ctx, sqlcgen.SetDialogUnreadMarkParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + Unread: unread, + }) + if err != nil { + return false, fmt.Errorf("set dialog unread mark: %w", err) + } + return changed, nil +} + +func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) { + rows, err := s.q.ListDialogUnreadMarks(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list dialog unread marks: %w", err) + } + out := make([]domain.Peer, 0, len(rows)) + for _, row := range rows { + out = append(out, domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}) + } + return out, nil +} + +func (s *DialogStore) SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { + changed, err := s.q.SetPeerSettingsBarHidden(ctx, sqlcgen.SetPeerSettingsBarHiddenParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }) + if err != nil { + return false, fmt.Errorf("set peer settings bar hidden: %w", err) + } + return changed, nil +} + +func (s *DialogStore) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { + hidden, err := s.q.GetPeerSettingsBarHidden(ctx, sqlcgen.GetPeerSettingsBarHiddenParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("get peer settings bar hidden: %w", err) + } + return hidden, nil +} + +func (s *DialogStore) ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) { + rows, err := s.q.ListDialogFolders(ctx, userID) + if err != nil { + return domain.DialogFolderList{}, fmt.Errorf("list dialog folders: %w", err) + } + tagsEnabled := false + if enabled, err := s.q.GetDialogFolderTags(ctx, userID); err == nil { + tagsEnabled = enabled + } else if !errors.Is(err, pgx.ErrNoRows) { + return domain.DialogFolderList{}, fmt.Errorf("get dialog folder tags: %w", err) + } + out := domain.DialogFolderList{ + TagsEnabled: tagsEnabled, + Folders: make([]domain.DialogFolder, 0, len(rows)), + } + for _, row := range rows { + folder, err := decodeDialogFolder(row.FilterJson) + if err != nil { + return domain.DialogFolderList{}, fmt.Errorf("decode dialog folder %d: %w", row.FilterID, err) + } + folder.ID = int(row.FilterID) + folder.IsChatlist = row.IsChatlist + out.Folders = append(out.Folders, folder) + } + return out, nil +} + +func (s *DialogStore) GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) { + row, err := s.q.GetDialogFolder(ctx, sqlcgen.GetDialogFolderParams{ + UserID: userID, + FilterID: pgInt32NonNegative(folderID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.DialogFolder{}, false, nil + } + return domain.DialogFolder{}, false, fmt.Errorf("get dialog folder: %w", err) + } + folder, err := decodeDialogFolder(row.FilterJson) + if err != nil { + return domain.DialogFolder{}, false, fmt.Errorf("decode dialog folder: %w", err) + } + folder.ID = int(row.FilterID) + folder.IsChatlist = row.IsChatlist + return folder, true, nil +} + +func (s *DialogStore) UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error { + data, err := json.Marshal(folder) + if err != nil { + return fmt.Errorf("marshal dialog folder: %w", err) + } + if err := s.q.UpsertDialogFolder(ctx, sqlcgen.UpsertDialogFolderParams{ + UserID: userID, + FilterID: pgInt32NonNegative(folder.ID), + IsChatlist: folder.IsChatlist, + FilterJson: data, + }); err != nil { + return fmt.Errorf("upsert dialog folder: %w", err) + } + return nil +} + +func (s *DialogStore) DeleteFolder(ctx context.Context, userID int64, folderID int) error { + if err := s.q.DeleteDialogFolder(ctx, sqlcgen.DeleteDialogFolderParams{ + UserID: userID, + FilterID: pgInt32NonNegative(folderID), + }); err != nil { + return fmt.Errorf("delete dialog folder: %w", err) + } + return nil +} + +func (s *DialogStore) ReorderFolders(ctx context.Context, userID int64, order []int) error { + if err := s.q.ReorderDialogFolders(ctx, sqlcgen.ReorderDialogFoldersParams{ + UserID: userID, + FilterIds: int32s(order), + }); err != nil { + return fmt.Errorf("reorder dialog folders: %w", err) + } + return nil +} + +func (s *DialogStore) SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error { + if err := s.q.SetDialogFolderTags(ctx, sqlcgen.SetDialogFolderTagsParams{ + UserID: userID, + TagsEnabled: enabled, + }); err != nil { + return fmt.Errorf("set dialog folder tags: %w", err) + } + return nil +} + +func (s *DialogStore) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + folderIDs := make([]int32, 0, len(peers)) + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, item := range peers { + if item.Peer.Type == "" || item.Peer.ID == 0 { + continue + } + if _, ok := seen[item.Peer]; ok { + continue + } + seen[item.Peer] = struct{}{} + peerTypes = append(peerTypes, string(item.Peer.Type)) + peerIDs = append(peerIDs, item.Peer.ID) + folderIDs = append(folderIDs, pgInt32NonNegative(item.FolderID)) + } + if len(peerTypes) == 0 { + return nil + } + if err := s.q.EditDialogPeerFolders(ctx, sqlcgen.EditDialogPeerFoldersParams{ + UserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + FolderIds: folderIDs, + }); err != nil { + return fmt.Errorf("edit dialog peer folders: %w", err) + } + return nil +} + +type dialogFolderParams struct { + contacts bool + nonContacts bool + excludeArchived bool + excludeRead bool + includeTypes []string + includeIDs []int64 + pinnedTypes []string + pinnedIDs []int64 + excludeTypes []string + excludeIDs []int64 +} + +func dialogFolderQueryParams(folder *domain.DialogFolder) dialogFolderParams { + if folder == nil { + return dialogFolderParams{} + } + includeTypes, includeIDs := folderPeerArrays(folder.IncludePeers) + pinnedTypes, pinnedIDs := folderPeerArrays(folder.PinnedPeers) + excludeTypes, excludeIDs := folderPeerArrays(folder.ExcludePeers) + return dialogFolderParams{ + contacts: folder.Contacts, + nonContacts: folder.NonContacts, + excludeArchived: folder.ExcludeArchived, + excludeRead: folder.ExcludeRead, + includeTypes: includeTypes, + includeIDs: includeIDs, + pinnedTypes: pinnedTypes, + pinnedIDs: pinnedIDs, + excludeTypes: excludeTypes, + excludeIDs: excludeIDs, + } +} + +func folderPeerArrays(peers []domain.DialogFolderPeer) ([]string, []int64) { + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, item := range peers { + peer := item.Peer + if peer.Type == "" || peer.ID == 0 { + continue + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + return peerTypes, peerIDs +} + +func decodeDialogFolder(data string) (domain.DialogFolder, error) { + if data == "" { + return domain.DialogFolder{}, nil + } + var folder domain.DialogFolder + if err := json.Unmarshal([]byte(data), &folder); err != nil { + return domain.DialogFolder{}, err + } + return folder, nil +} + +func decodeDialogDrafts(rows []string) ([]domain.DialogDraft, error) { + out := make([]domain.DialogDraft, 0, len(rows)) + for _, row := range rows { + draft, err := decodeDialogDraft(row) + if err != nil { + return nil, err + } + out = append(out, draft) + } + return out, nil +} + +func decodeDialogDraft(data string) (domain.DialogDraft, error) { + if data == "" { + return domain.DialogDraft{}, nil + } + var draft domain.DialogDraft + if err := json.Unmarshal([]byte(data), &draft); err != nil { + return domain.DialogDraft{}, fmt.Errorf("decode dialog draft: %w", err) + } + if draft.Entities == nil { + draft.Entities = []domain.MessageEntity{} + } + return draft, nil +} + +func clampDialogDraftLimit(limit int) int { + if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { + return domain.MaxDialogDraftsPerUser + } + return limit +} + +func peerArrays(peers []domain.Peer) ([]string, []int64) { + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, peer := range peers { + if peer.Type == "" || peer.ID == 0 { + continue + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + return peerTypes, peerIDs +} + +func dialogListHash(dialogs []domain.Dialog) int64 { + if len(dialogs) == 0 { + return 0 + } + h := fnv.New64a() + var buf [47]byte + for _, d := range dialogs { + binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage)) + binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate)) + binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID)) + binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID)) + binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount)) + binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions)) + binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions)) + if d.Pinned { + buf[40] = 1 + } else { + buf[40] = 0 + } + binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder)) + if d.UnreadMark { + buf[45] = 1 + } else { + buf[45] = 0 + } + if d.PeerSettingsBarHidden { + buf[46] = 1 + } else { + buf[46] = 0 + } + _, _ = h.Write(buf[:]) + } + return int64(h.Sum64()) +} diff --git a/internal/store/postgres/dialog_folders_integration_test.go b/internal/store/postgres/dialog_folders_integration_test.go new file mode 100644 index 00000000..9f405ecd --- /dev/null +++ b/internal/store/postgres/dialog_folders_integration_test.go @@ -0,0 +1,98 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestDialogFoldersRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 11, Phone: "+1666" + suffix + "01", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 22, Phone: "+1666" + suffix + "02", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + stranger, err := users.Create(ctx, domain.User{AccessHash: 33, Phone: "+1666" + suffix + "03", FirstName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, stranger.ID}) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO contacts (user_id, contact_user_id, mutual) + VALUES ($1, $2, true) + `, owner.ID, friend.ID); err != nil { + t.Fatalf("insert contact: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO dialogs (user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, unread_count) + VALUES + ($1, 'user', $2, 0, 10, 1000, 0), + ($1, 'user', $3, 1, 9, 900, 1) + `, owner.ID, friend.ID, stranger.ID); err != nil { + t.Fatalf("insert dialogs: %v", err) + } + + dialogs := NewDialogStore(pool) + main, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 10}) + if err != nil { + t.Fatalf("list main folder: %v", err) + } + if len(main.Dialogs) != 1 || main.Dialogs[0].Peer.ID != friend.ID { + t.Fatalf("main dialogs = %+v, want friend only", main.Dialogs) + } + archive, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10}) + if err != nil { + t.Fatalf("list archive folder: %v", err) + } + if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != stranger.ID || archive.Dialogs[0].FolderID != domain.DialogArchiveFolderID { + t.Fatalf("archive dialogs = %+v, want archived stranger", archive.Dialogs) + } + + folder := domain.DialogFolder{ + ID: 2, + Title: "Work", + Contacts: true, + ExcludeArchived: true, + IncludePeers: []domain.DialogFolderPeer{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: stranger.ID}, AccessHash: stranger.AccessHash}}, + } + if err := dialogs.UpsertFolder(ctx, owner.ID, folder); err != nil { + t.Fatalf("upsert folder: %v", err) + } + if err := dialogs.SetFolderTagsEnabled(ctx, owner.ID, true); err != nil { + t.Fatalf("set tags: %v", err) + } + folders, err := dialogs.ListFolders(ctx, owner.ID) + if err != nil { + t.Fatalf("list folders: %v", err) + } + if !folders.TagsEnabled || len(folders.Folders) != 1 || folders.Folders[0].Title != "Work" { + t.Fatalf("folders = %+v, want tags plus work folder", folders) + } + custom, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: 2, Folder: &folder, Limit: 10}) + if err != nil { + t.Fatalf("list custom folder: %v", err) + } + if len(custom.Dialogs) != 1 || custom.Dialogs[0].Peer.ID != friend.ID { + t.Fatalf("custom dialogs = %+v, want contact only because archived explicit peer is excluded", custom.Dialogs) + } + if err := dialogs.EditPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: stranger.ID}, FolderID: domain.DialogMainFolderID}}); err != nil { + t.Fatalf("edit peer folders: %v", err) + } + archive, err = dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10}) + if err != nil { + t.Fatalf("list archive after edit: %v", err) + } + if len(archive.Dialogs) != 0 { + t.Fatalf("archive after edit = %+v, want empty", archive.Dialogs) + } +} diff --git a/internal/store/postgres/dispatch_outbox.go b/internal/store/postgres/dispatch_outbox.go new file mode 100644 index 00000000..7f54baa1 --- /dev/null +++ b/internal/store/postgres/dispatch_outbox.go @@ -0,0 +1,140 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// defaultDispatchLease 是 'dispatching' 行被判定租约过期、可被重新 claim 的默认时长。 +// 与 docs/message-module.md 的 outbox 背压参数对应;生产由 config 注入覆盖。 +const defaultDispatchLease = 30 * time.Second + +// DispatchOutboxStore 用 PostgreSQL 实现 transactional outbox。 +type DispatchOutboxStore struct { + q *sqlcgen.Queries + leaseSeconds int32 +} + +// DispatchOutboxOption 调整 DispatchOutboxStore 的 claim 行为。 +type DispatchOutboxOption func(*DispatchOutboxStore) + +// WithLeaseTimeout 设置租约超时;<=0 时保持默认。 +func WithLeaseTimeout(d time.Duration) DispatchOutboxOption { + return func(s *DispatchOutboxStore) { + if d > 0 { + s.leaseSeconds = int32(d / time.Second) + if s.leaseSeconds < 1 { + s.leaseSeconds = 1 + } + } + } +} + +// NewDispatchOutboxStore 基于 pgx 连接池(或事务)创建 DispatchOutboxStore。 +func NewDispatchOutboxStore(db sqlcgen.DBTX, opts ...DispatchOutboxOption) *DispatchOutboxStore { + s := &DispatchOutboxStore{ + q: sqlcgen.New(db), + leaseSeconds: int32(defaultDispatchLease / time.Second), + } + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + return s +} + +func (s *DispatchOutboxStore) ClaimPending(ctx context.Context, limit int) ([]store.DispatchOutboxItem, error) { + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + rows, err := s.q.ClaimDispatchOutbox(ctx, sqlcgen.ClaimDispatchOutboxParams{ + LeaseSeconds: s.leaseSeconds, + LimitCount: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("claim dispatch outbox: %w", err) + } + out := make([]store.DispatchOutboxItem, 0, len(rows)) + for _, row := range rows { + out = append(out, store.DispatchOutboxItem{ + ID: row.ID, + TargetUserID: row.TargetUserID, + Pts: int(row.Pts), + EventType: domain.UpdateEventType(row.EventType), + ExcludeAuthKeyID: authKeyIDFromInt64(row.ExcludeAuthKeyID), + ExcludeSessionID: row.ExcludeSessionID, + Attempts: int(row.Attempts), + }) + } + return out, nil +} + +// MarkDeliveredBatch 一次性删除一批已投递的 outbox 行(方案 A:投递成功即删),取代逐条 MarkDelivered。 +func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error { + if len(items) == 0 { + return nil + } + targetUserIDs := make([]int64, len(items)) + ids := make([]int64, len(items)) + for i, it := range items { + targetUserIDs[i] = it.TargetUserID + ids[i] = it.ID + } + if err := s.q.MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{ + TargetUserIds: targetUserIDs, + Ids: ids, + }); err != nil { + return fmt.Errorf("mark dispatch delivered batch: %w", err) + } + return nil +} + +func (s *DispatchOutboxStore) MarkDelivered(ctx context.Context, targetUserID, id int64) error { + if err := s.q.MarkDispatchDelivered(ctx, sqlcgen.MarkDispatchDeliveredParams{ + TargetUserID: targetUserID, + ID: id, + }); err != nil { + return fmt.Errorf("mark dispatch delivered: %w", err) + } + return nil +} + +func (s *DispatchOutboxStore) MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error { + if err := s.q.MarkDispatchFailed(ctx, sqlcgen.MarkDispatchFailedParams{ + TargetUserID: targetUserID, + ID: id, + LastError: lastError, + }); err != nil { + return fmt.Errorf("mark dispatch failed: %w", err) + } + return nil +} + +func (s *DispatchOutboxStore) DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) { + if olderThan <= 0 { + olderThan = 24 * time.Hour + } + if limit <= 0 { + limit = 10000 + } + if limit > 100000 { + limit = 100000 + } + deleted, err := s.q.DeleteFailedDispatchOutbox(ctx, sqlcgen.DeleteFailedDispatchOutboxParams{ + OlderThanSeconds: int32(olderThan / time.Second), + LimitCount: int32(limit), + }) + if err != nil { + return 0, fmt.Errorf("delete failed dispatch outbox: %w", err) + } + return int(deleted), nil +} diff --git a/internal/store/postgres/help.go b/internal/store/postgres/help.go new file mode 100644 index 00000000..6074676a --- /dev/null +++ b/internal/store/postgres/help.go @@ -0,0 +1,141 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "hash/fnv" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// HelpStore 用 PostgreSQL 实现 store.AppConfigStore 和 store.CountryStore。 +type HelpStore struct { + q *sqlcgen.Queries +} + +// NewHelpStore 基于 pgx 连接池(或事务)创建 HelpStore。 +func NewHelpStore(db sqlcgen.DBTX) *HelpStore { + return &HelpStore{q: sqlcgen.New(db)} +} + +func (s *HelpStore) GetAppConfig(ctx context.Context, client string) (domain.AppConfig, bool, error) { + row, err := s.q.GetAppConfig(ctx, client) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AppConfig{Client: client, JSON: []byte("{}")}, false, nil + } + return domain.AppConfig{}, false, fmt.Errorf("get app config: %w", err) + } + return domain.AppConfig{ + Client: row.Client, + Hash: int(row.Hash), + JSON: []byte(row.ConfigJson), + }, true, nil +} + +func (s *HelpStore) UpsertAppConfig(ctx context.Context, cfg domain.AppConfig) error { + if len(cfg.JSON) == 0 { + cfg.JSON = []byte("{}") + } + if err := s.q.UpsertAppConfig(ctx, sqlcgen.UpsertAppConfigParams{ + Client: cfg.Client, + Hash: int32(cfg.Hash), + ConfigJson: cfg.JSON, + }); err != nil { + return fmt.Errorf("upsert app config: %w", err) + } + return nil +} + +func (s *HelpStore) ListCountries(ctx context.Context, _ string) (domain.CountriesList, error) { + rows, err := s.q.ListCountries(ctx) + if err != nil { + return domain.CountriesList{}, fmt.Errorf("list countries: %w", err) + } + byISO := make(map[string]int) + out := domain.CountriesList{} + for _, row := range rows { + idx, ok := byISO[row.Iso2] + if !ok { + idx = len(out.Countries) + byISO[row.Iso2] = idx + out.Countries = append(out.Countries, domain.Country{ + ISO2: row.Iso2, + DefaultName: row.DefaultName, + Name: row.Name, + Hidden: row.Hidden, + }) + } + out.Countries[idx].CountryCodes = append(out.Countries[idx].CountryCodes, domain.CountryCode{ + CountryCode: row.CountryCode, + Prefixes: append([]string(nil), row.Prefixes...), + Patterns: append([]string(nil), row.Patterns...), + }) + } + out.Hash = countriesHash(out.Countries) + return out, nil +} + +func (s *HelpStore) UpsertCountries(ctx context.Context, countries []domain.Country) error { + for i, country := range countries { + if err := s.q.UpsertCountry(ctx, sqlcgen.UpsertCountryParams{ + Iso2: country.ISO2, + DefaultName: country.DefaultName, + Name: country.Name, + Hidden: country.Hidden, + OrderIndex: int32(i + 1), + }); err != nil { + return fmt.Errorf("upsert country %q: %w", country.ISO2, err) + } + for j, code := range country.CountryCodes { + if err := s.q.UpsertCountryCode(ctx, sqlcgen.UpsertCountryCodeParams{ + Iso2: country.ISO2, + CountryCode: code.CountryCode, + Prefixes: code.Prefixes, + Patterns: code.Patterns, + OrderIndex: int32(j + 1), + }); err != nil { + return fmt.Errorf("upsert country code %q/%q: %w", country.ISO2, code.CountryCode, err) + } + } + } + return nil +} + +func countriesHash(countries []domain.Country) int { + if len(countries) == 0 { + return 0 + } + h := fnv.New32a() + var buf [4]byte + for _, country := range countries { + _, _ = h.Write([]byte(country.ISO2)) + _, _ = h.Write([]byte(country.DefaultName)) + _, _ = h.Write([]byte(country.Name)) + if country.Hidden { + buf[0] = 1 + } else { + buf[0] = 0 + } + _, _ = h.Write(buf[:1]) + for _, code := range country.CountryCodes { + _, _ = h.Write([]byte(code.CountryCode)) + binary.LittleEndian.PutUint32(buf[:], uint32(len(code.Prefixes))) + _, _ = h.Write(buf[:]) + for _, prefix := range code.Prefixes { + _, _ = h.Write([]byte(prefix)) + } + binary.LittleEndian.PutUint32(buf[:], uint32(len(code.Patterns))) + _, _ = h.Write(buf[:]) + for _, pattern := range code.Patterns { + _, _ = h.Write([]byte(pattern)) + } + } + } + return int(h.Sum32()) +} diff --git a/internal/store/postgres/langpack.go b/internal/store/postgres/langpack.go new file mode 100644 index 00000000..d74c22a7 --- /dev/null +++ b/internal/store/postgres/langpack.go @@ -0,0 +1,169 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// LangPackStore 用 PostgreSQL 实现 store.LangPackStore。 +type LangPackStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewLangPackStore 基于 pgx 连接池(或事务)创建 LangPackStore。 +func NewLangPackStore(db sqlcgen.DBTX) *LangPackStore { + return &LangPackStore{db: db, q: sqlcgen.New(db)} +} + +func (s *LangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + meta, found, err := s.meta(ctx, langPack, langCode) + if err != nil || !found { + return meta, err + } + meta.FromVersion = fromVersion + if meta.Version <= fromVersion { + return meta, nil + } + rows, err := s.q.ListLangPackStrings(ctx, sqlcgen.ListLangPackStringsParams{ + LangPack: langPack, + LangCode: langCode, + }) + if err != nil { + return domain.LangPack{}, fmt.Errorf("list lang pack strings: %w", err) + } + meta.Strings = make([]domain.LangPackString, 0, len(rows)) + for _, row := range rows { + meta.Strings = append(meta.Strings, langPackStringFromListRow(row)) + } + return meta, nil +} + +func (s *LangPackStore) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { + meta, found, err := s.meta(ctx, langPack, langCode) + if err != nil || !found { + return meta, err + } + if len(keys) == 0 { + return s.GetPack(ctx, langPack, langCode, 0) + } + rows, err := s.q.GetLangPackStringsByKeys(ctx, sqlcgen.GetLangPackStringsByKeysParams{ + LangPack: langPack, + LangCode: langCode, + Keys: keys, + }) + if err != nil { + return domain.LangPack{}, fmt.Errorf("get lang pack strings: %w", err) + } + meta.Strings = make([]domain.LangPackString, 0, len(rows)) + for _, row := range rows { + meta.Strings = append(meta.Strings, langPackStringFromKeysRow(row)) + } + return meta, nil +} + +func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) error { + if txer, ok := s.db.(interface { + Begin(context.Context) (pgx.Tx, error) + }); ok { + tx, err := txer.Begin(ctx) + if err != nil { + return fmt.Errorf("begin lang pack upsert: %w", err) + } + q := s.q.WithTx(tx) + if err := upsertPackWith(ctx, q, pack); err != nil { + _ = tx.Rollback(ctx) + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit lang pack upsert: %w", err) + } + return nil + } + return upsertPackWith(ctx, s.q, pack) +} + +func upsertPackWith(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPack) error { + if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{ + LangPack: pack.LangPack, + LangCode: pack.LangCode, + Version: int32(pack.Version), + StringsCount: int32(len(pack.Strings)), + }); err != nil { + return fmt.Errorf("upsert lang pack meta: %w", err) + } + for _, item := range pack.Strings { + if err := q.UpsertLangPackString(ctx, sqlcgen.UpsertLangPackStringParams{ + LangPack: pack.LangPack, + LangCode: pack.LangCode, + Key: item.Key, + Version: int32(pack.Version), + Pluralized: item.Pluralized, + Value: item.Value, + ZeroValue: item.ZeroValue, + OneValue: item.OneValue, + TwoValue: item.TwoValue, + FewValue: item.FewValue, + ManyValue: item.ManyValue, + OtherValue: item.OtherValue, + Deleted: item.Deleted, + }); err != nil { + return fmt.Errorf("upsert lang pack string %q: %w", item.Key, err) + } + } + return nil +} + +func (s *LangPackStore) meta(ctx context.Context, langPack, langCode string) (domain.LangPack, bool, error) { + row, err := s.q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{ + LangPack: langPack, + LangCode: langCode, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.LangPack{LangPack: langPack, LangCode: langCode}, false, nil + } + return domain.LangPack{}, false, fmt.Errorf("get lang pack meta: %w", err) + } + return domain.LangPack{ + LangPack: row.LangPack, + LangCode: row.LangCode, + Version: int(row.Version), + }, true, nil +} + +func langPackStringFromListRow(row sqlcgen.ListLangPackStringsRow) domain.LangPackString { + return domain.LangPackString{ + Key: row.Key, + Value: row.Value, + Pluralized: row.Pluralized, + ZeroValue: row.ZeroValue, + OneValue: row.OneValue, + TwoValue: row.TwoValue, + FewValue: row.FewValue, + ManyValue: row.ManyValue, + OtherValue: row.OtherValue, + Deleted: row.Deleted, + } +} + +func langPackStringFromKeysRow(row sqlcgen.GetLangPackStringsByKeysRow) domain.LangPackString { + return domain.LangPackString{ + Key: row.Key, + Value: row.Value, + Pluralized: row.Pluralized, + ZeroValue: row.ZeroValue, + OneValue: row.OneValue, + TwoValue: row.TwoValue, + FewValue: row.FewValue, + ManyValue: row.ManyValue, + OtherValue: row.OtherValue, + Deleted: row.Deleted, + } +} diff --git a/internal/store/postgres/media.go b/internal/store/postgres/media.go new file mode 100644 index 00000000..5fc4a936 --- /dev/null +++ b/internal/store/postgres/media.go @@ -0,0 +1,509 @@ +package postgres + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// MediaStore 用 PostgreSQL 实现 store.MediaStore(媒体元数据 + blob 索引)。 +type MediaStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewMediaStore 基于 pgx 连接池(或事务)创建 MediaStore。 +func NewMediaStore(db sqlcgen.DBTX) *MediaStore { + return &MediaStore{db: db, q: sqlcgen.New(db)} +} + +// bytesOrEmpty 把 nil []byte 归一为空切片,避免落入 NOT NULL bytea 列时被当作 NULL。 +func bytesOrEmpty(b []byte) []byte { + if b == nil { + return []byte{} + } + return b +} + +var _ store.MediaStore = (*MediaStore)(nil) + +// ---- 上传分片 ---- + +func (s *MediaStore) SaveFilePart(ctx context.Context, part domain.UploadPart) error { + return s.q.SaveUploadPart(ctx, sqlcgen.SaveUploadPartParams{ + OwnerUserID: part.OwnerUserID, + FileID: part.FileID, + Part: int32(part.Part), + TotalParts: int32(part.TotalParts), + IsBig: part.Big, + Bytes: part.Bytes, + }) +} + +func (s *MediaStore) LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) { + rows, err := s.q.ListUploadParts(ctx, sqlcgen.ListUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID}) + if err != nil { + return nil, err + } + out := make([]domain.UploadPart, 0, len(rows)) + for _, r := range rows { + out = append(out, domain.UploadPart{ + OwnerUserID: ownerUserID, + FileID: fileID, + Part: int(r.Part), + TotalParts: int(r.TotalParts), + Big: r.IsBig, + Bytes: r.Bytes, + }) + } + return out, nil +} + +func (s *MediaStore) DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error { + return s.q.DeleteUploadParts(ctx, sqlcgen.DeleteUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID}) +} + +// ---- blob 索引 ---- + +func (s *MediaStore) PutFileBlob(ctx context.Context, blob domain.FileBlob) error { + backend := string(blob.Backend) + if backend == "" { + backend = string(domain.MediaBackendLocalFS) + } + sha := blob.SHA256 + if sha == nil { + sha = []byte{} // 列为 NOT NULL;nil []byte 会被 pgx 当作 NULL。 + } + return s.q.PutFileBlob(ctx, sqlcgen.PutFileBlobParams{ + LocationKey: blob.LocationKey, + Backend: backend, + ObjectKey: blob.ObjectKey, + Size: blob.Size, + Sha256: sha, + MimeType: blob.MimeType, + }) +} + +func (s *MediaStore) GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) { + row, err := s.q.GetFileBlob(ctx, locationKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.FileBlob{}, false, nil + } + return domain.FileBlob{}, false, err + } + return domain.FileBlob{ + LocationKey: row.LocationKey, + Backend: domain.MediaBackend(row.Backend), + ObjectKey: row.ObjectKey, + Size: row.Size, + SHA256: row.Sha256, + MimeType: row.MimeType, + }, true, nil +} + +// ---- 文档 ---- + +func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error { + attrs, err := jsonArrayOrEmpty(doc.Attributes) + if err != nil { + return err + } + thumbs, err := jsonArrayOrEmpty(doc.Thumbs) + if err != nil { + return err + } + return s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{ + ID: doc.ID, + AccessHash: doc.AccessHash, + FileReference: bytesOrEmpty(doc.FileReference), + Date: int32(doc.Date), + MimeType: doc.MimeType, + Size: doc.Size, + DcID: int32(doc.DCID), + AttributesJson: attrs, + ThumbsJson: thumbs, + }) +} + +func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { + row, err := s.q.GetDocument(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Document{}, false, nil + } + return domain.Document{}, false, err + } + doc, err := documentFromRow(row) + if err != nil { + return domain.Document{}, false, err + } + return doc, true, nil +} + +func (s *MediaStore) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := s.q.GetDocuments(ctx, ids) + if err != nil { + return nil, err + } + out := make([]domain.Document, 0, len(rows)) + for _, r := range rows { + doc, err := documentFromRow(sqlcgen.GetDocumentRow(r)) + if err != nil { + return nil, err + } + out = append(out, doc) + } + return out, nil +} + +func documentFromRow(row sqlcgen.GetDocumentRow) (domain.Document, error) { + attrs, err := decodeDocumentAttributes(row.AttributesJson) + if err != nil { + return domain.Document{}, err + } + thumbs, err := decodePhotoSizes(row.ThumbsJson) + if err != nil { + return domain.Document{}, err + } + return domain.Document{ + ID: row.ID, + AccessHash: row.AccessHash, + FileReference: row.FileReference, + Date: int(row.Date), + MimeType: row.MimeType, + Size: row.Size, + DCID: int(row.DcID), + Attributes: attrs, + Thumbs: thumbs, + }, nil +} + +// ---- 照片 ---- + +func (s *MediaStore) PutPhoto(ctx context.Context, photo domain.Photo) error { + sizes, err := jsonArrayOrEmpty(photo.Sizes) + if err != nil { + return err + } + return s.q.PutPhoto(ctx, sqlcgen.PutPhotoParams{ + ID: photo.ID, + AccessHash: photo.AccessHash, + FileReference: bytesOrEmpty(photo.FileReference), + Date: int32(photo.Date), + DcID: int32(photo.DCID), + HasStickers: photo.HasStickers, + SizesJson: sizes, + }) +} + +func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) { + row, err := s.q.GetPhoto(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Photo{}, false, nil + } + return domain.Photo{}, false, err + } + sizes, err := decodePhotoSizes(row.SizesJson) + if err != nil { + return domain.Photo{}, false, err + } + return domain.Photo{ + ID: row.ID, + AccessHash: row.AccessHash, + FileReference: row.FileReference, + Date: int(row.Date), + DCID: int(row.DcID), + HasStickers: row.HasStickers, + Sizes: sizes, + }, true, nil +} + +// ---- 贴纸集 ---- + +func (s *MediaStore) PutStickerSet(ctx context.Context, set domain.StickerSet) error { + thumbs, err := jsonArrayOrEmpty(set.Thumbs) + if err != nil { + return err + } + docIDs, err := jsonArrayOrEmpty(set.DocumentIDs) + if err != nil { + return err + } + packs, err := jsonArrayOrEmpty(set.Packs) + if err != nil { + return err + } + kind := string(set.Kind) + if kind == "" { + kind = string(domain.StickerSetKindStickers) + } + return s.q.PutStickerSet(ctx, sqlcgen.PutStickerSetParams{ + ID: set.ID, + AccessHash: set.AccessHash, + ShortName: set.ShortName, + Title: set.Title, + Count: int32(set.Count), + Hash: int32(set.Hash), + SetKind: kind, + Official: set.Official, + Animated: set.Animated, + Videos: set.Videos, + Emojis: set.Emojis, + Masks: set.Masks, + Installed: set.Installed, + Archived: set.Archived, + InstalledDate: int32(set.InstalledDate), + ThumbDocumentID: set.ThumbDocumentID, + ThumbsJson: thumbs, + ThumbDcID: int32(set.ThumbDCID), + ThumbVersion: int32(set.ThumbVersion), + DocumentIdsJson: docIDs, + PacksJson: packs, + SortOrder: int32(set.SortOrder), + SystemKey: set.SystemKey, + }) +} + +func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) { + row, err := s.q.GetStickerSetByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StickerSet{}, false, nil + } + return domain.StickerSet{}, false, err + } + return stickerSetFromRow(row) +} + +func (s *MediaStore) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) { + row, err := s.q.GetStickerSetByShortName(ctx, shortName) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StickerSet{}, false, nil + } + return domain.StickerSet{}, false, err + } + return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row)) +} + +func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) { + row, err := s.q.GetStickerSetBySystemKey(ctx, systemKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StickerSet{}, false, nil + } + return domain.StickerSet{}, false, err + } + return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row)) +} + +func (s *MediaStore) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + rows, err := s.q.ListStickerSetsByKind(ctx, string(kind)) + if err != nil { + return nil, err + } + out := make([]domain.StickerSet, 0, len(rows)) + for _, r := range rows { + set, _, err := stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(r)) + if err != nil { + return nil, err + } + out = append(out, set) + } + return out, nil +} + +func (s *MediaStore) CountStickerSets(ctx context.Context) (int, error) { + n, err := s.q.CountStickerSets(ctx) + return int(n), err +} + +func stickerSetFromRow(row sqlcgen.GetStickerSetByIDRow) (domain.StickerSet, bool, error) { + thumbs, err := decodePhotoSizes(row.ThumbsJson) + if err != nil { + return domain.StickerSet{}, false, err + } + docIDs, err := decodeInt64Slice(row.DocumentIdsJson) + if err != nil { + return domain.StickerSet{}, false, err + } + packs, err := decodeStickerPacks(row.PacksJson) + if err != nil { + return domain.StickerSet{}, false, err + } + return domain.StickerSet{ + ID: row.ID, + AccessHash: row.AccessHash, + ShortName: row.ShortName, + Title: row.Title, + Count: int(row.Count), + Hash: int(row.Hash), + Kind: domain.StickerSetKind(row.SetKind), + Official: row.Official, + Animated: row.Animated, + Videos: row.Videos, + Emojis: row.Emojis, + Masks: row.Masks, + Installed: row.Installed, + Archived: row.Archived, + InstalledDate: int(row.InstalledDate), + ThumbDocumentID: row.ThumbDocumentID, + Thumbs: thumbs, + ThumbDCID: int(row.ThumbDcID), + ThumbVersion: int(row.ThumbVersion), + DocumentIDs: docIDs, + Packs: packs, + SortOrder: int(row.SortOrder), + SystemKey: row.SystemKey, + }, true, nil +} + +// ---- 可用 reaction ---- + +func (s *MediaStore) PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error { + return s.q.PutAvailableReaction(ctx, sqlcgen.PutAvailableReactionParams{ + Reaction: r.Reaction, + Title: r.Title, + Inactive: r.Inactive, + Premium: r.Premium, + StaticIconID: r.StaticIconID, + AppearAnimationID: r.AppearAnimationID, + SelectAnimationID: r.SelectAnimationID, + ActivateAnimationID: r.ActivateAnimationID, + EffectAnimationID: r.EffectAnimationID, + AroundAnimationID: r.AroundAnimationID, + CenterIconID: r.CenterIconID, + SortOrder: int32(r.Order), + }) +} + +func (s *MediaStore) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) { + rows, err := s.q.ListAvailableReactions(ctx) + if err != nil { + return nil, err + } + out := make([]domain.AvailableReaction, 0, len(rows)) + for _, r := range rows { + out = append(out, domain.AvailableReaction{ + Reaction: r.Reaction, + Title: r.Title, + Inactive: r.Inactive, + Premium: r.Premium, + StaticIconID: r.StaticIconID, + AppearAnimationID: r.AppearAnimationID, + SelectAnimationID: r.SelectAnimationID, + ActivateAnimationID: r.ActivateAnimationID, + EffectAnimationID: r.EffectAnimationID, + AroundAnimationID: r.AroundAnimationID, + CenterIconID: r.CenterIconID, + Order: int(r.SortOrder), + }) + } + return out, nil +} + +func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) { + n, err := s.q.CountAvailableReactions(ctx) + return int(n), err +} + +// ---- 头像历史 ---- + +func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error { + next, err := s.q.NextProfilePhotoOrder(ctx, sqlcgen.NextProfilePhotoOrderParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + }) + if err != nil { + return err + } + return s.q.AddProfilePhoto(ctx, sqlcgen.AddProfilePhotoParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + PhotoID: photoID, + Date: int32(date), + SortOrder: next + 1, + }) +} + +func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) { + id, err := s.q.CurrentProfilePhoto(ctx, sqlcgen.CurrentProfilePhotoParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, false, nil + } + return 0, false, err + } + return id, true, nil +} + +func (s *MediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + if len(ownerIDs) == 0 { + return map[int64]domain.ProfilePhotoRef{}, nil + } + rows, err := s.q.CurrentProfilePhotosForOwners(ctx, sqlcgen.CurrentProfilePhotosForOwnersParams{ + OwnerPeerType: string(ownerType), + OwnerIds: ownerIDs, + }) + if err != nil { + return nil, err + } + out := make(map[int64]domain.ProfilePhotoRef, len(rows)) + for _, r := range rows { + sizes, err := decodePhotoSizes(r.SizesJson) + if err != nil { + return nil, err + } + out[r.OwnerPeerID] = domain.ProfilePhotoRef{ + PhotoID: r.PhotoID, + DCID: int(r.DcID), + Stripped: domain.StrippedFromSizes(sizes), + } + } + return out, nil +} + +func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) { + ids, err := s.q.ListProfilePhotos(ctx, sqlcgen.ListProfilePhotosParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + MaxID: maxID, + OffsetCount: int32(offset), + LimitCount: int32(limit), + }) + if err != nil { + return nil, 0, err + } + total, err := s.q.CountProfilePhotos(ctx, sqlcgen.CountProfilePhotosParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + }) + if err != nil { + return nil, 0, err + } + return ids, int(total), nil +} + +func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) { + if len(photoIDs) == 0 { + return nil, nil + } + return s.q.DeactivateProfilePhotos(ctx, sqlcgen.DeactivateProfilePhotosParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + PhotoIds: photoIDs, + }) +} diff --git a/internal/store/postgres/media_codec.go b/internal/store/postgres/media_codec.go new file mode 100644 index 00000000..28353c0c --- /dev/null +++ b/internal/store/postgres/media_codec.go @@ -0,0 +1,88 @@ +package postgres + +import ( + "encoding/json" + + "telesrv/internal/domain" +) + +// 媒体相关 JSON 编解码:domain 值对象 ↔ JSONB 列。domain.* 带 json tag,可直接 marshal。 + +// jsonArrayOrEmpty 把切片序列化为 JSONB;nil 序列化为 "[]"(列为 NOT NULL DEFAULT '[]')。 +func jsonArrayOrEmpty(v any) ([]byte, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + if string(b) == "null" { + return []byte("[]"), nil + } + return b, nil +} + +// encodeMessageMedia 把消息媒体快照序列化为 JSONB;无媒体序列化为 "{}"。 +func encodeMessageMedia(m *domain.MessageMedia) ([]byte, error) { + if m.IsZero() { + return []byte("{}"), nil + } + return json.Marshal(m) +} + +// decodeMessageMedia 把消息行的 media JSONB 文本还原为 *MessageMedia;空载荷返回 nil。 +func decodeMessageMedia(s string) (*domain.MessageMedia, error) { + if s == "" || s == "{}" || s == "null" { + return nil, nil + } + var m domain.MessageMedia + if err := json.Unmarshal([]byte(s), &m); err != nil { + return nil, err + } + if m.IsZero() { + return nil, nil + } + return &m, nil +} + +func decodePhotoSizes(s string) ([]domain.PhotoSize, error) { + if s == "" || s == "[]" || s == "null" { + return nil, nil + } + var out []domain.PhotoSize + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +func decodeDocumentAttributes(s string) ([]domain.DocumentAttribute, error) { + if s == "" || s == "[]" || s == "null" { + return nil, nil + } + var out []domain.DocumentAttribute + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +func decodeInt64Slice(s string) ([]int64, error) { + if s == "" || s == "[]" || s == "null" { + return nil, nil + } + var out []int64 + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +func decodeStickerPacks(s string) ([]domain.StickerPack, error) { + if s == "" || s == "[]" || s == "null" { + return nil, nil + } + var out []domain.StickerPack + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/store/postgres/media_integration_test.go b/internal/store/postgres/media_integration_test.go new file mode 100644 index 00000000..3331062d --- /dev/null +++ b/internal/store/postgres/media_integration_test.go @@ -0,0 +1,210 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/domain" +) + +// TestMediaStoreRoundTrip 验证 MediaStore 各表的写读往返(含 nil bytea 归一、JSONB attributes/sizes、 +// 头像历史 current/list/delete、上传分片)。直接证明媒体元数据落 PG 后可原样读回。 +func TestMediaStoreRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + s := NewMediaStore(pool) + + const docID = int64(9100000000000000001) + const photoID = int64(9100000000000000002) + const setID = int64(9100000000000000003) + const ownerID = int64(9100000000000000099) + const reactionEmoji = "\U0001f9ea" + + cleanupMediaStoreRoundTripRows(t, ctx, pool) + t.Cleanup(func() { + cleanupMediaStoreRoundTripRows(t, context.Background(), pool) + }) + + // ---- file blob(nil sha256 应被归一为空,不报 NOT NULL)---- + if err := s.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: "doc:9100000000000000001", + ObjectKey: "ab/cd/abcdef", + Size: 1234, + MimeType: "application/x-tgsticker", + }); err != nil { + t.Fatalf("put file blob (nil sha256): %v", err) + } + blob, ok, err := s.GetFileBlob(ctx, "doc:9100000000000000001") + if err != nil || !ok { + t.Fatalf("get file blob: ok=%v err=%v", ok, err) + } + if blob.ObjectKey != "ab/cd/abcdef" || blob.Size != 1234 || blob.Backend != domain.MediaBackendLocalFS { + t.Fatalf("file blob mismatch: %+v", blob) + } + + // ---- document(含 sticker 属性 + thumbs JSONB;nil file_reference 路径)---- + doc := domain.Document{ + ID: docID, + AccessHash: 77, + DCID: 2, + MimeType: "application/x-tgsticker", + Size: 2048, + Attributes: []domain.DocumentAttribute{ + {Kind: domain.DocAttrImageSize, W: 512, H: 512}, + {Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: setID, StickerSetAccessHash: 5}, + }, + Thumbs: []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1, 2, 3}}}, + } + if err := s.PutDocument(ctx, doc); err != nil { + t.Fatalf("put document: %v", err) + } + got, ok, err := s.GetDocument(ctx, docID) + if err != nil || !ok { + t.Fatalf("get document: ok=%v err=%v", ok, err) + } + if got.DCID != 2 || len(got.Attributes) != 2 || len(got.Thumbs) != 1 { + t.Fatalf("document mismatch: %+v", got) + } + if id, hash, ok := got.StickerSetRef(); !ok || id != setID || hash != 5 { + t.Fatalf("document sticker set ref = (%d,%d,%v)", id, hash, ok) + } + docs, err := s.GetDocuments(ctx, []int64{docID}) + if err != nil || len(docs) != 1 { + t.Fatalf("get documents: n=%d err=%v", len(docs), err) + } + + // ---- photo(sizes JSONB)---- + photo := domain.Photo{ + ID: photoID, + AccessHash: 88, + DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600, Size: 4096}}, + } + if err := s.PutPhoto(ctx, photo); err != nil { + t.Fatalf("put photo: %v", err) + } + gotPhoto, ok, err := s.GetPhoto(ctx, photoID) + if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" { + t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto) + } + + // ---- sticker set ---- + set := domain.StickerSet{ + ID: setID, + AccessHash: 5, + ShortName: "telesrv_test_set_9100000000000000003", + Title: "Test Set", + Count: 1, + Kind: domain.StickerSetKindStickers, + Animated: true, + Installed: true, + DocumentIDs: []int64{docID}, + Packs: []domain.StickerPack{{Emoticon: "\U0001f600", DocumentIDs: []int64{docID}}}, + SystemKey: "test_system_9100000000000000003", + } + if err := s.PutStickerSet(ctx, set); err != nil { + t.Fatalf("put sticker set: %v", err) + } + byID, ok, err := s.GetStickerSetByID(ctx, setID) + if err != nil || !ok || len(byID.DocumentIDs) != 1 || len(byID.Packs) != 1 { + t.Fatalf("get sticker set by id: ok=%v err=%v set=%+v", ok, err, byID) + } + if byShort, ok, _ := s.GetStickerSetByShortName(ctx, set.ShortName); !ok || byShort.ID != setID { + t.Fatalf("get sticker set by short name failed: ok=%v", ok) + } + if bySys, ok, _ := s.GetStickerSetBySystemKey(ctx, set.SystemKey); !ok || bySys.ID != setID { + t.Fatalf("get sticker set by system key failed: ok=%v", ok) + } + + // ---- available reaction ---- + if err := s.PutAvailableReaction(ctx, domain.AvailableReaction{ + Reaction: reactionEmoji, Title: "Test", StaticIconID: docID, SelectAnimationID: docID, Order: 9999, + }); err != nil { + t.Fatalf("put available reaction: %v", err) + } + reactions, err := s.ListAvailableReactions(ctx) + if err != nil { + t.Fatalf("list available reactions: %v", err) + } + foundReaction := false + for _, r := range reactions { + if r.Reaction == reactionEmoji { + foundReaction = true + if r.StaticIconID != docID { + t.Fatalf("reaction static icon id = %d", r.StaticIconID) + } + } + } + if !foundReaction { + t.Fatal("inserted reaction not found in list") + } + + // ---- profile photo 历史 ---- + if err := s.AddProfilePhoto(ctx, domain.PeerTypeUser, ownerID, photoID, 1700000000); err != nil { + t.Fatalf("add profile photo: %v", err) + } + cur, ok, err := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID) + if err != nil || !ok || cur != photoID { + t.Fatalf("current profile photo = (%d,%v,%v)", cur, ok, err) + } + refs, err := s.CurrentProfilePhotos(ctx, domain.PeerTypeUser, []int64{ownerID}) + if err != nil || refs[ownerID].PhotoID != photoID || refs[ownerID].DCID != 2 { + t.Fatalf("current profile photos batch = %+v err=%v", refs, err) + } + ids, total, err := s.ListProfilePhotos(ctx, domain.PeerTypeUser, ownerID, 0, 10, 0) + if err != nil || total < 1 || len(ids) < 1 { + t.Fatalf("list profile photos: ids=%v total=%d err=%v", ids, total, err) + } + deleted, err := s.DeleteProfilePhotos(ctx, domain.PeerTypeUser, ownerID, []int64{photoID}) + if err != nil || len(deleted) != 1 { + t.Fatalf("delete profile photos: deleted=%v err=%v", deleted, err) + } + if _, ok, _ := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID); ok { + t.Fatal("profile photo still current after delete") + } + + // ---- upload parts ---- + if err := s.SaveFilePart(ctx, domain.UploadPart{OwnerUserID: ownerID, FileID: 555, Part: 0, Bytes: []byte("hello")}); err != nil { + t.Fatalf("save file part: %v", err) + } + parts, err := s.LoadFileParts(ctx, ownerID, 555) + if err != nil || len(parts) != 1 || string(parts[0].Bytes) != "hello" { + t.Fatalf("load file parts: parts=%+v err=%v", parts, err) + } + if err := s.DeleteFileParts(ctx, ownerID, 555); err != nil { + t.Fatalf("delete file parts: %v", err) + } + if parts, _ := s.LoadFileParts(ctx, ownerID, 555); len(parts) != 0 { + t.Fatal("file parts not cleared") + } +} + +func cleanupMediaStoreRoundTripRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + + const docID = int64(9100000000000000001) + const photoID = int64(9100000000000000002) + const setID = int64(9100000000000000003) + const ownerID = int64(9100000000000000099) + const reactionEmoji = "\U0001f9ea" + + statements := []struct { + sql string + args []any + }{ + {sql: "DELETE FROM upload_parts WHERE owner_user_id = $1 AND file_id = 555", args: []any{ownerID}}, + {sql: "DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND photo_id = $2", args: []any{ownerID, photoID}}, + {sql: "DELETE FROM available_reactions WHERE reaction IN ($1, 'telesrv-test-😀')", args: []any{reactionEmoji}}, + {sql: "DELETE FROM sticker_sets WHERE id = $1 OR short_name = 'telesrv_test_set_9100000000000000003' OR system_key = 'test_system_9100000000000000003'", args: []any{setID}}, + {sql: "DELETE FROM file_blobs WHERE location_key = 'doc:9100000000000000001'"}, + {sql: "DELETE FROM documents WHERE id = $1", args: []any{docID}}, + {sql: "DELETE FROM photos WHERE id = $1", args: []any{photoID}}, + } + for _, stmt := range statements { + if _, err := pool.Exec(ctx, stmt.sql, stmt.args...); err != nil { + t.Fatalf("cleanup media store round trip rows: %v", err) + } + } +} diff --git a/internal/store/postgres/message.go b/internal/store/postgres/message.go new file mode 100644 index 00000000..ce65e426 --- /dev/null +++ b/internal/store/postgres/message.go @@ -0,0 +1,2561 @@ +package postgres + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "hash" + "hash/fnv" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// MessageStore 用 PostgreSQL 实现 store.MessageStore。 +type MessageStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries + boxIDs store.BoxIDAllocator + pts store.PtsAllocator +} + +type txBeginner interface { + Begin(ctx context.Context) (pgx.Tx, error) +} + +// MessageStoreOption 调整 PostgreSQL MessageStore 依赖。 +type MessageStoreOption func(*MessageStore) + +// WithMessageAllocators 注入 Redis-backed allocator;未注入时使用 PG max+1 兜底,仅用于测试。 +func WithMessageAllocators(boxIDs store.BoxIDAllocator, pts store.PtsAllocator) MessageStoreOption { + return func(s *MessageStore) { + s.boxIDs = boxIDs + s.pts = pts + } +} + +// NewMessageStore 基于 pgx 连接池(或事务)创建 MessageStore。 +func NewMessageStore(db sqlcgen.DBTX, opts ...MessageStoreOption) *MessageStore { + s := &MessageStore{db: db, q: sqlcgen.New(db)} + for _, opt := range opts { + opt(s) + } + if s.boxIDs == nil { + s.boxIDs = pgBoxIDAllocator{s: s} + } + if s.pts == nil { + s.pts = pgPtsAllocator{events: NewUpdateEventStore(db)} + } + return s +} + +func (s *MessageStore) Create(ctx context.Context, msg domain.Message) (domain.Message, error) { + if err := s.ensureOfficialSystemUser(ctx, msg); err != nil { + return domain.Message{}, err + } + entities, err := encodeMessageEntities(msg.Entities) + if err != nil { + return domain.Message{}, err + } + if msg.Date == 0 { + msg.Date = int(time.Now().Unix()) + } + if msg.ID == 0 { + msg.ID, err = s.boxIDs.NextBoxID(ctx, msg.OwnerUserID) + if err != nil { + return domain.Message{}, fmt.Errorf("allocate login message box id: %w", err) + } + } + row, err := s.q.CreateMessage(ctx, sqlcgen.CreateMessageParams{ + OwnerUserID: msg.OwnerUserID, + BoxID: int32(msg.ID), + PeerType: string(msg.Peer.Type), + PeerID: msg.Peer.ID, + FromUserID: msg.From.ID, + MessageDate: int32(msg.Date), + Outgoing: msg.Out, + Body: msg.Body, + EntitiesJson: entities, + Pts: int32(msg.Pts), + }) + if err != nil { + return domain.Message{}, fmt.Errorf("create message: %w", err) + } + return messageFromCreateRow(row) +} + +func (s *MessageStore) ensureOfficialSystemUser(ctx context.Context, msg domain.Message) error { + if msg.Peer.Type != domain.PeerTypeUser && msg.From.Type != domain.PeerTypeUser { + return nil + } + if msg.Peer.ID != domain.OfficialSystemUserID && msg.From.ID != domain.OfficialSystemUserID { + return nil + } + u := domain.OfficialSystemUser() + if _, err := s.db.Exec(ctx, ` +INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + phone = EXCLUDED.phone, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + username = EXCLUDED.username, + country_code = EXCLUDED.country_code, + verified = EXCLUDED.verified, + support = EXCLUDED.support, + about = EXCLUDED.about, + updated_at = now() +`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About); err != nil { + return fmt.Errorf("ensure official system user: %w", err) + } + return nil +} + +func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) { + if req.SenderUserID == 0 || req.RecipientUserID == 0 { + return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing user id") + } + if req.RandomID == 0 { + return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing random id") + } + if req.Message == "" && req.Media.IsZero() { + return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: empty message") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + entities, err := encodeMessageEntities(req.Entities) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + mediaJSON, err := encodeMessageMedia(req.Media) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, req) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + senderMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, senderReply, req.Forward) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + recipientMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, recipientReply, req.Forward) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: db does not support transactions") + } + + // Redis pts/box_id 分配移到事务外:分配走 Redis(本就不属 PG 事务),放在 Begin 前可避免在 + // 持有 PG 连接(与行锁)期间空等 Redis 往返,显著降低高并发下的连接占用。代价是分配→提交窗口变长、 + // 瞬时 pts 空洞窗口变大,但已由 getState/getDifference 只暴露「连续 pts」兜底(见 internal/app/updates)。 + // box_id 空洞无害(消息 id 允许不连续);只有 pts 必须无洞,故仅把 pts 计入 reserved 做补洞。 + var reserved []reservedPts + senderBoxID, err := s.boxIDs.NextBoxID(ctx, req.SenderUserID) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("allocate sender box id: %w", err) + } + senderPts, err := s.pts.NextPts(ctx, req.SenderUserID) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("allocate sender pts: %w", err) + } + reserved = append(reserved, reservedPts{userID: req.SenderUserID, pts: senderPts}) + + var recipientBoxID, recipientPts int + selfMessage := req.RecipientUserID == req.SenderUserID + if !selfMessage { + recipientBoxID, err = s.boxIDs.NextBoxID(ctx, req.RecipientUserID) + if err != nil { + s.recordPtsGaps(ctx, reserved, req.Date) + return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient box id: %w", err) + } + recipientPts, err = s.pts.NextPts(ctx, req.RecipientUserID) + if err != nil { + s.recordPtsGaps(ctx, reserved, req.Date) + return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient pts: %w", err) + } + reserved = append(reserved, reservedPts{userID: req.RecipientUserID, pts: recipientPts}) + } + + tx, err := beginner.Begin(ctx) + if err != nil { + s.recordPtsGaps(ctx, reserved, req.Date) + return domain.SendPrivateTextResult{}, fmt.Errorf("begin send message tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + // 未提交(出错或 random_id 重复):已分配的 pts 不会落进真实事件,补 noop 占位避免 pts 永久空洞。 + s.recordPtsGaps(ctx, reserved, req.Date) + }() + + // 事务级 advisory lock 串行化涉及收发双方的并发写,在任何行锁之前获取,消除 watermark/dialog + // 行锁的 AB-BA 死锁(A↔B 反向并发 send/read/edit)。 + if err := lockUsersForUpdate(ctx, tx, req.SenderUserID, req.RecipientUserID); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("lock send users: %w", err) + } + + privateArg := sqlcgen.CreatePrivateMessageParams{ + SenderUserID: req.SenderUserID, + RecipientUserID: req.RecipientUserID, + RandomID: req.RandomID, + MessageDate: int32(req.Date), + Body: req.Message, + EntitiesJson: entities, + MediaJson: mediaJSON, + } + applyCreatePrivateMessageMetadata(&privateArg, senderMeta) + pm, err := qtx.CreatePrivateMessage(ctx, privateArg) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // 幂等重复:返回原消息盒;defer 会把本次(白白分配的)pts 补成 noop。 + dup, dupErr := s.duplicateSendResult(ctx, req.SenderUserID, req.RecipientUserID, req.RandomID) + if dupErr != nil { + return domain.SendPrivateTextResult{}, dupErr + } + dup.Duplicate = true + return dup, nil + } + return domain.SendPrivateTextResult{}, fmt.Errorf("create private message: %w", err) + } + + senderArg := sqlcgen.CreateMessageBoxParams{ + OwnerUserID: req.SenderUserID, + BoxID: int32(senderBoxID), + PrivateMessageID: pm.ID, + MessageSenderID: req.SenderUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.RecipientUserID, + FromUserID: req.SenderUserID, + MessageDate: int32(req.Date), + Outgoing: true, + Body: req.Message, + EntitiesJson: entities, + Pts: int32(senderPts), + MediaJson: mediaJSON, + } + applyCreateMessageBoxMetadata(&senderArg, senderMeta) + senderRow, err := qtx.CreateMessageBox(ctx, senderArg) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("create sender box: %w", err) + } + sender := messageFromBoxRow(senderRow) + if err := qtx.UpsertOutboxDialog(ctx, sqlcgen.UpsertOutboxDialogParams{ + UserID: req.SenderUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.RecipientUserID, + TopMessageID: int32(sender.ID), + TopMessageDate: int32(sender.Date), + }); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("upsert sender dialog: %w", err) + } + if err := appendNewMessageEvent(ctx, qtx, sender); err != nil { + return domain.SendPrivateTextResult{}, err + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: req.SenderUserID, + Pts: int32(senderPts), + EventType: string(domain.UpdateEventNewMessage), + ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), + ExcludeSessionID: req.OriginSessionID, + }); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue sender dispatch: %w", err) + } + + recipient := sender + if !selfMessage { + recipientArg := sqlcgen.CreateMessageBoxParams{ + OwnerUserID: req.RecipientUserID, + BoxID: int32(recipientBoxID), + PrivateMessageID: pm.ID, + MessageSenderID: req.SenderUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.SenderUserID, + FromUserID: req.SenderUserID, + MessageDate: int32(req.Date), + Outgoing: false, + Body: req.Message, + EntitiesJson: entities, + Pts: int32(recipientPts), + MediaJson: mediaJSON, + } + applyCreateMessageBoxMetadata(&recipientArg, recipientMeta) + recipientRow, err := qtx.CreateMessageBox(ctx, recipientArg) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("create recipient box: %w", err) + } + recipient = messageFromBoxRow(recipientRow) + if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{ + UserID: req.RecipientUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.SenderUserID, + TopMessageID: int32(recipient.ID), + TopMessageDate: int32(recipient.Date), + }); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("upsert recipient dialog: %w", err) + } + if err := appendNewMessageEvent(ctx, qtx, recipient); err != nil { + return domain.SendPrivateTextResult{}, err + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: req.RecipientUserID, + Pts: int32(recipientPts), + EventType: string(domain.UpdateEventNewMessage), + ExcludeAuthKeyID: 0, + ExcludeSessionID: 0, + }); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue recipient dispatch: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("commit send message tx: %w", err) + } + committed = true + return domain.SendPrivateTextResult{ + SenderMessage: sender, + RecipientMessage: recipient, + SenderEvent: eventFromMessage(sender), + RecipientEvent: eventFromMessage(recipient), + }, nil +} + +func (s *MessageStore) duplicateSendResult(ctx context.Context, senderUserID, recipientUserID, randomID int64) (domain.SendPrivateTextResult, error) { + pm, err := s.q.GetPrivateMessageByRandomID(ctx, sqlcgen.GetPrivateMessageByRandomIDParams{ + SenderUserID: senderUserID, + RandomID: randomID, + }) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate private message: %w", err) + } + senderRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: senderUserID, + PrivateMessageID: pm.ID, + }) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate sender box: %w", err) + } + sender := messageFromGetBoxRow(senderRow) + recipient := sender + if recipientUserID != senderUserID { + recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: recipientUserID, + PrivateMessageID: pm.ID, + }) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate recipient box: %w", err) + } + recipient = messageFromGetBoxRow(recipientRow) + } + return domain.SendPrivateTextResult{ + SenderMessage: sender, + RecipientMessage: recipient, + SenderEvent: eventFromMessage(sender), + RecipientEvent: eventFromMessage(recipient), + }, nil +} + +func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { + if req.ReplyTo == nil { + return nil, nil, nil + } + if req.ReplyTo.MessageID <= 0 || req.ReplyTo.MessageID > domain.MaxMessageBoxID { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + peer := req.ReplyTo.Peer + if peer.ID == 0 { + peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} + } + if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + source, err := s.q.GetMessageBoxForReply(ctx, sqlcgen.GetMessageBoxForReplyParams{ + OwnerUserID: req.SenderUserID, + PeerType: string(peer.Type), + PeerID: peer.ID, + BoxID: int32(req.ReplyTo.MessageID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + return nil, nil, fmt.Errorf("get reply message: %w", err) + } + senderReply := cloneMessageReply(req.ReplyTo) + senderReply.MessageID = int(source.BoxID) + senderReply.Peer = peer + if req.SenderUserID == req.RecipientUserID { + return senderReply, cloneMessageReply(senderReply), nil + } + + recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: req.RecipientUserID, + PrivateMessageID: source.PrivateMessageID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return senderReply, nil, nil + } + return nil, nil, fmt.Errorf("get recipient reply message: %w", err) + } + recipientReply := cloneMessageReply(senderReply) + recipientReply.MessageID = int(recipientRow.BoxID) + recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + return senderReply, recipientReply, nil +} + +func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { + if reply == nil { + return nil + } + clone := *reply + clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...) + return &clone +} + +func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward { + if forward == nil { + return nil + } + clone := *forward + return &clone +} + +func cloneChannelMessageAction(action *domain.ChannelMessageAction) *domain.ChannelMessageAction { + if action == nil { + return nil + } + clone := *action + clone.UserIDs = append([]int64(nil), action.UserIDs...) + if action.Closed != nil { + v := *action.Closed + clone.Closed = &v + } + if action.Hidden != nil { + v := *action.Hidden + clone.Hidden = &v + } + return &clone +} + +func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + res := domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.ToUserID == 0 { + return res, fmt.Errorf("forward private messages: missing user id") + } + if req.FromPeer.Type != domain.PeerTypeUser || req.FromPeer.ID == 0 { + return res, fmt.Errorf("forward private messages: invalid source peer") + } + if len(req.MessageIDs) == 0 || len(req.MessageIDs) != len(req.RandomIDs) { + return res, domain.ErrMessageIDInvalid + } + if len(req.MessageIDs) > domain.MaxForwardMessageIDs { + return res, fmt.Errorf("forward private messages: too many ids: %d > %d", len(req.MessageIDs), domain.MaxForwardMessageIDs) + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + boxIDs := make([]int32, 0, len(req.MessageIDs)) + for i, id := range req.MessageIDs { + if id <= 0 || id > domain.MaxMessageBoxID || req.RandomIDs[i] == 0 { + return res, domain.ErrMessageIDInvalid + } + boxIDs = append(boxIDs, int32(id)) + } + rows, err := s.q.GetMessageBoxesForForward(ctx, sqlcgen.GetMessageBoxesForForwardParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.FromPeer.Type), + PeerID: req.FromPeer.ID, + BoxIds: boxIDs, + }) + if err != nil { + return res, fmt.Errorf("get forward messages: %w", err) + } + if len(rows) != len(req.MessageIDs) { + return res, domain.ErrMessageIDInvalid + } + res.SenderMessages = make([]domain.Message, 0, len(rows)) + res.RecipientMessages = make([]domain.Message, 0, len(rows)) + res.SenderEvents = make([]domain.UpdateEvent, 0, len(rows)) + res.RecipientEvents = make([]domain.UpdateEvent, 0, len(rows)) + res.Duplicates = make([]bool, 0, len(rows)) + for i, row := range rows { + if int(row.BoxID) != req.MessageIDs[i] { + return res, domain.ErrMessageIDInvalid + } + source, err := messageFromForwardRow(row) + if err != nil { + return res, err + } + if source.NoForwards { + return res, domain.ErrChatForwardsRestricted + } + var forward *domain.MessageForward + if !req.DropAuthor { + forward = cloneMessageForward(source.Forward) + if forward == nil { + forward = &domain.MessageForward{From: source.From, Date: source.Date} + } + } + sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: req.OwnerUserID, + RecipientUserID: req.ToUserID, + RandomID: req.RandomIDs[i], + Message: source.Body, + Entities: append([]domain.MessageEntity(nil), source.Entities...), + Media: source.Media, + Silent: req.Silent, + NoForwards: req.NoForwards, + ReplyTo: req.ReplyTo, + Forward: forward, + Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, + OriginSessionID: req.OriginSessionID, + }) + if err != nil { + return res, err + } + res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) + res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) + res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) + res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) + res.Duplicates = append(res.Duplicates, sent.Duplicate) + } + return res, nil +} + +func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) { + if userID == 0 || len(ids) == 0 { + return domain.MessageList{}, nil + } + boxIDs := make([]int32, 0, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + boxIDs = append(boxIDs, int32(id)) + } + if len(boxIDs) == 0 { + return domain.MessageList{}, nil + } + rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{ + OwnerUserID: userID, + BoxIds: boxIDs, + }) + if err != nil { + return domain.MessageList{}, fmt.Errorf("get messages by ids: %w", err) + } + out := domain.MessageList{ + Messages: make([]domain.Message, 0, len(rows)), + Users: make([]domain.User, 0, len(rows)*2), + } + seenUsers := map[int64]struct{}{} + for _, row := range rows { + msg, err := messageFromIDRow(row) + if err != nil { + return domain.MessageList{}, err + } + out.Messages = append(out.Messages, msg) + appendUsersFromMessageIDRow(&out, seenUsers, row) + } + if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { + return domain.MessageList{}, err + } + out.Hash = messageListHash(out.Messages) + return out, nil +} + +func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset) + rows, err := s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{ + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + Query: filter.Query, + OffsetID: pgInt32NonNegative(filter.OffsetID), + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + AddOffset: pgInt32Bounded(addOffset), + LimitCount: int32(limit), + NeedTotalCount: filter.NeedTotalCount, + }) + if err != nil { + return domain.MessageList{}, fmt.Errorf("list messages: %w", err) + } + out := domain.MessageList{ + Messages: make([]domain.Message, 0, len(rows)), + Users: make([]domain.User, 0, len(rows)*2), + } + seenUsers := map[int64]struct{}{} + for _, row := range rows { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.MessageList{}, fmt.Errorf("decode message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + if err != nil { + return domain.MessageList{}, fmt.Errorf("decode message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.MessageList{}, fmt.Errorf("decode message media: %w", err) + } + out.Messages = append(out.Messages, domain.Message{ + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + Media: media, + }) + if out.Count == 0 { + out.Count = int(row.TotalCount) + } + appendUserFromMessageRow(&out, seenUsers, row) + } + if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { + return domain.MessageList{}, err + } + out.Hash = messageListHash(out.Messages) + return out, nil +} + +func (s *MessageStore) ReadHistory(ctx context.Context, req domain.ReadHistoryRequest) (res domain.ReadHistoryResult, err error) { + res = domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("read history: missing owner user id") + } + if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return res, fmt.Errorf("read history: invalid peer") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("read history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin read history tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + var reserved []reservedPts + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + s.recordPtsGaps(ctx, reserved, req.Date) + }() + + // advisory lock 串行化与会话对端的并发写(peer 即私聊另一方 / 回执 sender),须在行锁前获取。 + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { + return res, fmt.Errorf("lock read history users: %w", err) + } + + state, err := qtx.GetDialogReadStateForUpdate(ctx, sqlcgen.GetDialogReadStateForUpdateParams{ + UserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return res, nil + } + return res, fmt.Errorf("get dialog read state: %w", err) + } + readMax := req.MaxID + if readMax <= 0 { + readMax = int(state.TopMessageID) + } + if readMax > domain.MaxMessageBoxID { + readMax = domain.MaxMessageBoxID + } + res.MaxID = readMax + oldRead := int(state.ReadInboxMaxID) + changed := int(state.UnreadCount) > 0 || readMax > oldRead + if !changed { + return res, nil + } + + candidate, candidateErr := qtx.LatestIncomingReadReceiptCandidate(ctx, sqlcgen.LatestIncomingReadReceiptCandidateParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + OldReadInboxMaxID: int32(oldRead), + NewReadInboxMaxID: int32(readMax), + }) + if candidateErr != nil && !errors.Is(candidateErr, pgx.ErrNoRows) { + return res, fmt.Errorf("load read receipt candidate: %w", candidateErr) + } + + updated, err := qtx.UpdateDialogReadInbox(ctx, sqlcgen.UpdateDialogReadInboxParams{ + UserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + ReadInboxMaxID: int32(readMax), + }) + if err != nil { + return res, fmt.Errorf("update dialog read inbox: %w", err) + } + readerPts, err := s.pts.NextPts(ctx, req.OwnerUserID) + if err != nil { + return res, fmt.Errorf("allocate read history pts: %w", err) + } + reserved = append(reserved, reservedPts{userID: req.OwnerUserID, pts: readerPts}) + res.Changed = true + res.MaxID = int(updated.ReadInboxMaxID) + res.StillUnreadCount = int(updated.UnreadCount) + res.InboxEvent = domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventReadHistoryInbox, + Pts: readerPts, + PtsCount: 1, + Date: req.Date, + Peer: req.Peer, + MaxID: res.MaxID, + StillUnreadCount: res.StillUnreadCount, + } + if err := appendUserUpdateEvent(ctx, qtx, req.OwnerUserID, res.InboxEvent); err != nil { + return res, fmt.Errorf("append read inbox event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: req.OwnerUserID, + Pts: int32(readerPts), + EventType: string(domain.UpdateEventReadHistoryInbox), + ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), + ExcludeSessionID: req.OriginSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue read inbox dispatch: %w", err) + } + + if candidateErr == nil && candidate.SenderOwnerUserID != 0 && int(candidate.SenderBoxID) > 0 { + if _, err := qtx.UpdateDialogReadOutbox(ctx, sqlcgen.UpdateDialogReadOutboxParams{ + UserID: candidate.SenderOwnerUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.OwnerUserID, + ReadOutboxMaxID: candidate.SenderBoxID, + }); err == nil { + senderPts, err := s.pts.NextPts(ctx, candidate.SenderOwnerUserID) + if err != nil { + return res, fmt.Errorf("allocate read outbox pts: %w", err) + } + reserved = append(reserved, reservedPts{userID: candidate.SenderOwnerUserID, pts: senderPts}) + res.OutboxChanged = true + res.OutboxUserID = candidate.SenderOwnerUserID + res.OutboxEvent = domain.UpdateEvent{ + UserID: candidate.SenderOwnerUserID, + Type: domain.UpdateEventReadHistoryOutbox, + Pts: senderPts, + PtsCount: 1, + Date: req.Date, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + MaxID: int(candidate.SenderBoxID), + } + if err := appendUserUpdateEvent(ctx, qtx, candidate.SenderOwnerUserID, res.OutboxEvent); err != nil { + return res, fmt.Errorf("append read outbox event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: candidate.SenderOwnerUserID, + Pts: int32(senderPts), + EventType: string(domain.UpdateEventReadHistoryOutbox), + ExcludeAuthKeyID: 0, + ExcludeSessionID: 0, + }); err != nil { + return res, fmt.Errorf("enqueue read outbox dispatch: %w", err) + } + } else if !errors.Is(err, pgx.ErrNoRows) { + return res, fmt.Errorf("update dialog read outbox: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit read history tx: %w", err) + } + committed = true + return res, nil +} + +func (s *MessageStore) ReadMessageContents(ctx context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { + res := domain.ReadMessageContentsResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("read message contents: missing owner user id") + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return res, domain.ErrMessageIDInvalid + } + seen := make(map[int]struct{}, len(req.IDs)) + ids := make([]int32, 0, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, int32(id)) + } + if len(ids) == 0 { + return res, nil + } + rows, err := s.db.Query(ctx, ` +SELECT box_id +FROM message_boxes +WHERE owner_user_id = $1 + AND box_id = ANY($2::int[]) + AND NOT deleted +ORDER BY box_id`, req.OwnerUserID, ids) + if err != nil { + return res, fmt.Errorf("read message contents: %w", err) + } + defer rows.Close() + for rows.Next() { + var id int32 + if err := rows.Scan(&id); err != nil { + return res, fmt.Errorf("scan read message contents: %w", err) + } + res.MessageIDs = append(res.MessageIDs, int(id)) + } + if err := rows.Err(); err != nil { + return res, fmt.Errorf("read message contents rows: %w", err) + } + return res, nil +} + +func (s *MessageStore) GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error) { + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return 0, domain.ErrMessageIDInvalid + } + if _, err := s.q.GetOutboxMessageForReadDate(ctx, sqlcgen.GetOutboxMessageForReadDateParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + BoxID: int32(req.ID), + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, domain.ErrMessageIDInvalid + } + return 0, fmt.Errorf("get outbox message for read date: %w", err) + } + date, err := s.q.GetOutboxReadDate(ctx, sqlcgen.GetOutboxReadDateParams{ + UserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + MessageID: int32(req.ID), + }) + if err != nil { + return 0, fmt.Errorf("get outbox read date: %w", err) + } + if date == 0 { + return 0, domain.ErrMessageNotReadYet + } + return int(date), nil +} + +func (s *MessageStore) SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if req.UserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + for _, reaction := range req.Reactions { + if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("set message reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("begin set message reactions tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + if err := lockUsersForUpdate(ctx, tx, req.UserID, req.Peer.ID); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("lock set message reactions users: %w", err) + } + + var target struct { + boxID int32 + privateMessageID int64 + messageSenderID int64 + } + if err := tx.QueryRow(ctx, ` +SELECT box_id, private_message_id, message_sender_id +FROM message_boxes +WHERE owner_user_id = $1 + AND box_id = $2 + AND peer_type = $3 + AND peer_id = $4 + AND NOT deleted +LIMIT 1 +FOR UPDATE`, req.UserID, int32(req.MessageID), string(req.Peer.Type), req.Peer.ID).Scan(&target.boxID, &target.privateMessageID, &target.messageSenderID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("get message for reactions: %w", err) + } + if _, err := tx.Exec(ctx, ` +DELETE FROM private_message_reactions +WHERE message_sender_id = $1 + AND private_message_id = $2 + AND user_id = $3`, target.messageSenderID, target.privateMessageID, req.UserID); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("delete old message reactions: %w", err) + } + for i, reaction := range req.Reactions { + if _, err := tx.Exec(ctx, ` +INSERT INTO private_message_reactions ( + message_sender_id, + private_message_id, + user_id, + reaction_type, + reaction_value, + big, + reaction_date, + chosen_order +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (message_sender_id, private_message_id, user_id, reaction_type, reaction_value) +DO UPDATE SET + big = EXCLUDED.big, + reaction_date = EXCLUDED.reaction_date, + chosen_order = EXCLUDED.chosen_order, + updated_at = now()`, + target.messageSenderID, + target.privateMessageID, + req.UserID, + string(reaction.Type), + reaction.Emoticon, + req.Big, + int32(req.Date), + int32(i+1), + ); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("insert message reaction: %w", err) + } + } + + boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + MessageSenderID: target.messageSenderID, + PrivateMessageID: target.privateMessageID, + }) + if err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("list visible reaction boxes: %w", err) + } + res := domain.PrivateMessageReactionsResult{Messages: make([]domain.Message, 0, len(boxes))} + for _, box := range boxes { + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return domain.PrivateMessageReactionsResult{}, err + } + res.Messages = append(res.Messages, msg) + } + if err := s.enrichPrivateMessageReactions(ctx, tx, req.UserID, res.Messages); err != nil { + return domain.PrivateMessageReactionsResult{}, err + } + for _, msg := range res.Messages { + if msg.OwnerUserID == req.UserID && msg.Reactions != nil { + res.Reactions = *msg.Reactions + break + } + } + if err := tx.Commit(ctx); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("commit set message reactions tx: %w", err) + } + committed = true + return res, nil +} + +func (s *MessageStore) GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if len(req.IDs) == 0 { + return domain.PrivateMessageReactionsResult{}, nil + } + boxIDs := make([]int32, 0, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + boxIDs = append(boxIDs, int32(id)) + } + rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{ + OwnerUserID: req.OwnerUserID, + BoxIds: boxIDs, + }) + if err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("get message reactions boxes: %w", err) + } + res := domain.PrivateMessageReactionsResult{Messages: make([]domain.Message, 0, len(rows))} + for _, row := range rows { + if row.PeerType != string(req.Peer.Type) || row.PeerID != req.Peer.ID { + continue + } + msg, err := messageFromIDRow(row) + if err != nil { + return domain.PrivateMessageReactionsResult{}, err + } + res.Messages = append(res.Messages, msg) + } + if err := s.enrichPrivateMessageReactions(ctx, s.db, req.OwnerUserID, res.Messages); err != nil { + return domain.PrivateMessageReactionsResult{}, err + } + for _, msg := range res.Messages { + if msg.Reactions != nil { + res.Reactions = *msg.Reactions + break + } + } + return res, nil +} + +func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRequest) (res domain.EditMessageResult, err error) { + res = domain.EditMessageResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("edit message: missing owner user id") + } + if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + if req.Message == "" { + return res, fmt.Errorf("edit message: empty message") + } + if req.EditDate == 0 { + req.EditDate = int(time.Now().Unix()) + } + entities, err := encodeMessageEntities(req.Entities) + if err != nil { + return res, err + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("edit message: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin edit message tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + var reserved []reservedPts + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + s.recordPtsGaps(ctx, reserved, req.EditDate) + }() + + // advisory lock 串行化与会话对端的并发写,须在行锁前获取,消除 AB-BA 死锁。 + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { + return res, fmt.Errorf("lock edit message users: %w", err) + } + + target, err := qtx.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ + OwnerUserID: req.OwnerUserID, + BoxID: int32(req.ID), + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return res, domain.ErrMessageIDInvalid + } + return res, fmt.Errorf("get message for edit: %w", err) + } + if !target.Outgoing || target.MessageSenderID != req.OwnerUserID || target.FromUserID != req.OwnerUserID { + return res, domain.ErrMessageAuthorRequired + } + oldEntities, err := decodeMessageEntities(target.EntitiesJson) + if err != nil { + return res, fmt.Errorf("decode target entities: %w", err) + } + if target.Body == req.Message && sameMessageEntities(oldEntities, req.Entities) { + return res, domain.ErrMessageNotModified + } + boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + MessageSenderID: req.OwnerUserID, + PrivateMessageID: target.PrivateMessageID, + }) + if err != nil { + return res, fmt.Errorf("list visible edit boxes: %w", err) + } + if len(boxes) == 0 { + return res, domain.ErrMessageIDInvalid + } + if err := qtx.UpdatePrivateMessageEdit(ctx, sqlcgen.UpdatePrivateMessageEditParams{ + SenderUserID: req.OwnerUserID, + PrivateMessageID: target.PrivateMessageID, + Body: req.Message, + EntitiesJson: entities, + EditDate: int32(req.EditDate), + }); err != nil { + return res, fmt.Errorf("update private message edit: %w", err) + } + res.Edited = make([]domain.EditedMessageForUser, 0, len(boxes)) + for _, box := range boxes { + pts, err := s.pts.NextPts(ctx, box.OwnerUserID) + if err != nil { + return res, fmt.Errorf("allocate edit message pts: %w", err) + } + reserved = append(reserved, reservedPts{userID: box.OwnerUserID, pts: pts}) + updated, err := qtx.UpdateMessageBoxEdit(ctx, sqlcgen.UpdateMessageBoxEditParams{ + OwnerUserID: box.OwnerUserID, + BoxID: box.BoxID, + Body: req.Message, + EntitiesJson: entities, + EditDate: int32(req.EditDate), + Pts: int32(pts), + }) + if err != nil { + return res, fmt.Errorf("update message box edit: %w", err) + } + msg, err := messageFromUpdateEditRow(updated) + if err != nil { + return res, err + } + event := domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventEditMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: req.EditDate, + Message: msg, + } + if err := appendUserUpdateEvent(ctx, qtx, msg.OwnerUserID, event); err != nil { + return res, fmt.Errorf("append edit message event: %w", err) + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if msg.OwnerUserID == req.OwnerUserID { + dispatchAuthKeyID = req.OriginAuthKeyID + dispatchSessionID = req.OriginSessionID + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: msg.OwnerUserID, + Pts: int32(pts), + EventType: string(domain.UpdateEventEditMessage), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), + ExcludeSessionID: dispatchSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue edit message dispatch: %w", err) + } + res.Edited = append(res.Edited, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit edit message tx: %w", err) + } + committed = true + return res, nil +} + +func (s *MessageStore) DeleteMessages(ctx context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("delete messages: missing owner user id") + } + ids := normalizeMessageIDs(req.IDs) + if len(ids) == 0 { + return res, nil + } + if len(ids) > domain.MaxDeleteMessageIDs { + return res, fmt.Errorf("delete messages: too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs) + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("delete messages: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin delete messages tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + var reserved []reservedPts + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + s.recordPtsGaps(ctx, reserved, req.Date) + }() + + // advisory lock 串行化本 owner 的并发写(与 send/edit/read/其它 delete 共享 owner 时串行)。 + // 被删消息的对端是动态的(由删除结果推出),未在此锁定;但 finishDeleteMessagesTx 内 watermark + // 与 dialog rebuild 均按 user_id 升序执行,故两个反向 delete 也不会 AB-BA。 + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil { + return res, fmt.Errorf("lock delete messages user: %w", err) + } + + rows, err := qtx.DeleteMessageBoxesByIDs(ctx, sqlcgen.DeleteMessageBoxesByIDsParams{ + OwnerUserID: req.OwnerUserID, + BoxIds: int32s(ids), + }) + if err != nil { + return res, fmt.Errorf("delete message boxes by ids: %w", err) + } + deleted := deletedRowsFromIDRows(rows) + if req.Revoke && len(deleted) > 0 { + peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted)) + if err != nil { + return res, fmt.Errorf("delete revoked private message boxes: %w", err) + } + deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...) + } + res, reserved, err = s.finishDeleteMessagesTx(ctx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, false) + if err != nil { + return res, err + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit delete messages tx: %w", err) + } + committed = true + return res, nil +} + +func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("delete history: missing owner user id") + } + if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return res, fmt.Errorf("delete history: invalid peer") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("delete history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin delete history tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + var reserved []reservedPts + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + s.recordPtsGaps(ctx, reserved, req.Date) + }() + + // advisory lock 串行化与会话对端的并发写,须在行锁前获取,消除 AB-BA 死锁。 + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { + return res, fmt.Errorf("lock delete history users: %w", err) + } + + maxID := pgInt32NonNegative(req.MaxID) + rows, err := qtx.DeleteMessageBoxesByPeerBatch(ctx, sqlcgen.DeleteMessageBoxesByPeerBatchParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + MaxID: maxID, + LimitCount: int32(domain.MaxDeleteHistoryBatch), + }) + if err != nil { + return res, fmt.Errorf("delete message boxes by peer: %w", err) + } + deleted := deletedRowsFromPeerBatchRows(rows) + if req.Revoke && len(deleted) > 0 { + peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted)) + if err != nil { + return res, fmt.Errorf("delete revoked private history boxes: %w", err) + } + deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...) + } + res, reserved, err = s.finishDeleteMessagesTx(ctx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, req.JustClear) + if err != nil { + return res, err + } + more, err := qtx.HasDeletableMessageBoxByPeer(ctx, sqlcgen.HasDeletableMessageBoxByPeerParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + MaxID: maxID, + }) + if err != nil { + return res, fmt.Errorf("check remaining history after delete: %w", err) + } + if more { + res.Offset = 1 + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit delete history tx: %w", err) + } + committed = true + return res, nil +} + +type deletedBox struct { + ownerUserID int64 + boxID int + privateMessageID int64 + messageSenderID int64 + peer domain.Peer +} + +func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, q *sqlcgen.Queries, ownerUserID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, date int, rows []deletedBox, preserveEmptyDialogs bool) (domain.DeleteMessagesResult, []reservedPts, error) { + res := domain.DeleteMessagesResult{OwnerUserID: ownerUserID} + if len(rows) == 0 { + return res, nil, nil + } + peersByOwner := make(map[int64]map[domain.Peer]struct{}) + idsByOwner := make(map[int64][]int) + for _, row := range rows { + if row.ownerUserID == 0 || row.boxID == 0 { + continue + } + idsByOwner[row.ownerUserID] = append(idsByOwner[row.ownerUserID], row.boxID) + if row.peer.ID != 0 { + if peersByOwner[row.ownerUserID] == nil { + peersByOwner[row.ownerUserID] = make(map[domain.Peer]struct{}) + } + peersByOwner[row.ownerUserID][row.peer] = struct{}{} + } + } + // 按 owner 升序重建 dialog,使两个反向 delete(X 删与 Y 的会话 / Y 删与 X 的会话)以一致顺序 + // 获取 dialog 行锁,配合下方 watermark 的升序推进,彻底避免 delete-delete 之间的 AB-BA 死锁。 + rebuildOwners := make([]int64, 0, len(peersByOwner)) + for userID := range peersByOwner { + rebuildOwners = append(rebuildOwners, userID) + } + sort.Slice(rebuildOwners, func(i, j int) bool { return rebuildOwners[i] < rebuildOwners[j] }) + for _, userID := range rebuildOwners { + for peer := range peersByOwner[userID] { + if err := rebuildDialogAfterMessageDelete(ctx, q, userID, peer, preserveEmptyDialogs); err != nil { + return res, nil, err + } + } + } + + ownerIDs := make([]int64, 0, len(idsByOwner)) + for userID := range idsByOwner { + ownerIDs = append(ownerIDs, userID) + } + sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] }) + + reserved := make([]reservedPts, 0, len(ownerIDs)) + res.Deleted = make([]domain.DeletedMessagesForUser, 0, len(ownerIDs)) + for _, userID := range ownerIDs { + ids := normalizeMessageIDs(idsByOwner[userID]) + if len(ids) == 0 { + continue + } + pts, err := s.nextPtsN(ctx, userID, len(ids)) + if err != nil { + return res, reserved, fmt.Errorf("allocate delete messages pts: %w", err) + } + reserved = append(reserved, reservedPts{userID: userID, pts: pts, count: len(ids)}) + event := domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventDeleteMessages, + Pts: pts, + PtsCount: len(ids), + Date: date, + MessageIDs: ids, + } + if err := appendDeleteMessagesEvent(ctx, q, event); err != nil { + return res, reserved, err + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if userID == ownerUserID { + dispatchAuthKeyID = excludeAuthKeyID + dispatchSessionID = excludeSessionID + } + if err := q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: userID, + Pts: int32(pts), + EventType: string(domain.UpdateEventDeleteMessages), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), + ExcludeSessionID: dispatchSessionID, + }); err != nil { + return res, reserved, fmt.Errorf("enqueue delete messages dispatch: %w", err) + } + res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{ + UserID: userID, + MessageIDs: ids, + Event: event, + }) + } + return res, reserved, nil +} + +func rebuildDialogAfterMessageDelete(ctx context.Context, q *sqlcgen.Queries, userID int64, peer domain.Peer, preserveEmpty bool) error { + top, err := q.TopVisibleMessageBoxByPeer(ctx, sqlcgen.TopVisibleMessageBoxByPeerParams{ + OwnerUserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }) + if errors.Is(err, pgx.ErrNoRows) { + if preserveEmpty { + if err := q.ClearDialogAfterHistoryDelete(ctx, sqlcgen.ClearDialogAfterHistoryDeleteParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }); err != nil { + return fmt.Errorf("clear empty dialog after history delete: %w", err) + } + return nil + } + if err := q.DeleteDialogByPeer(ctx, sqlcgen.DeleteDialogByPeerParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }); err != nil { + return fmt.Errorf("delete empty dialog after message delete: %w", err) + } + return nil + } + if err != nil { + return fmt.Errorf("load top message after delete: %w", err) + } + if err := q.RefreshDialogAfterMessageDelete(ctx, sqlcgen.RefreshDialogAfterMessageDeleteParams{ + TopMessageID: top.BoxID, + TopMessageDate: top.MessageDate, + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }); err != nil { + return fmt.Errorf("refresh dialog after message delete: %w", err) + } + return nil +} + +func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event domain.UpdateEvent) error { + messageIDs, err := encodeEventMessageIDs(event.MessageIDs) + if err != nil { + return err + } + if event.PtsCount == 0 { + event.PtsCount = len(event.MessageIDs) + } + if event.PtsCount == 0 { + event.PtsCount = 1 + } + if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ + UserID: event.UserID, + Pts: int32(event.Pts), + PtsCount: int32(event.PtsCount), + Date: int32(event.Date), + EventType: string(domain.UpdateEventDeleteMessages), + EventPeers: []byte("[]"), + PeerSettings: []byte("{}"), + MessageIds: messageIDs, + DialogFilter: []byte("{}"), + FilterOrder: []byte("[]"), + FolderPeers: []byte("[]"), + }); err != nil { + return fmt.Errorf("append delete messages event: %w", err) + } + if _, err := advanceContiguousPts(ctx, q, event.UserID); err != nil { + return fmt.Errorf("advance update watermark after delete messages: %w", err) + } + return nil +} + +func deletedRowsFromIDRows(rows []sqlcgen.DeleteMessageBoxesByIDsRow) []deletedBox { + out := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + out = append(out, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + return out +} + +func deletedRowsFromPeerRows(rows []sqlcgen.DeleteMessageBoxesByPeerRow) []deletedBox { + out := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + out = append(out, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + return out +} + +func deletedRowsFromPeerBatchRows(rows []sqlcgen.DeleteMessageBoxesByPeerBatchRow) []deletedBox { + out := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + out = append(out, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + return out +} + +func deletedRowsFromPrivateRows(rows []sqlcgen.DeleteMessageBoxesByPrivateMessagesRow) []deletedBox { + out := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + out = append(out, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + return out +} + +func privateMessageDeleteParams(rows []deletedBox) sqlcgen.DeleteMessageBoxesByPrivateMessagesParams { + senderIDs := make([]int64, 0, len(rows)) + privateIDs := make([]int64, 0, len(rows)) + seen := make(map[[2]int64]struct{}, len(rows)) + for _, row := range rows { + key := [2]int64{row.messageSenderID, row.privateMessageID} + if row.messageSenderID == 0 || row.privateMessageID == 0 { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + senderIDs = append(senderIDs, row.messageSenderID) + privateIDs = append(privateIDs, row.privateMessageID) + } + return sqlcgen.DeleteMessageBoxesByPrivateMessagesParams{ + MessageSenderIds: senderIDs, + PrivateMessageIds: privateIDs, + } +} + +func normalizeMessageIDs(ids []int) []int { + if len(ids) == 0 { + return nil + } + out := make([]int, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Ints(out) + return out +} + +func int32s(ids []int) []int32 { + if len(ids) == 0 { + return nil + } + out := make([]int32, 0, len(ids)) + for _, id := range ids { + out = append(out, pgInt32NonNegative(id)) + } + return out +} + +func pgInt32NonNegative(v int) int32 { + if v <= 0 { + return 0 + } + if v > domain.MaxMessageBoxID { + return int32(domain.MaxMessageBoxID) + } + return int32(v) +} + +func pgInt32Bounded(v int) int32 { + if v > domain.MaxMessageBoxID { + return int32(domain.MaxMessageBoxID) + } + if v < -domain.MaxMessageBoxID { + return int32(-domain.MaxMessageBoxID) + } + return int32(v) +} + +func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.Message) error { + boxID := int32(msg.ID) + peerType := string(msg.Peer.Type) + peerID := msg.Peer.ID + if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ + UserID: msg.OwnerUserID, + Pts: int32(msg.Pts), + PtsCount: 1, + Date: int32(msg.Date), + EventType: string(domain.UpdateEventNewMessage), + EventPeers: []byte("[]"), + PeerSettings: []byte("{}"), + MessageIds: []byte("[]"), + DialogFilter: []byte("{}"), + FilterOrder: []byte("[]"), + FolderPeers: []byte("[]"), + MessageBoxID: &boxID, + PeerType: &peerType, + PeerID: &peerID, + }); err != nil { + return fmt.Errorf("append new message event: %w", err) + } + if _, err := advanceContiguousPts(ctx, q, msg.OwnerUserID); err != nil { + return fmt.Errorf("advance update watermark after new message: %w", err) + } + return nil +} + +// lockUsersForUpdate 在事务开始处用事务级 advisory lock 串行化所有涉及指定用户的并发写事务。 +// advisory lock 与行锁处于独立锁空间,且按 user_id 升序获取,因此:① 不会与后续 dialog / +// watermark / box 行锁交叉成跨类型死锁;② 任意两个共享某用户的写事务(send/read/edit/delete 对 +// 收发双方的并发操作)被完全串行化,从根上消除它们在 watermark 与 dialog 行上因加锁顺序相反 +// 导致的 AB-BA 死锁——既包含本次 watermark 优化新引入的(user_update_watermarks FOR UPDATE), +// 也包含 dialog upsert 既有的反向行锁。advisory xact lock 在事务结束自动释放;同对用户本就竞争 +// 这些行(天然串行),不额外降并发,不同用户集合的事务仍并行。**必须在任何行锁之前调用。** +func lockUsersForUpdate(ctx context.Context, tx pgx.Tx, userIDs ...int64) error { + if len(userIDs) == 0 { + return nil + } + unique := make([]int64, 0, len(userIDs)) + seen := make(map[int64]struct{}, len(userIDs)) + for _, id := range userIDs { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] }) + for _, id := range unique { + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", id); err != nil { + return fmt.Errorf("advisory lock user %d: %w", id, err) + } + } + return nil +} + +type reservedPts struct { + userID int64 + pts int + count int +} + +func (s *MessageStore) recordPtsGaps(ctx context.Context, items []reservedPts, date int) { + for _, item := range items { + count := item.count + if count <= 0 { + count = 1 + } + _ = s.q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ + UserID: item.userID, + Pts: int32(item.pts), + PtsCount: int32(count), + Date: int32(date), + EventType: string(domain.UpdateEventNoop), + EventPeers: []byte("[]"), + PeerSettings: []byte("{}"), + MessageIds: []byte("[]"), + DialogFilter: []byte("{}"), + FilterOrder: []byte("[]"), + FolderPeers: []byte("[]"), + }) + _, _ = advanceContiguousPts(ctx, s.q, item.userID) + } +} + +func (s *MessageStore) nextPtsN(ctx context.Context, userID int64, count int) (int, error) { + if count <= 0 { + count = 1 + } + if count == 1 { + return s.pts.NextPts(ctx, userID) + } + if ranges, ok := s.pts.(store.PtsRangeAllocator); ok { + return ranges.NextPtsN(ctx, userID, count) + } + var pts int + var err error + for i := 0; i < count; i++ { + pts, err = s.pts.NextPts(ctx, userID) + if err != nil { + return 0, err + } + } + return pts, nil +} + +type messageMetadataParams struct { + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJSON []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 +} + +func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageReply, forward *domain.MessageForward) (messageMetadataParams, error) { + meta := messageMetadataParams{ + Silent: silent, + Noforwards: noforwards, + QuoteEntitiesJSON: []byte("[]"), + } + if reply != nil { + if err := domain.ValidateMessageReplyBounds(reply); err != nil { + return messageMetadataParams{}, err + } + quoteEntities, err := encodeMessageEntities(reply.QuoteEntities) + if err != nil { + return messageMetadataParams{}, err + } + meta.ReplyToMsgID = int32(reply.MessageID) + meta.ReplyToPeerType = string(reply.Peer.Type) + meta.ReplyToPeerID = reply.Peer.ID + meta.ReplyToTopID = int32(reply.TopMessageID) + meta.QuoteText = reply.QuoteText + meta.QuoteEntitiesJSON = quoteEntities + meta.QuoteOffset = int32(reply.QuoteOffset) + } + if forward != nil { + if forward.Date < 0 { + return messageMetadataParams{}, fmt.Errorf("forward metadata: invalid date") + } + meta.FwdFromPeerType = string(forward.From.Type) + meta.FwdFromPeerID = forward.From.ID + meta.FwdFromName = forward.FromName + meta.FwdDate = int32(forward.Date) + } + return meta, nil +} + +func applyCreatePrivateMessageMetadata(arg *sqlcgen.CreatePrivateMessageParams, meta messageMetadataParams) { + arg.Silent = meta.Silent + arg.Noforwards = meta.Noforwards + arg.ReplyToMsgID = meta.ReplyToMsgID + arg.ReplyToPeerType = meta.ReplyToPeerType + arg.ReplyToPeerID = meta.ReplyToPeerID + arg.ReplyToTopID = meta.ReplyToTopID + arg.QuoteText = meta.QuoteText + arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON + arg.QuoteOffset = meta.QuoteOffset + arg.FwdFromPeerType = meta.FwdFromPeerType + arg.FwdFromPeerID = meta.FwdFromPeerID + arg.FwdFromName = meta.FwdFromName + arg.FwdDate = meta.FwdDate +} + +func applyCreateMessageBoxMetadata(arg *sqlcgen.CreateMessageBoxParams, meta messageMetadataParams) { + arg.Silent = meta.Silent + arg.Noforwards = meta.Noforwards + arg.ReplyToMsgID = meta.ReplyToMsgID + arg.ReplyToPeerType = meta.ReplyToPeerType + arg.ReplyToPeerID = meta.ReplyToPeerID + arg.ReplyToTopID = meta.ReplyToTopID + arg.QuoteText = meta.QuoteText + arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON + arg.QuoteOffset = meta.QuoteOffset + arg.FwdFromPeerType = meta.FwdFromPeerType + arg.FwdFromPeerID = meta.FwdFromPeerID + arg.FwdFromName = meta.FwdFromName + arg.FwdDate = meta.FwdDate +} + +func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, replyToPeerType string, replyToPeerID int64, replyToTopID int32, quoteText, quoteEntitiesJSON string, quoteOffset int32, fwdFromPeerType string, fwdFromPeerID int64, fwdFromName string, fwdDate int32) (bool, bool, *domain.MessageReply, *domain.MessageForward, error) { + var reply *domain.MessageReply + if replyToMsgID > 0 { + quoteEntities, err := decodeMessageEntities(quoteEntitiesJSON) + if err != nil { + return false, false, nil, nil, err + } + reply = &domain.MessageReply{ + MessageID: int(replyToMsgID), + Peer: domain.Peer{Type: domain.PeerType(replyToPeerType), ID: replyToPeerID}, + TopMessageID: int(replyToTopID), + QuoteText: quoteText, + QuoteEntities: quoteEntities, + QuoteOffset: int(quoteOffset), + } + } + var forward *domain.MessageForward + if fwdDate != 0 || fwdFromPeerID != 0 || fwdFromName != "" { + forward = &domain.MessageForward{ + From: domain.Peer{Type: domain.PeerType(fwdFromPeerType), ID: fwdFromPeerID}, + FromName: fwdFromName, + Date: int(fwdDate), + } + } + return silent, noforwards, reply, forward, nil +} + +func messageFromCreateRow(row sqlcgen.CreateMessageRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message entities: %w", err) + } + return domain.Message{ + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Body: row.Body, + Entities: entities, + Pts: int(row.Pts), + }, nil +} + +func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message { + entities, _ := decodeMessageEntities(row.EntitiesJson) + media, _ := decodeMessageMedia(row.MediaJson) + silent, noforwards, reply, forward, _ := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + return domain.Message{ + Media: media, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + } +} + +func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.Message { + entities, _ := decodeMessageEntities(row.EntitiesJson) + media, _ := decodeMessageMedia(row.MediaJson) + silent, noforwards, reply, forward, _ := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + return domain.Message{ + Media: media, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + } +} + +func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessageRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode visible message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode visible message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode visible message media: %w", err) + } + return domain.Message{ + Media: media, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + }, nil +} + +func messageFromUpdateEditRow(row sqlcgen.UpdateMessageBoxEditRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode edited message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode edited message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode edited message media: %w", err) + } + return domain.Message{ + Media: media, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + }, nil +} + +func messageFromForwardRow(row sqlcgen.GetMessageBoxesForForwardRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode forward message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode forward message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode forward message media: %w", err) + } + return domain.Message{ + Media: media, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + }, nil +} + +func messageFromIDRow(row sqlcgen.GetMessageBoxesByIDsRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message media: %w", err) + } + return domain.Message{ + Media: media, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + }, nil +} + +func eventFromMessage(msg domain.Message) domain.UpdateEvent { + return domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + } +} + +func appendUserFromMessageRow(out *domain.MessageList, seen map[int64]struct{}, row sqlcgen.ListMessagesByUserRow) { + appendMessageUsers(out, seen, + domain.User{ + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + LastSeenAt: int(row.PeerLastSeenAt), + }, + domain.User{ + ID: row.FromUserUserID, + AccessHash: row.FromUserAccessHash, + Phone: row.FromUserPhone, + FirstName: row.FromUserFirstName, + LastName: row.FromUserLastName, + Username: row.FromUserUsername, + CountryCode: row.FromUserCountryCode, + Verified: row.FromUserVerified, + Support: row.FromUserSupport, + LastSeenAt: int(row.FromUserLastSeenAt), + }, + ) +} + +func appendUsersFromMessageIDRow(out *domain.MessageList, seen map[int64]struct{}, row sqlcgen.GetMessageBoxesByIDsRow) { + appendMessageUsers(out, seen, + domain.User{ + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + LastSeenAt: int(row.PeerLastSeenAt), + }, + domain.User{ + ID: row.FromUserUserID, + AccessHash: row.FromUserAccessHash, + Phone: row.FromUserPhone, + FirstName: row.FromUserFirstName, + LastName: row.FromUserLastName, + Username: row.FromUserUsername, + CountryCode: row.FromUserCountryCode, + Verified: row.FromUserVerified, + Support: row.FromUserSupport, + LastSeenAt: int(row.FromUserLastSeenAt), + }, + ) +} + +func appendMessageUsers(out *domain.MessageList, seen map[int64]struct{}, users ...domain.User) { + add := func(u domain.User) { + if u.ID == 0 { + return + } + if _, ok := seen[u.ID]; ok { + return + } + seen[u.ID] = struct{}{} + out.Users = append(out.Users, u) + } + for _, user := range users { + add(user) + } +} + +type privateMessageReactionRow struct { + messageSenderID int64 + privateMessageID int64 + userID int64 + reaction domain.MessageReaction + big bool + date int + chosenOrder int +} + +type privateMessageReactionKey struct { + messageSenderID int64 + privateMessageID int64 +} + +func (s *MessageStore) enrichPrivateMessageReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.Message) error { + if len(messages) == 0 { + return nil + } + keySet := make(map[privateMessageReactionKey]struct{}, len(messages)) + senderIDs := make([]int64, 0, len(messages)) + privateIDs := make([]int64, 0, len(messages)) + for _, msg := range messages { + if msg.UID == 0 || msg.From.ID == 0 { + continue + } + key := privateMessageReactionKey{messageSenderID: msg.From.ID, privateMessageID: msg.UID} + if _, ok := keySet[key]; ok { + continue + } + keySet[key] = struct{}{} + senderIDs = append(senderIDs, key.messageSenderID) + privateIDs = append(privateIDs, key.privateMessageID) + } + if len(senderIDs) == 0 { + return nil + } + rows, err := db.Query(ctx, ` +WITH wanted AS ( + SELECT message_sender_id, private_message_id + FROM unnest($1::bigint[], $2::bigint[]) AS w(message_sender_id, private_message_id) +) +SELECT r.message_sender_id, r.private_message_id, r.user_id, r.reaction_type, r.reaction_value, r.big, r.reaction_date, r.chosen_order +FROM private_message_reactions r +JOIN wanted w + ON w.message_sender_id = r.message_sender_id + AND w.private_message_id = r.private_message_id +ORDER BY r.message_sender_id ASC, r.private_message_id ASC, r.reaction_date DESC, r.user_id DESC, r.reaction_value ASC`, senderIDs, privateIDs) + if err != nil { + return fmt.Errorf("load private message reactions: %w", err) + } + defer rows.Close() + byMessage := make(map[privateMessageReactionKey][]privateMessageReactionRow) + for rows.Next() { + var ( + messageSenderID int64 + uid int64 + userID int64 + reactionType string + value string + big bool + date int32 + chosenOrder int32 + ) + if err := rows.Scan(&messageSenderID, &uid, &userID, &reactionType, &value, &big, &date, &chosenOrder); err != nil { + return fmt.Errorf("scan private message reactions: %w", err) + } + if reactionType != string(domain.MessageReactionEmoji) || strings.TrimSpace(value) == "" { + continue + } + key := privateMessageReactionKey{messageSenderID: messageSenderID, privateMessageID: uid} + byMessage[key] = append(byMessage[key], privateMessageReactionRow{ + messageSenderID: messageSenderID, + privateMessageID: uid, + userID: userID, + reaction: domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: value}, + big: big, + date: int(date), + chosenOrder: int(chosenOrder), + }) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("private message reactions rows: %w", err) + } + for i := range messages { + key := privateMessageReactionKey{messageSenderID: messages[i].From.ID, privateMessageID: messages[i].UID} + reactions := privateMessageReactionsFromRows(byMessage[key], viewerUserID) + if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { + continue + } + messages[i].Reactions = &reactions + } + return nil +} + +func privateMessageReactionsFromRows(rows []privateMessageReactionRow, viewerUserID int64) domain.ChannelMessageReactions { + out := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } + if len(rows) == 0 { + return out + } + type aggregate struct { + reaction domain.MessageReaction + count int + chosenOrder int + latestDate int + } + aggregates := make(map[string]*aggregate) + recent := make([]domain.ChannelMessagePeerReaction, 0, len(rows)) + for _, row := range rows { + key := string(row.reaction.Type) + "\x00" + row.reaction.Emoticon + item := aggregates[key] + if item == nil { + item = &aggregate{reaction: row.reaction} + aggregates[key] = item + } + item.count++ + if row.userID == viewerUserID && row.chosenOrder > 0 && (item.chosenOrder == 0 || row.chosenOrder < item.chosenOrder) { + item.chosenOrder = row.chosenOrder + } + if row.date > item.latestDate { + item.latestDate = row.date + } + recent = append(recent, domain.ChannelMessagePeerReaction{ + UserID: row.userID, + Reaction: row.reaction, + Big: row.big, + My: row.userID == viewerUserID, + ChosenOrder: row.chosenOrder, + Date: row.date, + }) + } + items := make([]aggregate, 0, len(aggregates)) + for _, item := range aggregates { + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].count != items[j].count { + return items[i].count > items[j].count + } + if items[i].latestDate != items[j].latestDate { + return items[i].latestDate > items[j].latestDate + } + return items[i].reaction.Emoticon < items[j].reaction.Emoticon + }) + for _, item := range items { + out.Results = append(out.Results, domain.ChannelMessageReactionCount{ + Reaction: item.reaction, + Count: item.count, + ChosenOrder: item.chosenOrder, + }) + } + sort.Slice(recent, func(i, j int) bool { + if recent[i].Date != recent[j].Date { + return recent[i].Date > recent[j].Date + } + if recent[i].UserID != recent[j].UserID { + return recent[i].UserID > recent[j].UserID + } + return recent[i].Reaction.Emoticon < recent[j].Reaction.Emoticon + }) + if len(recent) > domain.MaxChannelMessageReactionRecent { + recent = recent[:domain.MaxChannelMessageReactionRecent] + } + out.Recent = recent + return out +} + +type pgBoxIDAllocator struct { + s *MessageStore +} + +func (a pgBoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) { + cur, err := a.CurrentBoxID(ctx, userID) + if err != nil { + return 0, err + } + return cur + 1, nil +} + +func (a pgBoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) { + v, err := a.s.q.MaxMessageBoxID(ctx, userID) + if err != nil { + return 0, err + } + return int(v), nil +} + +type pgPtsAllocator struct { + events *UpdateEventStore +} + +func (a pgPtsAllocator) NextPts(ctx context.Context, userID int64) (int, error) { + cur, err := a.CurrentPts(ctx, userID) + if err != nil { + return 0, err + } + return cur + 1, nil +} + +func (a pgPtsAllocator) NextPtsN(ctx context.Context, userID int64, count int) (int, error) { + if count <= 0 { + count = 1 + } + cur, err := a.CurrentPts(ctx, userID) + if err != nil { + return 0, err + } + return cur + count, nil +} + +func (a pgPtsAllocator) CurrentPts(ctx context.Context, userID int64) (int, error) { + return a.events.Current(ctx, userID) +} + +type messageEntityJSON struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` +} + +func encodeMessageEntities(entities []domain.MessageEntity) ([]byte, error) { + if len(entities) == 0 { + return []byte("[]"), nil + } + wire := make([]messageEntityJSON, 0, len(entities)) + for _, entity := range entities { + wire = append(wire, messageEntityJSON{ + Type: string(entity.Type), + Offset: entity.Offset, + Length: entity.Length, + }) + } + raw, err := json.Marshal(wire) + if err != nil { + return nil, fmt.Errorf("marshal message entities: %w", err) + } + return raw, nil +} + +func decodeMessageEntities(raw string) ([]domain.MessageEntity, error) { + if raw == "" { + return nil, nil + } + var wire []messageEntityJSON + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return nil, err + } + out := make([]domain.MessageEntity, 0, len(wire)) + for _, entity := range wire { + out = append(out, domain.MessageEntity{ + Type: domain.MessageEntityType(entity.Type), + Offset: entity.Offset, + Length: entity.Length, + }) + } + return out, nil +} + +func sameMessageEntities(a, b []domain.MessageEntity) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func messageListHash(messages []domain.Message) int64 { + if len(messages) == 0 { + return 0 + } + h := fnv.New64a() + var buf [24]byte + for _, msg := range messages { + binary.LittleEndian.PutUint32(buf[:4], uint32(msg.ID)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(msg.Date)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.From.ID)) + binary.LittleEndian.PutUint64(buf[16:24], uint64(msg.UID)) + _, _ = h.Write(buf[:]) + writeMessageReactionsHash(h, msg.Reactions) + } + return int64(h.Sum64()) +} + +func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageReactions) { + if reactions == nil { + _, _ = h.Write([]byte{0}) + return + } + var buf [16]byte + for _, item := range reactions.Results { + _, _ = h.Write([]byte(item.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(item.Reaction.Emoticon)) + _, _ = h.Write([]byte{0}) + binary.LittleEndian.PutUint32(buf[:4], uint32(item.Count)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(item.ChosenOrder)) + _, _ = h.Write(buf[:8]) + } + _, _ = h.Write([]byte{0xfe}) + for _, item := range reactions.Recent { + _, _ = h.Write([]byte(item.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(item.Reaction.Emoticon)) + _, _ = h.Write([]byte{0}) + binary.LittleEndian.PutUint64(buf[:8], uint64(item.UserID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(item.Date)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(item.ChosenOrder)) + _, _ = h.Write(buf[:]) + } +} diff --git a/internal/store/postgres/message_concurrent_integration_test.go b/internal/store/postgres/message_concurrent_integration_test.go new file mode 100644 index 00000000..26862339 --- /dev/null +++ b/internal/store/postgres/message_concurrent_integration_test.go @@ -0,0 +1,114 @@ +package postgres + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSendPrivateTextConcurrentNoPtsGap 是「Redis 分配移出事务」的正确性核心证明: +// N 条消息并发 sender→recipient 发送,全部成功提交后,接收方账号事件 pts 必须严格连续 1..N +// (无空洞、无重复、无丢失),且 MaxContiguousPts == N。 +// 用 perUserCounterAllocator(mutex 单调自增,忠实复刻 Redis INCR 原子性)驱动分配器, +// 验证事务外分配 + 连续 pts 兜底在高并发下不丢消息,不引入 Redis 依赖。 +func TestSendPrivateTextConcurrentNoPtsGap(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 51, + Phone: "+1999" + suffix + "01", + FirstName: "ConcSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1999" + suffix + "02", + FirstName: "ConcRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + // 按 FK 依赖序清理子表(message_boxes.from_user_id 为 RESTRICT)再删用户。 + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{})) + + const n = 200 + base := time.Now().UnixNano() + date := int(time.Now().Unix()) + errs := make([]error, n) + recipPts := make([]int, n) + sem := make(chan struct{}, 32) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + sem <- struct{}{} + go func(i int) { + defer wg.Done() + defer func() { <-sem }() + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: base + int64(i), + Message: "concurrent body", + Date: date, + }) + errs[i] = err + recipPts[i] = res.RecipientMessage.Pts + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("send %d: %v", i, err) + } + } + + // 进程内检测 allocator 是否给接收方分配了重复 pts。 + seen := map[int]int{} + for i := 0; i < n; i++ { + if prev, ok := seen[recipPts[i]]; ok { + t.Errorf("recipient pts %d 被发送 #%d 和 #%d 同时分配(allocator 并发重复)", recipPts[i], prev, i) + } + seen[recipPts[i]] = i + } + + // 接收方应有 n 条事件,pts 连续 1..n(无空洞无重复无丢失)。 + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, n+10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + if len(got) != n { + t.Fatalf("recipient events = %d, want %d(无丢失无重复)", len(got), n) + } + for i, ev := range got { + if ev.Pts != i+1 { + t.Fatalf("recipient event[%d].Pts = %d, want %d(pts 必须连续无洞)", i, ev.Pts, i+1) + } + } + contig, err := events.MaxContiguousPts(ctx, recipient.ID) + if err != nil { + t.Fatalf("MaxContiguousPts: %v", err) + } + if contig != n { + t.Fatalf("MaxContiguousPts(recipient) = %d, want %d", contig, n) + } +} diff --git a/internal/store/postgres/message_deadlock_integration_test.go b/internal/store/postgres/message_deadlock_integration_test.go new file mode 100644 index 00000000..7ea30176 --- /dev/null +++ b/internal/store/postgres/message_deadlock_integration_test.go @@ -0,0 +1,119 @@ +package postgres + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestMessageStoreBidirectionalConcurrencyNoDeadlock 验证 watermark/dialog 死锁修复(advisory lock)。 +// +// 背景:send/read/edit/delete 在一个事务内会按业务顺序锁住收发双方的 user_update_watermarks 与 +// channel/private dialog 行。A→B 与 B→A 反向并发时,两个事务以相反顺序竞争同一对用户的这些行 +// (watermark[A]→watermark[B] vs watermark[B]→watermark[A],dialog(A,B)→dialog(B,A) vs 反向), +// 形成 AB-BA 死锁——PostgreSQL 会检测并 abort 其中一个事务(SQLSTATE 40P01),表现为操作返回错误。 +// +// 修复:每个写事务在任何行锁之前,用事务级 advisory lock 按 user_id 升序锁住涉及的用户 +// (lockUsersForUpdate),把同一对用户的并发写事务串行化。advisory 与行锁处于独立锁空间且升序获取, +// 既不与行锁交叉成新死锁,也消除了 watermark 与 dialog 的 AB-BA。本测试在高并发反向负载下应 +// 全部成功、零错误;若死锁回归,会以 40P01 错误形式被捕获。 +func TestMessageStoreBidirectionalConcurrencyNoDeadlock(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + a, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1997" + suffix + "01", FirstName: "BidiA"}) + if err != nil { + t.Fatalf("create a: %v", err) + } + b, err := users.Create(ctx, domain.User{AccessHash: 72, Phone: "+1997" + suffix + "02", FirstName: "BidiB"}) + if err != nil { + t.Fatalf("create b: %v", err) + } + ids := []int64{a.ID, b.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_watermarks WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{})) + date := int(time.Now().Unix()) + + var ridMu sync.Mutex + rid := time.Now().UnixNano() + nextRID := func() int64 { + ridMu.Lock() + defer ridMu.Unlock() + rid++ + return rid + } + + // 预热:双向各发若干条,建立双向 dialog 与未读历史,使后续 read 真正推进 watermark(命中行锁)。 + for i := 0; i < 4; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: nextRID(), Message: "warmup a->b", Date: date}); err != nil { + t.Fatalf("warmup a->b: %v", err) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: nextRID(), Message: "warmup b->a", Date: date}); err != nil { + t.Fatalf("warmup b->a: %v", err) + } + } + + // 反向并发:每轮同时发起 A→B send、B→A send、A 读 B、B 读 A,goroutine 一起抢同一对用户的行锁。 + const rounds = 80 + var wg sync.WaitGroup + errCh := make(chan error, rounds*4) + sem := make(chan struct{}, 24) + + submit := func(op func() error) { + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + if e := op(); e != nil { + errCh <- e + } + }() + } + + for r := 0; r < rounds; r++ { + submit(func() error { + _, e := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: nextRID(), Message: "a->b", Date: date}) + return e + }) + submit(func() error { + _, e := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: nextRID(), Message: "b->a", Date: date}) + return e + }) + submit(func() error { + _, e := messages.ReadHistory(ctx, domain.ReadHistoryRequest{OwnerUserID: a.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: b.ID}, MaxID: domain.MaxMessageBoxID, Date: date}) + return e + }) + submit(func() error { + _, e := messages.ReadHistory(ctx, domain.ReadHistoryRequest{OwnerUserID: b.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: a.ID}, MaxID: domain.MaxMessageBoxID, Date: date}) + return e + }) + } + wg.Wait() + close(errCh) + + failed := 0 + for e := range errCh { + failed++ + if failed <= 5 { + t.Errorf("反向并发操作失败(疑似死锁回归): %v", e) + } + } + if failed > 0 { + t.Fatalf("%d/%d 反向并发操作失败", failed, rounds*4) + } +} diff --git a/internal/store/postgres/message_integration_test.go b/internal/store/postgres/message_integration_test.go new file mode 100644 index 00000000..42391258 --- /dev/null +++ b/internal/store/postgres/message_integration_test.go @@ -0,0 +1,813 @@ +package postgres + +import ( + "context" + "sync" + "testing" + + "telesrv/internal/domain" +) + +func TestMessageStoreSendPrivateTextRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 11, + Phone: "+1666" + suffix + "01", + FirstName: "Sender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 22, + Phone: "+1666" + suffix + "02", + FirstName: "Recipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + var originAuthKeyID [8]byte + originAuthKeyID[0] = 5 + req := domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 123456, + Message: "hello from pg", + Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 5}}, + Date: 1700000200, + OriginAuthKeyID: originAuthKeyID, + OriginSessionID: 77, + } + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if got.SenderMessage.ID != 1 || got.SenderMessage.Pts != 1 || !got.SenderMessage.Out || got.SenderMessage.Peer.ID != recipient.ID { + t.Fatalf("sender message = %+v, want first outgoing box to recipient", got.SenderMessage) + } + if got.RecipientMessage.ID != 1 || got.RecipientMessage.Pts != 1 || got.RecipientMessage.Out || got.RecipientMessage.Peer.ID != sender.ID { + t.Fatalf("recipient message = %+v, want first incoming box from sender", got.RecipientMessage) + } + if got.SenderMessage.UID == 0 || got.SenderMessage.UID != got.RecipientMessage.UID { + t.Fatalf("uid = sender %d recipient %d, want shared private message uid", got.SenderMessage.UID, got.RecipientMessage.UID) + } + + senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history: %v", err) + } + recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(senderHistory.Messages) != 1 || len(recipientHistory.Messages) != 1 { + t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages)) + } + + events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + if len(events) != 1 || events[0].Message.ID != got.RecipientMessage.ID || len(events[0].Users) != 1 || events[0].Users[0].ID != sender.ID { + t.Fatalf("recipient events = %+v, want new message with sender user", events) + } + + var pendingOutbox int + if err := pool.QueryRow(ctx, ` + SELECT count(*) + FROM dispatch_outbox + WHERE target_user_id = ANY($1::bigint[]) + AND status = 'pending' + `, []int64{sender.ID, recipient.ID}).Scan(&pendingOutbox); err != nil { + t.Fatalf("count dispatch outbox: %v", err) + } + if pendingOutbox != 2 { + t.Fatalf("pending outbox = %d, want sender + recipient dispatch rows", pendingOutbox) + } + var excludeAuthKeyID, excludeSessionID int64 + if err := pool.QueryRow(ctx, ` + SELECT exclude_auth_key_id, exclude_session_id + FROM dispatch_outbox + WHERE target_user_id = $1 + `, sender.ID).Scan(&excludeAuthKeyID, &excludeSessionID); err != nil { + t.Fatalf("sender dispatch outbox: %v", err) + } + if excludeAuthKeyID != authKeyIDToInt64(originAuthKeyID) || excludeSessionID != 77 { + t.Fatalf("sender dispatch exclude = auth %d session %d, want origin auth/session", excludeAuthKeyID, excludeSessionID) + } + + dup, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText duplicate: %v", err) + } + if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID { + t.Fatalf("duplicate = %+v, want original message boxes", dup) + } +} + +func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{ + AccessHash: 91, + Phone: "+1667" + suffix + "01", + FirstName: "Alice", + }) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{ + AccessHash: 92, + Phone: "+1667" + suffix + "02", + FirstName: "Bob", + }) + if err != nil { + t.Fatalf("create bob: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + }) + + messages := NewMessageStore(pool) + for i := 1; i <= 6; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: bob.ID, + RandomID: int64(700 + i), + Message: "history", + Date: 1700000000 + i, + }); err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID} + + around, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -3, + Limit: 6, + NeedTotalCount: true, + }) + if err != nil { + t.Fatalf("around history: %v", err) + } + if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) { + t.Fatalf("around ids = %v, want unread/newer side plus older context", got) + } + if around.Count != 6 { + t.Fatalf("around count = %d, want full dialog count", around.Count) + } + + forward, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -3, + Limit: 3, + NeedTotalCount: true, + }) + if err != nil { + t.Fatalf("forward history: %v", err) + } + if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) { + t.Fatalf("forward ids = %v, want messages newer than offset", got) + } + if forward.Count != 6 { + t.Fatalf("forward count = %d, want full dialog count", forward.Count) + } +} + +func TestMessageStoreReadAndEditEmitDurableEvents(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 31, + Phone: "+1666" + suffix + "11", + FirstName: "ReadSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 32, + Phone: "+1666" + suffix + "12", + FirstName: "ReadRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 223344, + Message: "before edit", + Date: 1700000300, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + Date: 1700000310, + }) + if err != nil { + t.Fatalf("ReadHistory: %v", err) + } + if !read.Changed || read.InboxEvent.Pts != 2 || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.MaxID != sent.RecipientMessage.ID { + t.Fatalf("read inbox = %+v, want recipient pts=2 max recipient id", read) + } + if !read.OutboxChanged || read.OutboxEvent.Pts != 2 || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID { + t.Fatalf("read outbox = %+v, want sender pts=2 max sender id", read) + } + readDate, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{ + OwnerUserID: sender.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, + ID: sent.SenderMessage.ID, + }) + if err != nil || readDate != 1700000310 { + t.Fatalf("outbox read date = %d err=%v, want read date", readDate, err) + } + + edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: sender.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, + ID: sent.SenderMessage.ID, + Message: "after edit", + EditDate: 1700000320, + }) + if err != nil { + t.Fatalf("EditMessage: %v", err) + } + if self := edited.Self(); self.Event.Pts != 3 || self.Event.Type != domain.UpdateEventEditMessage || self.Message.Body != "after edit" { + t.Fatalf("self edit = %+v, want sender edit event pts=3", self) + } + recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after edit" || recipientHistory.Messages[0].EditDate != 1700000320 { + t.Fatalf("recipient history = %+v, want edited message visible", recipientHistory.Messages) + } + + senderEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 0, 10) + if err != nil { + t.Fatalf("sender events: %v", err) + } + if len(senderEvents) != 3 || senderEvents[1].Type != domain.UpdateEventReadHistoryOutbox || senderEvents[2].Type != domain.UpdateEventEditMessage { + t.Fatalf("sender events = %+v, want new/read_outbox/edit", senderEvents) + } + recipientEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("recipient events: %v", err) + } + if len(recipientEvents) != 3 || recipientEvents[1].Type != domain.UpdateEventReadHistoryInbox || recipientEvents[2].Type != domain.UpdateEventEditMessage || recipientEvents[2].Message.Body != "after edit" { + t.Fatalf("recipient events = %+v, want new/read_inbox/edit with edited body", recipientEvents) + } +} + +func TestMessageStoreSendPrivateTextRollbackRecordsPtsNoop(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 31, + Phone: "+1777" + suffix + "01", + FirstName: "GapSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 32, + Phone: "+1777" + suffix + "02", + FirstName: "GapRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 223344, + Message: "seed box", + Date: 1700000210, + }); err != nil { + t.Fatalf("seed SendPrivateText: %v", err) + } + + failing := NewMessageStore(pool, WithMessageAllocators(fixedBoxIDAllocator{next: 1}, fixedPtsAllocator{next: 42})) + _, err = failing.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 223345, + Message: "should roll back", + Date: 1700000211, + }) + if err == nil { + t.Fatal("SendPrivateText succeeded, want box id conflict") + } + + events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 1, 10) + if err != nil { + t.Fatalf("list sender events: %v", err) + } + for _, event := range events { + if event.Pts == 42 && event.Type == domain.UpdateEventNoop { + return + } + } + t.Fatalf("events = %+v, want noop gap at pts=42", events) +} + +func TestMessageStoreConcurrentRandomIDIdempotent(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1888" + suffix + "01", + FirstName: "ConcurrentSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1888" + suffix + "02", + FirstName: "ConcurrentRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + boxCounters := &perUserCounterAllocator{} + ptsCounters := &perUserCounterAllocator{} + messages := NewMessageStore(pool, WithMessageAllocators(boxCounters, ptsCounters)) + req := domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 556677, + Message: "same random id", + Date: 1700000220, + } + + const workers = 8 + results := make(chan domain.SendPrivateTextResult, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + res, err := messages.SendPrivateText(ctx, req) + if err != nil { + errs <- err + return + } + results <- res + }() + } + wg.Wait() + close(results) + close(errs) + + for err := range errs { + t.Fatalf("SendPrivateText: %v", err) + } + var uid int64 + duplicates := 0 + successes := 0 + for res := range results { + if res.SenderMessage.UID == 0 || res.RecipientMessage.UID == 0 { + t.Fatalf("result = %+v, want populated shared message uid", res) + } + if uid == 0 { + uid = res.SenderMessage.UID + } + if res.SenderMessage.UID != uid || res.RecipientMessage.UID != uid { + t.Fatalf("result = %+v, want same private message uid %d", res, uid) + } + if res.Duplicate { + duplicates++ + } else { + successes++ + } + } + if successes != 1 || duplicates != workers-1 { + t.Fatalf("successes=%d duplicates=%d, want one insert and duplicate rest", successes, duplicates) + } + + var privateCount int + if err := pool.QueryRow(ctx, ` + SELECT count(*) + FROM private_messages + WHERE sender_user_id = $1 + AND random_id = $2 + `, sender.ID, req.RandomID).Scan(&privateCount); err != nil { + t.Fatalf("count private_messages: %v", err) + } + if privateCount != 1 { + t.Fatalf("private message count = %d, want 1", privateCount) + } + var boxCount int + if err := pool.QueryRow(ctx, ` + SELECT count(*) + FROM message_boxes + WHERE private_message_id = $1 + `, uid).Scan(&boxCount); err != nil { + t.Fatalf("count message boxes: %v", err) + } + if boxCount != 2 { + t.Fatalf("message box count = %d, want sender + recipient boxes", boxCount) + } +} + +func TestMessageStoreDeleteHistoryRebuildsDialogAndEmitsDeleteUpdates(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1991"+suffix+"01", "DeleteSender", "") + recipient := createTestUser(t, ctx, users, "+1991"+suffix+"02", "DeleteRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + for i := 0; i < 2; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: int64(7000 + i), + Message: "history", + Date: 1700000700 + i, + }); err != nil { + t.Fatalf("seed send %d: %v", i, err) + } + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} + deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: sender.ID, + Peer: peer, + Date: 1700000800, + }) + if err != nil { + t.Fatalf("DeleteHistory: %v", err) + } + if self := deleted.Self(); self.Event.Pts != 4 || self.Event.PtsCount != 2 || len(self.MessageIDs) != 2 { + t.Fatalf("delete result = %+v, want sender delete range pts=4 count=2 ids", self) + } + senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history: %v", err) + } + recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 { + t.Fatalf("history sizes sender=%d recipient=%d, want sender cleared only", len(senderHistory.Messages), len(recipientHistory.Messages)) + } + senderDialogs, err := NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("sender dialogs after delete: %v", err) + } + if len(senderDialogs.Dialogs) != 0 { + t.Fatalf("sender dialogs = %+v, want empty after full history delete", senderDialogs.Dialogs) + } + events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 2, 10) + if err != nil { + t.Fatalf("list sender events: %v", err) + } + if len(events) != 1 || events[0].Type != domain.UpdateEventDeleteMessages || events[0].Pts != 4 || events[0].PtsCount != 2 || len(events[0].MessageIDs) != 2 { + t.Fatalf("events = %+v, want delete messages event pts=4 pts_count=2", events) + } + + rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 8000, + Message: "after clear", + Date: 1700000900, + }) + if err != nil { + t.Fatalf("send after delete: %v", err) + } + senderDialogs, err = NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("sender dialogs after rebuild: %v", err) + } + if len(senderDialogs.Dialogs) != 1 || senderDialogs.Dialogs[0].Peer != peer || senderDialogs.Dialogs[0].TopMessage != rebuilt.SenderMessage.ID { + t.Fatalf("rebuilt dialogs = %+v, want new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID) + } + + revoked, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: sender.ID, + IDs: []int{rebuilt.SenderMessage.ID}, + Revoke: true, + Date: 1700001000, + }) + if err != nil { + t.Fatalf("DeleteMessages revoke: %v", err) + } + if len(revoked.Deleted) != 2 || !revoked.Changed() { + t.Fatalf("revoked = %+v, want delete events for both owners", revoked) + } + senderHistory, err = messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history after revoke: %v", err) + } + recipientHistory, err = messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10}) + if err != nil { + t.Fatalf("recipient history after revoke: %v", err) + } + if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 { + t.Fatalf("history sizes after revoke sender=%d recipient=%d, want new message removed from both owners", len(senderHistory.Messages), len(recipientHistory.Messages)) + } +} + +func TestMessageStoreDeleteHistoryJustClearPreservesEmptyDialog(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1992"+suffix+"01", "ClearOwner", "") + peerUser := createTestUser(t, ctx, users, "+1992"+suffix+"02", "ClearPeer", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID}) + }) + + messages := NewMessageStore(pool) + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: owner.ID, + RecipientUserID: peerUser.ID, + RandomID: 9000, + Message: "clear but keep dialog", + Date: 1700001100, + }); err != nil { + t.Fatalf("seed send: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID} + if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: owner.ID, + Peer: peer, + JustClear: true, + Date: 1700001200, + }); err != nil { + t.Fatalf("DeleteHistory just_clear: %v", err) + } + dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("dialogs after just_clear: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer != peer || dialogs.Dialogs[0].TopMessage != 0 || len(dialogs.Messages) != 0 { + t.Fatalf("dialogs = %+v messages=%+v, want empty dialog preserved after just_clear", dialogs.Dialogs, dialogs.Messages) + } + history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true}) + if err != nil { + t.Fatalf("history after just_clear: %v", err) + } + if len(history.Messages) != 0 { + t.Fatalf("history = %+v, want cleared", history.Messages) + } +} + +func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1993"+suffix+"01", "BulkOwner", "") + peerUser := createTestUser(t, ctx, users, "+1993"+suffix+"02", "BulkPeer", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID}) + }) + + total := domain.MaxDeleteHistoryBatch + 2 + if _, err := pool.Exec(ctx, ` + WITH src AS ( + SELECT generate_series(1, $3::int) AS g + ), + pm AS ( + INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities + ) + SELECT + $1::bigint, + $2::bigint, + 910000000 + g, + 1700002000 + g, + 'bulk history', + '[]'::jsonb + FROM src + RETURNING id, random_id, message_date + ) + INSERT INTO message_boxes ( + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + outgoing, + body, + entities, + pts + ) + SELECT + $1::bigint, + (random_id - 910000000)::int, + id, + $1::bigint, + 'user', + $2::bigint, + $1::bigint, + message_date, + true, + 'bulk history', + '[]'::jsonb, + 0 + FROM pm + `, owner.ID, peerUser.ID, total); err != nil { + t.Fatalf("seed bulk history: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + read_outbox_max_id, + unread_count + ) VALUES ($1, 'user', $2, $3, $4, $3, 0) + `, owner.ID, peerUser.ID, total, 1700002000+total); err != nil { + t.Fatalf("seed dialog: %v", err) + } + + messages := NewMessageStore(pool) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID} + first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: owner.ID, + Peer: peer, + MaxID: domain.MaxMessageBoxID, + Date: 1700003000, + }) + if err != nil { + t.Fatalf("DeleteHistory first batch: %v", err) + } + self := first.Self() + if first.Offset != 1 || self.Event.Pts != domain.MaxDeleteHistoryBatch || self.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(self.MessageIDs) != domain.MaxDeleteHistoryBatch { + t.Fatalf("first batch = %+v self=%+v, want offset=1 and exactly %d deleted ids", first, self, domain.MaxDeleteHistoryBatch) + } + history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true}) + if err != nil { + t.Fatalf("history after first batch: %v", err) + } + if history.Count != 2 || len(history.Messages) != 2 || history.Messages[0].ID != 2 { + t.Fatalf("history after first batch = %+v, want only two oldest messages left", history) + } + + second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: owner.ID, + Peer: peer, + MaxID: domain.MaxMessageBoxID, + Date: 1700003001, + }) + if err != nil { + t.Fatalf("DeleteHistory second batch: %v", err) + } + if second.Offset != 0 || second.Self().Event.PtsCount != 2 { + t.Fatalf("second batch = %+v, want final offset=0 pts_count=2", second) + } +} + +type fixedBoxIDAllocator struct { + next int +} + +func (a fixedBoxIDAllocator) NextBoxID(context.Context, int64) (int, error) { + return a.next, nil +} + +func (a fixedBoxIDAllocator) CurrentBoxID(context.Context, int64) (int, error) { + return a.next, nil +} + +type fixedPtsAllocator struct { + next int +} + +func (a fixedPtsAllocator) NextPts(context.Context, int64) (int, error) { + return a.next, nil +} + +func (a fixedPtsAllocator) CurrentPts(context.Context, int64) (int, error) { + return a.next, nil +} + +type perUserCounterAllocator struct { + mu sync.Mutex + values map[int64]int +} + +func (a *perUserCounterAllocator) NextBoxID(_ context.Context, userID int64) (int, error) { + return a.next(userID), nil +} + +func (a *perUserCounterAllocator) CurrentBoxID(_ context.Context, userID int64) (int, error) { + return a.current(userID), nil +} + +func (a *perUserCounterAllocator) NextPts(_ context.Context, userID int64) (int, error) { + return a.next(userID), nil +} + +func (a *perUserCounterAllocator) CurrentPts(_ context.Context, userID int64) (int, error) { + return a.current(userID), nil +} + +func (a *perUserCounterAllocator) next(userID int64) int { + a.mu.Lock() + defer a.mu.Unlock() + if a.values == nil { + a.values = map[int64]int{} + } + a.values[userID]++ + return a.values[userID] +} + +func (a *perUserCounterAllocator) current(userID int64) int { + a.mu.Lock() + defer a.mu.Unlock() + return a.values[userID] +} + +func messageIDs(messages []domain.Message) []int { + out := make([]int, 0, len(messages)) + for _, msg := range messages { + out = append(out, msg.ID) + } + return out +} + +func sameInts(got, want []int) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/internal/store/postgres/message_media_integration_test.go b/internal/store/postgres/message_media_integration_test.go new file mode 100644 index 00000000..e377c9ff --- /dev/null +++ b/internal/store/postgres/message_media_integration_test.go @@ -0,0 +1,181 @@ +package postgres + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSendPrivateMediaSurvivesUpdateEvent 验证带 media 的私聊消息: +// - 发送后 message_boxes 持久化 media 快照; +// - 接收方经 UpdateEventStore.ListAfter(在线 outbox / 离线 getDifference 共用的重建路径) +// 能拿回 media(曾因 update event 查询漏选 m.media 导致收件人/离线丢媒体,本测试守护该修复); +// - history(ListByUser)读取也带 media。 +func TestSendPrivateMediaSurvivesUpdateEvent(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1998" + suffix + "01", FirstName: "MediaSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1998" + suffix + "02", FirstName: "MediaRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{})) + + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindDocument, + Document: &domain.Document{ + ID: 9200000000000000001, + AccessHash: 9, + DCID: 2, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: 5, StickerSetAccessHash: 7}}, + }, + } + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano(), + Message: "", // 仅媒体(无 caption) + Media: media, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send private media: %v", err) + } + + // 发送结果双端均带 media。 + for name, msg := range map[string]domain.Message{"sender": res.SenderMessage, "recipient": res.RecipientMessage} { + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindDocument || msg.Media.Document == nil || msg.Media.Document.ID != media.Document.ID { + t.Fatalf("%s message media lost: %+v", name, msg.Media) + } + } + + // 关键:接收方经更新事件重建(在线推送 / 离线 difference 共用路径)仍带 media。 + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + var found bool + for _, ev := range got { + if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID != 0 { + found = true + if ev.Message.Media == nil || ev.Message.Media.Document == nil || ev.Message.Media.Document.ID != media.Document.ID { + t.Fatalf("recipient update-event message lost media: %+v", ev.Message.Media) + } + } + } + if !found { + t.Fatal("no new_message event for recipient") + } + + // history 读取也带 media。 + list, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10}) + if err != nil { + t.Fatalf("list by user: %v", err) + } + if len(list.Messages) == 0 || list.Messages[0].Media == nil || list.Messages[0].Media.Document == nil { + t.Fatalf("history message lost media: %+v", list.Messages) + } +} + +func TestSendChannelMediaSurvivesDifference(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 63, Phone: "+1998" + suffix + "03", FirstName: "MediaChannelOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 64, Phone: "+1998" + suffix + "04", FirstName: "MediaChannelMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Media Difference " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindDocument, + Document: &domain.Document{ + ID: 9200000000000000002, + AccessHash: 10, + DCID: 2, + MimeType: "application/octet-stream", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "telesrv-media.bin"}}, + }, + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: time.Now().UnixNano(), + Message: "", + Media: media, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send channel media: %v", err) + } + if sent.Message.Media == nil || sent.Message.Media.Document == nil || sent.Message.Media.Document.ID != media.Document.ID { + t.Fatalf("send result lost media: %+v", sent.Message.Media) + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: member.ID, + ChannelID: channelID, + Pts: created.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + var found bool + for _, msg := range diff.NewMessages { + if msg.ID == sent.Message.ID { + found = true + if msg.Media == nil || msg.Media.Document == nil || msg.Media.Document.ID != media.Document.ID { + t.Fatalf("channel difference message lost media: %+v", msg.Media) + } + } + } + if !found { + t.Fatalf("sent media message %d not found in channel difference: %+v", sent.Message.ID, diff.NewMessages) + } +} diff --git a/internal/store/postgres/message_plan_integration_test.go b/internal/store/postgres/message_plan_integration_test.go new file mode 100644 index 00000000..1363bfd6 --- /dev/null +++ b/internal/store/postgres/message_plan_integration_test.go @@ -0,0 +1,160 @@ +package postgres + +import ( + "context" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func TestMessagePartitionSeekPlansUseIndexes(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 51, + Phone: "+1999" + suffix + "01", + FirstName: "PlanSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1999" + suffix + "02", + FirstName: "PlanRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + for i := 0; i < 3; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: int64(9000 + i), + Message: "plan check", + Date: 1700000300 + i, + }); err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + } + if _, err := pool.Exec(ctx, ` + UPDATE dispatch_outbox + SET status = 'dispatching', + updated_at = now() - interval '1 minute' + WHERE target_user_id = $1 + `, recipient.ID); err != nil { + t.Fatalf("mark dispatch stale: %v", err) + } + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin explain tx: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil { + t.Fatalf("disable seqscan: %v", err) + } + + historyPlan := explainText(t, ctx, tx, ` +SELECT box_id +FROM message_boxes +WHERE owner_user_id = $1 + AND peer_type = 'user' + AND peer_id = $2 + AND NOT deleted + AND box_id < $3 +ORDER BY box_id DESC +LIMIT 20 +`, recipient.ID, sender.ID, 100000) + requirePlanUsesPartitionIndex(t, historyPlan, "message_boxes") + requirePlanNotContains(t, historyPlan, "Append") + + updatesPlan := explainText(t, ctx, tx, ` +SELECT pts +FROM user_update_events +WHERE user_id = $1 + AND pts > $2 +ORDER BY pts ASC +LIMIT 100 +`, recipient.ID, 0) + requirePlanUsesPartitionIndex(t, updatesPlan, "user_update_events") + requirePlanNotContains(t, updatesPlan, "Append") + + dispatchPlan := explainText(t, ctx, tx, ` +WITH picked AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE ( + status = 'pending' + AND next_attempt_at <= now() + ) + OR ( + status = 'dispatching' + AND updated_at < now() - interval '30 seconds' + ) + ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC + LIMIT 100 + FOR UPDATE SKIP LOCKED +) +SELECT target_user_id, id +FROM picked +`) + requirePlanContains(t, dispatchPlan, "dispatch_outbox_p") + requirePlanContains(t, dispatchPlan, "Index") + requirePlanNotContains(t, dispatchPlan, "Seq Scan") +} + +func explainText(t *testing.T, ctx context.Context, tx pgx.Tx, query string, args ...any) string { + t.Helper() + rows, err := tx.Query(ctx, "EXPLAIN (COSTS OFF) "+query, args...) + if err != nil { + t.Fatalf("explain query: %v", err) + } + defer rows.Close() + + var b strings.Builder + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + t.Fatalf("scan explain row: %v", err) + } + b.WriteString(line) + b.WriteByte('\n') + } + if err := rows.Err(); err != nil { + t.Fatalf("read explain rows: %v", err) + } + return b.String() +} + +func requirePlanUsesPartitionIndex(t *testing.T, plan, table string) { + t.Helper() + requirePlanContains(t, plan, table+"_p") + requirePlanContains(t, plan, "Index") + requirePlanNotContains(t, plan, "Seq Scan") +} + +func requirePlanContains(t *testing.T, plan string, needle string) { + t.Helper() + if !strings.Contains(plan, needle) { + t.Fatalf("plan missing %q:\n%s", needle, plan) + } +} + +func requirePlanNotContains(t *testing.T, plan string, needle string) { + t.Helper() + if strings.Contains(plan, needle) { + t.Fatalf("plan contains %q:\n%s", needle, plan) + } +} diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go new file mode 100644 index 00000000..f258889d --- /dev/null +++ b/internal/store/postgres/postgres.go @@ -0,0 +1,128 @@ +// Package postgres 用 PostgreSQL 实现持久化存储接口(第一阶段:AuthKeyStore)。 +// +// 查询代码由 sqlc 生成于 ./sqlcgen(见 telesrv/sqlc.yaml);本包在其上实现 store 接口。 +package postgres + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" // 注册 pgx5:// migrate driver + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/deploy" +) + +const defaultMinConns = 16 + +// PoolOption 调整 pgxpool 连接池配置。 +type PoolOption func(*pgxpool.Config) + +// WithMaxConns 设置连接池最大连接数;<=0 时保持 pgx 默认。 +// 同时把 MinConns 预热到 min(maxConns, 16),降低 TDesktop 启动风暴下的冷连接尾延迟突刺。 +func WithMaxConns(n int) PoolOption { + return func(cfg *pgxpool.Config) { + if n <= 0 { + return + } + cfg.MaxConns = int32(n) + minConns := int32(defaultMinConns) + if int32(n) < minConns { + minConns = int32(n) + } + cfg.MinConns = minConns + } +} + +// WithMinConns 设置启动时预热的最小连接数;<=0 保持既有配置。 +func WithMinConns(n int) PoolOption { + return func(cfg *pgxpool.Config) { + if n <= 0 { + return + } + minConns := int32(n) + if cfg.MaxConns > 0 && minConns > cfg.MaxConns { + minConns = cfg.MaxConns + } + cfg.MinConns = minConns + } +} + +// Open 建立 pgxpool 连接池并 ping 验证。 +func Open(ctx context.Context, dsn string, opts ...PoolOption) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("pgxpool parse config: %w", err) + } + for _, opt := range opts { + if opt != nil { + opt(cfg) + } + } + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("pgxpool new: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pg ping: %w", err) + } + if err := warmMinConns(ctx, pool); err != nil { + pool.Close() + return nil, err + } + return pool, nil +} + +func warmMinConns(ctx context.Context, pool *pgxpool.Pool) error { + target := pool.Config().MinConns + if target <= 0 { + return nil + } + conns := make([]*pgxpool.Conn, 0, target) + defer func() { + for _, conn := range conns { + conn.Release() + } + }() + for int32(len(conns)) < target { + conn, err := pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("prewarm pg connection %d/%d: %w", len(conns)+1, target, err) + } + conns = append(conns, conn) + } + return nil +} + +// Migrate 用嵌入的迁移脚本将数据库迁移到最新版本。幂等:已最新时返回 nil。 +func Migrate(dsn string) error { + src, err := iofs.New(deploy.Migrations, "migrations") + if err != nil { + return fmt.Errorf("iofs source: %w", err) + } + m, err := migrate.NewWithSourceInstance("iofs", src, toPgx5DSN(dsn)) + if err != nil { + return fmt.Errorf("migrate new: %w", err) + } + defer m.Close() + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate up: %w", err) + } + return nil +} + +// toPgx5DSN 把 pgxpool 用的 postgres:// DSN 转成 golang-migrate pgx5 driver 所需的 pgx5:// scheme。 +func toPgx5DSN(dsn string) string { + if s, ok := strings.CutPrefix(dsn, "postgres://"); ok { + return "pgx5://" + s + } + if s, ok := strings.CutPrefix(dsn, "postgresql://"); ok { + return "pgx5://" + s + } + return dsn +} diff --git a/internal/store/postgres/queries/account.sql b/internal/store/postgres/queries/account.sql new file mode 100644 index 00000000..6cb71f26 --- /dev/null +++ b/internal/store/postgres/queries/account.sql @@ -0,0 +1,22 @@ +-- name: GetPasswordByUser :one +SELECT + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random +FROM account_passwords +WHERE user_id = $1; + +-- name: UpsertPassword :exec +INSERT INTO account_passwords ( + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (user_id) DO UPDATE SET + has_recovery = EXCLUDED.has_recovery, + has_secure_values = EXCLUDED.has_secure_values, + has_password = EXCLUDED.has_password, + hint = EXCLUDED.hint, + email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern, + login_email_pattern = EXCLUDED.login_email_pattern, + secure_random = EXCLUDED.secure_random, + updated_at = now(); diff --git a/internal/store/postgres/queries/authkey.sql b/internal/store/postgres/queries/authkey.sql new file mode 100644 index 00000000..2d8dbe4a --- /dev/null +++ b/internal/store/postgres/queries/authkey.sql @@ -0,0 +1,10 @@ +-- name: GetAuthKey :one +SELECT auth_key_id, body, server_salt, created_at +FROM auth_keys +WHERE auth_key_id = $1; + +-- name: UpsertAuthKey :exec +INSERT INTO auth_keys (auth_key_id, body, server_salt) +VALUES ($1, $2, $3) +ON CONFLICT (auth_key_id) DO UPDATE +SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt; diff --git a/internal/store/postgres/queries/authorization.sql b/internal/store/postgres/queries/authorization.sql new file mode 100644 index 00000000..1539b810 --- /dev/null +++ b/internal/store/postgres/queries/authorization.sql @@ -0,0 +1,24 @@ +-- name: UpsertAuthorization :exec +INSERT INTO authorizations (auth_key_id, user_id, layer, device_model, platform, system_version, api_id, app_version, ip) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (auth_key_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + layer = EXCLUDED.layer, + device_model = EXCLUDED.device_model, + platform = EXCLUDED.platform, + system_version = EXCLUDED.system_version, + api_id = EXCLUDED.api_id, + app_version = EXCLUDED.app_version, + ip = EXCLUDED.ip, + active_at = now(); + +-- name: GetAuthorizationByAuthKey :one +SELECT * FROM authorizations WHERE auth_key_id = $1; + +-- name: ListAuthorizationsByUser :many +SELECT * FROM authorizations +WHERE user_id = $1 +ORDER BY active_at DESC, auth_key_id DESC; + +-- name: DeleteAuthorization :exec +DELETE FROM authorizations WHERE auth_key_id = $1; diff --git a/internal/store/postgres/queries/contact.sql b/internal/store/postgres/queries/contact.sql new file mode 100644 index 00000000..137b4a51 --- /dev/null +++ b/internal/store/postgres/queries/contact.sql @@ -0,0 +1,168 @@ +-- name: ListContactsByUser :many +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 +ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u.id; + +-- name: GetContact :one +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 + AND c.contact_user_id = $2; + +-- name: UpsertContact :one +WITH reverse AS ( + SELECT EXISTS ( + SELECT 1 + FROM contacts + WHERE user_id = sqlc.arg(contact_user_id)::bigint + AND contact_user_id = sqlc.arg(user_id)::bigint + )::boolean AS mutual +), +upserted AS ( + INSERT INTO contacts ( + user_id, + contact_user_id, + contact_phone, + contact_first_name, + contact_last_name, + note, + note_entities, + mutual + ) + SELECT + sqlc.arg(user_id)::bigint, + sqlc.arg(contact_user_id)::bigint, + sqlc.arg(contact_phone)::text, + sqlc.arg(contact_first_name)::text, + sqlc.arg(contact_last_name)::text, + sqlc.arg(note)::text, + sqlc.arg(note_entities)::jsonb, + reverse.mutual + FROM reverse + ON CONFLICT (user_id, contact_user_id) DO UPDATE SET + contact_phone = EXCLUDED.contact_phone, + contact_first_name = EXCLUDED.contact_first_name, + contact_last_name = EXCLUDED.contact_last_name, + note = EXCLUDED.note, + note_entities = EXCLUDED.note_entities, + mutual = contacts.mutual OR EXCLUDED.mutual, + updated_at = now() + RETURNING * +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = true, + updated_at = now() + WHERE c.user_id = sqlc.arg(contact_user_id)::bigint + AND c.contact_user_id = sqlc.arg(user_id)::bigint + AND NOT c.mutual + RETURNING c.user_id +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed +FROM upserted c +JOIN users u ON u.id = c.contact_user_id; + +-- name: UpdateContactNote :one +WITH updated AS ( + UPDATE contacts c + SET note = sqlc.arg(note)::text, + note_entities = sqlc.arg(note_entities)::jsonb, + updated_at = now() + WHERE c.user_id = sqlc.arg(user_id)::bigint + AND c.contact_user_id = sqlc.arg(contact_user_id)::bigint + RETURNING * +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM updated c +JOIN users u ON u.id = c.contact_user_id; + +-- name: DeleteContacts :one +WITH deleted AS ( + DELETE FROM contacts + WHERE user_id = sqlc.arg(user_id)::bigint + AND contact_user_id = ANY(sqlc.arg(contact_user_ids)::bigint[]) + RETURNING contact_user_id +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = false, + updated_at = now() + FROM deleted d + WHERE c.user_id = d.contact_user_id + AND c.contact_user_id = sqlc.arg(user_id)::bigint + RETURNING c.user_id +) +SELECT COUNT(*)::int AS deleted_count +FROM deleted; diff --git a/internal/store/postgres/queries/dialog.sql b/internal/store/postgres/queries/dialog.sql new file mode 100644 index 00000000..ba7cf24e --- /dev/null +++ b/internal/store/postgres/queries/dialog.sql @@ -0,0 +1,773 @@ +-- name: ListDialogsByUser :many +WITH base AS ( + SELECT + d.user_id, + d.peer_type, + d.peer_id, + d.folder_id, + d.top_message_id, + d.top_message_date, + d.read_inbox_max_id, + d.read_outbox_max_id, + d.unread_count, + d.unread_mentions_count, + d.unread_reactions_count, + d.pinned, + d.pinned_order, + d.unread_mark, + d.hidden_peer_settings_bar, + COALESCE(u.id, 0)::bigint AS peer_user_id, + COALESCE(u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name, + COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name, + COALESCE(u.username, '')::text AS peer_username, + COALESCE(u.country_code, '')::text AS peer_country_code, + COALESCE(u.verified, false)::boolean AS peer_verified, + COALESCE(u.support, false)::boolean AS peer_support, + COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at, + (c.contact_user_id IS NOT NULL)::boolean AS peer_contact, + COALESCE(c.mutual, false)::boolean AS peer_mutual, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id, + COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.outgoing, false)::boolean AS message_outgoing, + COALESCE(m.body, '')::text AS message_body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json + FROM dialogs d + LEFT JOIN users u ON d.peer_type = 'user' AND u.id = d.peer_id + LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id + LEFT JOIN message_boxes m ON m.owner_user_id = d.user_id AND m.box_id = d.top_message_id AND NOT m.deleted + WHERE d.user_id = $1 + AND ( + NOT sqlc.arg(has_folder_id)::boolean + OR ( + sqlc.arg(folder_id)::int < 2 + AND d.folder_id = sqlc.arg(folder_id)::int + ) + OR ( + sqlc.arg(folder_id)::int >= 2 + AND NOT (sqlc.arg(folder_exclude_archived)::boolean AND d.folder_id = 1) + AND NOT (sqlc.arg(folder_exclude_read)::boolean AND d.unread_count = 0 AND NOT d.unread_mark) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_exclude_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_exclude_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + AND ( + EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_include_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_include_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_pinned_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_pinned_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR (sqlc.arg(folder_contacts)::boolean AND c.contact_user_id IS NOT NULL) + OR (sqlc.arg(folder_non_contacts)::boolean AND c.contact_user_id IS NULL) + ) + ) + ) + AND (NOT sqlc.arg(pinned_only)::boolean OR d.pinned) + AND (NOT sqlc.arg(exclude_pinned)::boolean OR NOT d.pinned) +), +paged AS ( + SELECT * + FROM base + WHERE ( + (sqlc.arg(offset_date)::int <= 0 AND sqlc.arg(offset_id)::int <= 0) + OR ( + sqlc.arg(offset_date)::int > 0 + AND ( + top_message_date < sqlc.arg(offset_date)::int + OR ( + top_message_date = sqlc.arg(offset_date)::int + AND ( + sqlc.arg(offset_id)::int <= 0 + OR top_message_id < sqlc.arg(offset_id)::int + OR ( + top_message_id = sqlc.arg(offset_id)::int + AND sqlc.arg(has_offset_peer)::boolean + AND peer_id < sqlc.arg(offset_peer_id)::bigint + ) + ) + ) + ) + ) + OR ( + sqlc.arg(offset_date)::int <= 0 + AND sqlc.arg(offset_id)::int > 0 + AND ( + top_message_id < sqlc.arg(offset_id)::int + OR ( + top_message_id = sqlc.arg(offset_id)::int + AND sqlc.arg(has_offset_peer)::boolean + AND peer_id < sqlc.arg(offset_peer_id)::bigint + ) + ) + ) + ) +) +SELECT + user_id, + peer_type::text AS peer_type, + peer_id::bigint AS peer_id, + folder_id, + top_message_id, + top_message_date, + read_inbox_max_id, + read_outbox_max_id, + unread_count, + unread_mentions_count, + unread_reactions_count, + pinned, + pinned_order, + unread_mark, + hidden_peer_settings_bar, + peer_user_id, + peer_access_hash, + peer_phone, + peer_first_name, + peer_last_name, + peer_username, + peer_country_code, + peer_verified, + peer_support, + peer_last_seen_at, + peer_contact, + peer_mutual, + message_id, + message_from_user_id, + message_date, + message_outgoing, + message_body, + message_entities_json +FROM paged +ORDER BY + pinned DESC, + CASE WHEN pinned THEN COALESCE(NULLIF(pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + top_message_date DESC, + top_message_id DESC, + peer_id DESC +LIMIT sqlc.arg(limit_count); + +-- name: ListDialogSummaryByUser :many +SELECT + d.peer_type, + d.peer_id, + d.folder_id, + d.top_message_id, + d.top_message_date, + d.read_inbox_max_id, + d.read_outbox_max_id, + d.unread_count, + d.unread_mentions_count, + d.unread_reactions_count, + d.pinned, + d.pinned_order, + d.unread_mark, + d.hidden_peer_settings_bar +FROM dialogs d +LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id +WHERE d.user_id = $1 + AND ( + NOT sqlc.arg(has_folder_id)::boolean + OR ( + sqlc.arg(folder_id)::int < 2 + AND d.folder_id = sqlc.arg(folder_id)::int + ) + OR ( + sqlc.arg(folder_id)::int >= 2 + AND NOT (sqlc.arg(folder_exclude_archived)::boolean AND d.folder_id = 1) + AND NOT (sqlc.arg(folder_exclude_read)::boolean AND d.unread_count = 0 AND NOT d.unread_mark) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_exclude_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_exclude_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + AND ( + EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_include_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_include_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_pinned_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_pinned_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR (sqlc.arg(folder_contacts)::boolean AND c.contact_user_id IS NOT NULL) + OR (sqlc.arg(folder_non_contacts)::boolean AND c.contact_user_id IS NULL) + ) + ) + ) + AND (NOT sqlc.arg(pinned_only)::boolean OR d.pinned) + AND (NOT sqlc.arg(exclude_pinned)::boolean OR NOT d.pinned) +ORDER BY + d.pinned DESC, + CASE WHEN d.pinned THEN COALESCE(NULLIF(d.pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + d.top_message_date DESC, + d.top_message_id DESC, + d.peer_id DESC; + +-- name: ListDialogsByPeers :many +WITH requested AS ( + SELECT + (sqlc.arg(peer_types)::text[])[i] AS peer_type, + (sqlc.arg(peer_ids)::bigint[])[i] AS peer_id, + i::int AS ord + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + ord + FROM requested + ORDER BY peer_type, peer_id, ord +), +base AS ( + SELECT + sqlc.arg(user_id)::bigint AS user_id, + r.peer_type, + r.peer_id, + COALESCE(d.folder_id, 0)::int AS folder_id, + COALESCE(d.top_message_id, 0)::int AS top_message_id, + COALESCE(d.top_message_date, 0)::int AS top_message_date, + COALESCE(d.read_inbox_max_id, 0)::int AS read_inbox_max_id, + COALESCE(d.read_outbox_max_id, 0)::int AS read_outbox_max_id, + COALESCE(d.unread_count, 0)::int AS unread_count, + COALESCE(d.unread_mentions_count, 0)::int AS unread_mentions_count, + COALESCE(d.unread_reactions_count, 0)::int AS unread_reactions_count, + COALESCE(d.pinned, false)::boolean AS pinned, + COALESCE(d.pinned_order, 0)::int AS pinned_order, + COALESCE(d.unread_mark, false)::boolean AS unread_mark, + COALESCE(d.hidden_peer_settings_bar, false)::boolean AS hidden_peer_settings_bar, + COALESCE(u.id, 0)::bigint AS peer_user_id, + COALESCE(u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name, + COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name, + COALESCE(u.username, '')::text AS peer_username, + COALESCE(u.country_code, '')::text AS peer_country_code, + COALESCE(u.verified, false)::boolean AS peer_verified, + COALESCE(u.support, false)::boolean AS peer_support, + COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at, + (c.contact_user_id IS NOT NULL)::boolean AS peer_contact, + COALESCE(c.mutual, false)::boolean AS peer_mutual, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id, + COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.outgoing, false)::boolean AS message_outgoing, + COALESCE(m.body, '')::text AS message_body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + r.ord + FROM deduped r + LEFT JOIN dialogs d + ON d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = r.peer_type + AND d.peer_id = r.peer_id + LEFT JOIN users u ON r.peer_type = 'user' AND u.id = r.peer_id + LEFT JOIN contacts c ON r.peer_type = 'user' AND c.user_id = sqlc.arg(user_id)::bigint AND c.contact_user_id = r.peer_id + LEFT JOIN message_boxes m ON m.owner_user_id = sqlc.arg(user_id)::bigint AND m.box_id = d.top_message_id AND NOT m.deleted +) +SELECT + user_id, + peer_type::text AS peer_type, + peer_id::bigint AS peer_id, + folder_id, + top_message_id, + top_message_date, + read_inbox_max_id, + read_outbox_max_id, + unread_count, + unread_mentions_count, + unread_reactions_count, + pinned, + pinned_order, + unread_mark, + hidden_peer_settings_bar, + peer_user_id, + peer_access_hash, + peer_phone, + peer_first_name, + peer_last_name, + peer_username, + peer_country_code, + peer_verified, + peer_support, + peer_last_seen_at, + peer_contact, + peer_mutual, + message_id, + message_from_user_id, + message_date, + message_outgoing, + message_body, + message_entities_json +FROM base +ORDER BY ord; + +-- name: UpsertDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + read_inbox_max_id, + read_outbox_max_id, + unread_count, + unread_mentions_count, + unread_reactions_count, + pinned, + unread_mark +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 +) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + read_inbox_max_id = EXCLUDED.read_inbox_max_id, + read_outbox_max_id = EXCLUDED.read_outbox_max_id, + unread_count = EXCLUDED.unread_count, + unread_mentions_count = EXCLUDED.unread_mentions_count, + unread_reactions_count = EXCLUDED.unread_reactions_count, + pinned = EXCLUDED.pinned, + unread_mark = EXCLUDED.unread_mark, + updated_at = now(); + +-- name: UpsertOutboxDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + unread_count +) VALUES ( + $1, $2, $3, $4, $5, 0 +) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + updated_at = now(); + +-- name: UpsertInboxDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + unread_count +) VALUES ( + $1, $2, $3, $4, $5, 1 +) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + unread_count = dialogs.unread_count + 1, + updated_at = now(); + +-- name: MarkDialogRead :one +WITH target AS ( + SELECT + d.user_id, + d.peer_type, + d.peer_id, + d.top_message_id, + d.read_inbox_max_id, + d.unread_count + FROM dialogs d + WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3 +), +updated AS ( +UPDATE dialogs d +SET + read_inbox_max_id = GREATEST( + d.read_inbox_max_id, + CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE d.top_message_id END + ), + unread_count = 0, + unread_mark = false, + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +FROM target +WHERE d.user_id = target.user_id + AND d.peer_type = target.peer_type + AND d.peer_id = target.peer_id +RETURNING + d.user_id, + d.peer_type, + d.peer_id, + d.read_inbox_max_id, + d.unread_count, + ( + target.unread_count > 0 + OR ( + CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE target.top_message_id END + ) > target.read_inbox_max_id + )::boolean AS changed +) +SELECT + user_id, + peer_type, + peer_id, + read_inbox_max_id, + unread_count, + changed +FROM updated; + +-- name: SetDialogPinned :one +WITH next_order AS ( + SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value + FROM dialogs + WHERE user_id = sqlc.arg(user_id)::bigint + AND pinned +), +updated AS ( + UPDATE dialogs d + SET pinned = sqlc.arg(pinned)::boolean, + pinned_order = CASE + WHEN sqlc.arg(pinned)::boolean THEN + CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END + ELSE 0 + END, + updated_at = now() + FROM next_order + WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = sqlc.arg(peer_type)::text + AND d.peer_id = sqlc.arg(peer_id)::bigint + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed; + +-- name: SetDialogUnreadMark :one +WITH updated AS ( + UPDATE dialogs d + SET unread_mark = sqlc.arg(unread)::boolean, + updated_at = now() + WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = sqlc.arg(peer_type)::text + AND d.peer_id = sqlc.arg(peer_id)::bigint + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed; + +-- name: ListDialogUnreadMarks :many +SELECT + peer_type, + peer_id +FROM dialogs +WHERE user_id = $1 + AND unread_mark +ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC; + +-- name: SetPeerSettingsBarHidden :one +WITH updated AS ( + UPDATE dialogs d + SET hidden_peer_settings_bar = true, + updated_at = now() + WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = sqlc.arg(peer_type)::text + AND d.peer_id = sqlc.arg(peer_id)::bigint + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed; + +-- name: GetPeerSettingsBarHidden :one +SELECT hidden_peer_settings_bar +FROM dialogs +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3; + +-- name: ReorderPinnedDialogs :exec +WITH requested AS ( + SELECT + (sqlc.arg(peer_types)::text[])[i] AS peer_type, + (sqlc.arg(peer_ids)::bigint[])[i] AS peer_id, + i::int AS pos + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + pos::int AS ord + FROM requested + ORDER BY peer_type, peer_id, pos +) +UPDATE dialogs d +SET pinned = true, + pinned_order = deduped.ord, + updated_at = now() +FROM deduped +WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = deduped.peer_type + AND d.peer_id = deduped.peer_id; + +-- name: EditDialogPeerFolders :exec +WITH requested AS ( + SELECT + (sqlc.arg(peer_types)::text[])[i] AS peer_type, + (sqlc.arg(peer_ids)::bigint[])[i] AS peer_id, + (sqlc.arg(folder_ids)::int[])[i] AS folder_id + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) + AND i <= cardinality(sqlc.arg(folder_ids)::int[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + folder_id + FROM requested + WHERE folder_id IN (0, 1) + ORDER BY peer_type, peer_id +) +UPDATE dialogs d +SET folder_id = deduped.folder_id, + updated_at = now() +FROM deduped +WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = deduped.peer_type + AND d.peer_id = deduped.peer_id; + +-- name: ClearPinnedDialogsNotInOrder :exec +WITH requested AS ( + SELECT + (sqlc.arg(peer_types)::text[])[i] AS peer_type, + (sqlc.arg(peer_ids)::bigint[])[i] AS peer_id + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) +) +UPDATE dialogs d +SET pinned = false, + pinned_order = 0, + updated_at = now() +WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.pinned + AND NOT EXISTS ( + SELECT 1 + FROM requested r + WHERE r.peer_type = d.peer_type + AND r.peer_id = d.peer_id + ); + +-- name: RefreshDialogAfterMessageDelete :exec +UPDATE dialogs d +SET + top_message_id = sqlc.arg(top_message_id)::int, + top_message_date = sqlc.arg(top_message_date)::int, + unread_count = ( + SELECT COUNT(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND NOT m.outgoing + AND m.box_id > d.read_inbox_max_id + ), + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = sqlc.arg(peer_type)::text + AND d.peer_id = sqlc.arg(peer_id)::bigint; + +-- name: ClearDialogAfterHistoryDelete :exec +UPDATE dialogs d +SET + top_message_id = 0, + top_message_date = 0, + read_inbox_max_id = GREATEST(d.read_inbox_max_id, d.top_message_id), + read_outbox_max_id = GREATEST(d.read_outbox_max_id, d.top_message_id), + unread_count = 0, + unread_mark = false, + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = sqlc.arg(peer_type)::text + AND d.peer_id = sqlc.arg(peer_id)::bigint; + +-- name: DeleteDialogByPeer :exec +DELETE FROM dialogs +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3; + +-- name: ListDialogFolders :many +SELECT + filter_id, + is_chatlist, + filter::text AS filter_json +FROM dialog_filters +WHERE user_id = $1 +ORDER BY order_value ASC, filter_id ASC; + +-- name: GetDialogFolder :one +SELECT + filter_id, + is_chatlist, + filter::text AS filter_json +FROM dialog_filters +WHERE user_id = $1 + AND filter_id = $2; + +-- name: UpsertDialogFolder :exec +INSERT INTO dialog_filters ( + user_id, + filter_id, + is_chatlist, + filter, + order_value +) VALUES ( + $1, + $2, + $3, + sqlc.arg(filter_json)::jsonb, + COALESCE( + (SELECT order_value FROM dialog_filters WHERE user_id = $1 AND filter_id = $2), + (SELECT COALESCE(MAX(order_value), 0) + 1 FROM dialog_filters WHERE user_id = $1) + ) +) +ON CONFLICT (user_id, filter_id) DO UPDATE SET + is_chatlist = EXCLUDED.is_chatlist, + filter = EXCLUDED.filter, + updated_at = now(); + +-- name: DeleteDialogFolder :exec +DELETE FROM dialog_filters +WHERE user_id = $1 + AND filter_id = $2; + +-- name: ReorderDialogFolders :exec +WITH requested AS ( + SELECT filter_id, ord::int AS order_value + FROM unnest(sqlc.arg(filter_ids)::int[]) WITH ORDINALITY AS t(filter_id, ord) +), +deduped AS ( + SELECT DISTINCT ON (filter_id) + filter_id, + order_value + FROM requested + WHERE filter_id >= 2 + ORDER BY filter_id, order_value +) +UPDATE dialog_filters f +SET order_value = deduped.order_value, + updated_at = now() +FROM deduped +WHERE f.user_id = sqlc.arg(user_id)::bigint + AND f.filter_id = deduped.filter_id; + +-- name: GetDialogFolderTags :one +SELECT tags_enabled +FROM dialog_filter_settings +WHERE user_id = $1; + +-- name: SetDialogFolderTags :exec +INSERT INTO dialog_filter_settings ( + user_id, + tags_enabled +) VALUES ( + $1, + $2 +) +ON CONFLICT (user_id) DO UPDATE SET + tags_enabled = EXCLUDED.tags_enabled, + updated_at = now(); + +-- name: UpsertDialogDraft :exec +INSERT INTO dialog_drafts ( + user_id, + peer_type, + peer_id, + top_message_id, + date, + draft +) VALUES ( + $1, + $2, + $3, + $4, + $5, + sqlc.arg(draft_json)::jsonb +) +ON CONFLICT (user_id, peer_type, peer_id, top_message_id) DO UPDATE SET + date = EXCLUDED.date, + draft = EXCLUDED.draft, + updated_at = now(); + +-- name: DeleteDialogDraft :one +WITH deleted AS ( + DELETE FROM dialog_drafts + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND top_message_id = $4 + RETURNING user_id +) +SELECT EXISTS (SELECT 1 FROM deleted)::boolean AS changed; + +-- name: ListDialogDrafts :many +SELECT draft::text AS draft_json +FROM dialog_drafts +WHERE user_id = $1 +ORDER BY date DESC, peer_type ASC, peer_id DESC, top_message_id DESC +LIMIT sqlc.arg(limit_count); + +-- name: ClearDialogDrafts :many +WITH doomed AS ( + SELECT d.user_id, d.peer_type, d.peer_id, d.top_message_id + FROM dialog_drafts d + WHERE d.user_id = $1 + ORDER BY d.date DESC, d.peer_type ASC, d.peer_id DESC, d.top_message_id DESC + LIMIT sqlc.arg(limit_count) +), +deleted AS ( + DELETE FROM dialog_drafts d + USING doomed + WHERE d.user_id = doomed.user_id + AND d.peer_type = doomed.peer_type + AND d.peer_id = doomed.peer_id + AND d.top_message_id = doomed.top_message_id + RETURNING d.draft::text AS draft_json +) +SELECT draft_json +FROM deleted; diff --git a/internal/store/postgres/queries/help.sql b/internal/store/postgres/queries/help.sql new file mode 100644 index 00000000..c6207001 --- /dev/null +++ b/internal/store/postgres/queries/help.sql @@ -0,0 +1,43 @@ +-- name: GetAppConfig :one +SELECT client, hash, config_json::text AS config_json +FROM app_configs +WHERE client = $1; + +-- name: UpsertAppConfig :exec +INSERT INTO app_configs (client, hash, config_json) +VALUES ($1, $2, sqlc.arg(config_json)::jsonb) +ON CONFLICT (client) DO UPDATE SET + hash = EXCLUDED.hash, + config_json = EXCLUDED.config_json, + updated_at = now(); + +-- name: ListCountries :many +SELECT + c.iso2, + c.default_name, + c.name, + c.hidden, + cc.country_code, + cc.prefixes, + cc.patterns +FROM countries c +JOIN country_codes cc ON cc.iso2 = c.iso2 +ORDER BY c.order_index, c.iso2, cc.order_index, cc.country_code; + +-- name: UpsertCountry :exec +INSERT INTO countries (iso2, default_name, name, hidden, order_index) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (iso2) DO UPDATE SET + default_name = EXCLUDED.default_name, + name = EXCLUDED.name, + hidden = EXCLUDED.hidden, + order_index = EXCLUDED.order_index, + updated_at = now(); + +-- name: UpsertCountryCode :exec +INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (iso2, country_code) DO UPDATE SET + prefixes = EXCLUDED.prefixes, + patterns = EXCLUDED.patterns, + order_index = EXCLUDED.order_index; diff --git a/internal/store/postgres/queries/langpack.sql b/internal/store/postgres/queries/langpack.sql new file mode 100644 index 00000000..4c4bdf56 --- /dev/null +++ b/internal/store/postgres/queries/langpack.sql @@ -0,0 +1,47 @@ +-- name: GetLangPackMeta :one +SELECT lang_pack, lang_code, version, strings_count +FROM lang_packs +WHERE lang_pack = $1 AND lang_code = $2; + +-- name: UpsertLangPackMeta :exec +INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count) +VALUES ($1, $2, $3, $4) +ON CONFLICT (lang_pack, lang_code) DO UPDATE SET + version = EXCLUDED.version, + strings_count = EXCLUDED.strings_count, + updated_at = now(); + +-- name: UpsertLangPackString :exec +INSERT INTO lang_pack_strings ( + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET + version = EXCLUDED.version, + pluralized = EXCLUDED.pluralized, + value = EXCLUDED.value, + zero_value = EXCLUDED.zero_value, + one_value = EXCLUDED.one_value, + two_value = EXCLUDED.two_value, + few_value = EXCLUDED.few_value, + many_value = EXCLUDED.many_value, + other_value = EXCLUDED.other_value, + deleted = EXCLUDED.deleted, + updated_at = now(); + +-- name: ListLangPackStrings :many +SELECT + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +FROM lang_pack_strings +WHERE lang_pack = $1 AND lang_code = $2 AND NOT deleted +ORDER BY key; + +-- name: GetLangPackStringsByKeys :many +SELECT + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +FROM lang_pack_strings +WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY(sqlc.arg(keys)::text[]) AND NOT deleted +ORDER BY key; diff --git a/internal/store/postgres/queries/media.sql b/internal/store/postgres/queries/media.sql new file mode 100644 index 00000000..85053f8a --- /dev/null +++ b/internal/store/postgres/queries/media.sql @@ -0,0 +1,331 @@ +-- upload_parts ---------------------------------------------------------------- + +-- name: SaveUploadPart :exec +INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, bytes) +VALUES ( + sqlc.arg(owner_user_id)::bigint, + sqlc.arg(file_id)::bigint, + sqlc.arg(part)::int, + sqlc.arg(total_parts)::int, + sqlc.arg(is_big)::boolean, + sqlc.arg(bytes)::bytea +) +ON CONFLICT (owner_user_id, file_id, part) DO UPDATE SET + total_parts = EXCLUDED.total_parts, + is_big = EXCLUDED.is_big, + bytes = EXCLUDED.bytes; + +-- name: ListUploadParts :many +SELECT part, total_parts, is_big, bytes +FROM upload_parts +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND file_id = sqlc.arg(file_id)::bigint +ORDER BY part ASC; + +-- name: DeleteUploadParts :exec +DELETE FROM upload_parts +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND file_id = sqlc.arg(file_id)::bigint; + +-- file_blobs ------------------------------------------------------------------ + +-- name: PutFileBlob :exec +INSERT INTO file_blobs (location_key, backend, object_key, size, sha256, mime_type) +VALUES ( + sqlc.arg(location_key)::text, + sqlc.arg(backend)::text, + sqlc.arg(object_key)::text, + sqlc.arg(size)::bigint, + sqlc.arg(sha256)::bytea, + sqlc.arg(mime_type)::text +) +ON CONFLICT (location_key) DO UPDATE SET + backend = EXCLUDED.backend, + object_key = EXCLUDED.object_key, + size = EXCLUDED.size, + sha256 = EXCLUDED.sha256, + mime_type = EXCLUDED.mime_type; + +-- name: GetFileBlob :one +SELECT location_key, backend, object_key, size, sha256, mime_type +FROM file_blobs +WHERE location_key = sqlc.arg(location_key)::text; + +-- documents ------------------------------------------------------------------- + +-- name: PutDocument :exec +INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs) +VALUES ( + sqlc.arg(id)::bigint, + sqlc.arg(access_hash)::bigint, + sqlc.arg(file_reference)::bytea, + sqlc.arg(date)::int, + sqlc.arg(mime_type)::text, + sqlc.arg(size)::bigint, + sqlc.arg(dc_id)::int, + sqlc.arg(attributes_json)::jsonb, + sqlc.arg(thumbs_json)::jsonb +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + file_reference = EXCLUDED.file_reference, + date = EXCLUDED.date, + mime_type = EXCLUDED.mime_type, + size = EXCLUDED.size, + dc_id = EXCLUDED.dc_id, + attributes = EXCLUDED.attributes, + thumbs = EXCLUDED.thumbs; + +-- name: GetDocument :one +SELECT id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes::text AS attributes_json, + thumbs::text AS thumbs_json +FROM documents +WHERE id = sqlc.arg(id)::bigint; + +-- name: GetDocuments :many +SELECT id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes::text AS attributes_json, + thumbs::text AS thumbs_json +FROM documents +WHERE id = ANY(sqlc.arg(ids)::bigint[]); + +-- photos ---------------------------------------------------------------------- + +-- name: PutPhoto :exec +INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes) +VALUES ( + sqlc.arg(id)::bigint, + sqlc.arg(access_hash)::bigint, + sqlc.arg(file_reference)::bytea, + sqlc.arg(date)::int, + sqlc.arg(dc_id)::int, + sqlc.arg(has_stickers)::boolean, + sqlc.arg(sizes_json)::jsonb +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + file_reference = EXCLUDED.file_reference, + date = EXCLUDED.date, + dc_id = EXCLUDED.dc_id, + has_stickers = EXCLUDED.has_stickers, + sizes = EXCLUDED.sizes; + +-- name: GetPhoto :one +SELECT id, access_hash, file_reference, date, dc_id, has_stickers, + sizes::text AS sizes_json +FROM photos +WHERE id = sqlc.arg(id)::bigint; + +-- sticker_sets ---------------------------------------------------------------- + +-- name: PutStickerSet :exec +INSERT INTO sticker_sets ( + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs, thumb_dc_id, thumb_version, document_ids, packs, sort_order, system_key +) VALUES ( + sqlc.arg(id)::bigint, + sqlc.arg(access_hash)::bigint, + sqlc.arg(short_name)::text, + sqlc.arg(title)::text, + sqlc.arg(count)::int, + sqlc.arg(hash)::int, + sqlc.arg(set_kind)::text, + sqlc.arg(official)::boolean, + sqlc.arg(animated)::boolean, + sqlc.arg(videos)::boolean, + sqlc.arg(emojis)::boolean, + sqlc.arg(masks)::boolean, + sqlc.arg(installed)::boolean, + sqlc.arg(archived)::boolean, + sqlc.arg(installed_date)::int, + sqlc.arg(thumb_document_id)::bigint, + sqlc.arg(thumbs_json)::jsonb, + sqlc.arg(thumb_dc_id)::int, + sqlc.arg(thumb_version)::int, + sqlc.arg(document_ids_json)::jsonb, + sqlc.arg(packs_json)::jsonb, + sqlc.arg(sort_order)::int, + sqlc.arg(system_key)::text +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + short_name = EXCLUDED.short_name, + title = EXCLUDED.title, + count = EXCLUDED.count, + hash = EXCLUDED.hash, + set_kind = EXCLUDED.set_kind, + official = EXCLUDED.official, + animated = EXCLUDED.animated, + videos = EXCLUDED.videos, + emojis = EXCLUDED.emojis, + masks = EXCLUDED.masks, + installed = EXCLUDED.installed, + archived = EXCLUDED.archived, + installed_date = EXCLUDED.installed_date, + thumb_document_id = EXCLUDED.thumb_document_id, + thumbs = EXCLUDED.thumbs, + thumb_dc_id = EXCLUDED.thumb_dc_id, + thumb_version = EXCLUDED.thumb_version, + document_ids = EXCLUDED.document_ids, + packs = EXCLUDED.packs, + sort_order = EXCLUDED.sort_order, + system_key = EXCLUDED.system_key; + +-- name: GetStickerSetByID :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE id = sqlc.arg(id)::bigint; + +-- name: GetStickerSetByShortName :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE short_name = sqlc.arg(short_name)::text; + +-- name: GetStickerSetBySystemKey :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE system_key = sqlc.arg(system_key)::text; + +-- name: ListStickerSetsByKind :many +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE set_kind = sqlc.arg(set_kind)::text +ORDER BY sort_order ASC, id ASC; + +-- name: CountStickerSets :one +SELECT count(*)::int AS total FROM sticker_sets; + +-- available_reactions --------------------------------------------------------- + +-- name: PutAvailableReaction :exec +INSERT INTO available_reactions ( + reaction, title, inactive, premium, + static_icon_id, appear_animation_id, select_animation_id, + activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order +) VALUES ( + sqlc.arg(reaction)::text, + sqlc.arg(title)::text, + sqlc.arg(inactive)::boolean, + sqlc.arg(premium)::boolean, + sqlc.arg(static_icon_id)::bigint, + sqlc.arg(appear_animation_id)::bigint, + sqlc.arg(select_animation_id)::bigint, + sqlc.arg(activate_animation_id)::bigint, + sqlc.arg(effect_animation_id)::bigint, + sqlc.arg(around_animation_id)::bigint, + sqlc.arg(center_icon_id)::bigint, + sqlc.arg(sort_order)::int +) +ON CONFLICT (reaction) DO UPDATE SET + title = EXCLUDED.title, + inactive = EXCLUDED.inactive, + premium = EXCLUDED.premium, + static_icon_id = EXCLUDED.static_icon_id, + appear_animation_id = EXCLUDED.appear_animation_id, + select_animation_id = EXCLUDED.select_animation_id, + activate_animation_id = EXCLUDED.activate_animation_id, + effect_animation_id = EXCLUDED.effect_animation_id, + around_animation_id = EXCLUDED.around_animation_id, + center_icon_id = EXCLUDED.center_icon_id, + sort_order = EXCLUDED.sort_order; + +-- name: ListAvailableReactions :many +SELECT + reaction, title, inactive, premium, + static_icon_id, appear_animation_id, select_animation_id, + activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order +FROM available_reactions +ORDER BY sort_order ASC, reaction ASC; + +-- name: CountAvailableReactions :one +SELECT count(*)::int AS total FROM available_reactions; + +-- profile_photos -------------------------------------------------------------- + +-- name: AddProfilePhoto :exec +INSERT INTO profile_photos (owner_peer_type, owner_peer_id, photo_id, date, active, sort_order) +VALUES ( + sqlc.arg(owner_peer_type)::text, + sqlc.arg(owner_peer_id)::bigint, + sqlc.arg(photo_id)::bigint, + sqlc.arg(date)::int, + true, + sqlc.arg(sort_order)::bigint +) +ON CONFLICT (owner_peer_type, owner_peer_id, photo_id) DO UPDATE SET + date = EXCLUDED.date, + active = true, + sort_order = EXCLUDED.sort_order; + +-- name: NextProfilePhotoOrder :one +SELECT COALESCE(MAX(sort_order), 0)::bigint AS max_order +FROM profile_photos +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint; + +-- name: CurrentProfilePhoto :one +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint + AND active +ORDER BY sort_order DESC +LIMIT 1; + +-- name: CurrentProfilePhotosForOwners :many +SELECT DISTINCT ON (pp.owner_peer_id) + pp.owner_peer_id, + pp.photo_id, + ph.dc_id, + ph.sizes::text AS sizes_json +FROM profile_photos pp +JOIN photos ph ON ph.id = pp.photo_id +WHERE pp.owner_peer_type = sqlc.arg(owner_peer_type)::text + AND pp.owner_peer_id = ANY(sqlc.arg(owner_ids)::bigint[]) + AND pp.active +ORDER BY pp.owner_peer_id, pp.sort_order DESC; + +-- name: ListProfilePhotos :many +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint + AND active + AND (sqlc.arg(max_id)::bigint <= 0 OR photo_id < sqlc.arg(max_id)::bigint) +ORDER BY sort_order DESC +OFFSET sqlc.arg(offset_count)::int +LIMIT sqlc.arg(limit_count)::int; + +-- name: CountProfilePhotos :one +SELECT count(*)::int AS total +FROM profile_photos +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint + AND active; + +-- name: DeactivateProfilePhotos :many +UPDATE profile_photos +SET active = false +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint + AND photo_id = ANY(sqlc.arg(photo_ids)::bigint[]) + AND active +RETURNING photo_id; diff --git a/internal/store/postgres/queries/message.sql b/internal/store/postgres/queries/message.sql new file mode 100644 index 00000000..fe605787 --- /dev/null +++ b/internal/store/postgres/queries/message.sql @@ -0,0 +1,932 @@ +-- name: CreateMessage :one +WITH pm AS ( + INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities + ) VALUES ( + sqlc.arg(from_user_id), + sqlc.arg(owner_user_id), + 0, + sqlc.arg(message_date), + sqlc.arg(body), + sqlc.arg(entities_json)::jsonb + ) + RETURNING id, sender_user_id +), +box AS ( + INSERT INTO message_boxes ( + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + outgoing, + body, + entities, + pts + ) + SELECT + sqlc.arg(owner_user_id), + sqlc.arg(box_id), + pm.id, + pm.sender_user_id, + sqlc.arg(peer_type), + sqlc.arg(peer_id), + sqlc.arg(from_user_id), + sqlc.arg(message_date), + sqlc.arg(outgoing), + sqlc.arg(body), + sqlc.arg(entities_json)::jsonb, + sqlc.arg(pts) + FROM pm + RETURNING + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + pts +) +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities_json, + pts +FROM box; + +-- name: CreatePrivateMessage :one +INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + media +) VALUES ( + $1, $2, $3, $4, $5, sqlc.arg(entities_json)::jsonb, + sqlc.arg(silent)::boolean, + sqlc.arg(noforwards)::boolean, + sqlc.arg(reply_to_msg_id)::int, + sqlc.arg(reply_to_peer_type)::text, + sqlc.arg(reply_to_peer_id)::bigint, + sqlc.arg(reply_to_top_id)::int, + sqlc.arg(quote_text)::text, + sqlc.arg(quote_entities_json)::jsonb, + sqlc.arg(quote_offset)::int, + sqlc.arg(fwd_from_peer_type)::text, + sqlc.arg(fwd_from_peer_id)::bigint, + sqlc.arg(fwd_from_name)::text, + sqlc.arg(fwd_date)::int, + sqlc.arg(media_json)::jsonb +) +ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING +RETURNING + id, + sender_user_id, + recipient_user_id, + random_id, + message_date, + edit_date, + body, + entities::text AS entities_json; + +-- name: GetPrivateMessageByRandomID :one +SELECT + id, + sender_user_id, + recipient_user_id, + random_id, + message_date, + edit_date, + body, + entities::text AS entities_json +FROM private_messages +WHERE sender_user_id = $1 + AND random_id = $2 + AND random_id <> 0; + +-- name: CreateMessageBox :one +INSERT INTO message_boxes ( + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + outgoing, + body, + entities, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, sqlc.arg(entities_json)::jsonb, + sqlc.arg(silent)::boolean, + sqlc.arg(noforwards)::boolean, + sqlc.arg(reply_to_msg_id)::int, + sqlc.arg(reply_to_peer_type)::text, + sqlc.arg(reply_to_peer_id)::bigint, + sqlc.arg(reply_to_top_id)::int, + sqlc.arg(quote_text)::text, + sqlc.arg(quote_entities_json)::jsonb, + sqlc.arg(quote_offset)::int, + sqlc.arg(fwd_from_peer_type)::text, + sqlc.arg(fwd_from_peer_id)::bigint, + sqlc.arg(fwd_from_name)::text, + sqlc.arg(fwd_date)::int, + sqlc.arg(pts)::int, + sqlc.arg(media_json)::jsonb +) +RETURNING + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json; + +-- name: GetMessageBoxByPrivateMessage :one +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = $1 + AND private_message_id = $2 + AND NOT deleted; + +-- name: GetMessageBoxForReply :one +SELECT + box_id, + private_message_id, + message_sender_id +FROM message_boxes +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND box_id = sqlc.arg(box_id)::int + AND NOT deleted +LIMIT 1; + +-- name: GetMessageBoxesForForward :many +WITH requested AS ( + SELECT + id::int AS box_id, + ord::int AS ord + FROM unnest(sqlc.arg(box_ids)::int[]) WITH ORDINALITY AS r(id, ord) +) +SELECT + r.ord, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.message_sender_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.pts, + m.media::text AS media_json +FROM requested r +JOIN message_boxes m + ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = sqlc.arg(peer_type)::text + AND m.peer_id = sqlc.arg(peer_id)::bigint + AND m.box_id = r.box_id + AND NOT m.deleted +ORDER BY r.ord ASC; + +-- name: MaxMessageBoxID :one +SELECT COALESCE(MAX(box_id), 0)::int AS max_box_id +FROM message_boxes +WHERE owner_user_id = $1; + +-- name: ListMessagesByUser :many +WITH load_params AS ( + SELECT + sqlc.arg(offset_id)::int AS offset_id, + sqlc.arg(offset_date)::int AS offset_date, + sqlc.arg(add_offset)::int AS add_offset, + sqlc.arg(limit_count)::int AS limit_count, + CASE + WHEN sqlc.arg(add_offset)::int >= 0 THEN 'backward' + WHEN sqlc.arg(add_offset)::int + sqlc.arg(limit_count)::int > 0 THEN 'around' + ELSE 'forward' + END::text AS load_type +), +base AS NOT MATERIALIZED ( + SELECT + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.pts, + m.media::text AS media_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at + FROM message_boxes m + LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id + LEFT JOIN users from_u ON from_u.id = m.from_user_id + WHERE m.owner_user_id = $1 + AND NOT m.deleted + AND ( + NOT sqlc.arg(has_peer)::boolean + OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) + ) + AND ( + sqlc.arg(query)::text = '' + OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') + ) + AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int) + AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int) +), +total AS ( + SELECT count(*)::int AS total_count + FROM base + WHERE sqlc.arg(need_total_count)::boolean +), +backward AS ( + SELECT b.* + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'backward' + AND ( + (p.offset_date > 0 AND b.message_date < p.offset_date) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id)) + ) + ORDER BY b.box_id DESC + OFFSET GREATEST((SELECT add_offset FROM load_params), 0) + LIMIT (SELECT limit_count FROM load_params) +), +around_forward AS ( + SELECT f.* + FROM ( + SELECT b.* + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'around' + AND ( + (p.offset_date > 0 AND b.message_date >= p.offset_date) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + ) + ORDER BY b.box_id ASC + LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params)) + ) f +), +around_backward AS ( + SELECT b.* + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'around' + AND ( + (p.offset_date > 0 AND b.message_date < p.offset_date) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id)) + ) + ORDER BY b.box_id DESC + LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0) +), +forward AS ( + SELECT f.* + FROM ( + SELECT b.* + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'forward' + AND ( + (p.offset_date > 0 AND b.message_date >= p.offset_date) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + ) + ORDER BY b.box_id ASC + LIMIT (SELECT limit_count FROM load_params) + ) f +), +paged AS ( + SELECT * FROM backward + UNION ALL + SELECT * FROM around_forward + UNION ALL + SELECT * FROM around_backward + UNION ALL + SELECT * FROM forward +) +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media_json, + peer_user_id, + peer_access_hash, + peer_phone, + peer_first_name, + peer_last_name, + peer_username, + peer_country_code, + peer_verified, + peer_support, + peer_last_seen_at, + from_user_user_id, + from_user_access_hash, + from_user_phone, + from_user_first_name, + from_user_last_name, + from_user_username, + from_user_country_code, + from_user_verified, + from_user_support, + from_user_last_seen_at, + COALESCE(total.total_count, 0)::int AS total_count +FROM paged +CROSS JOIN total +ORDER BY box_id DESC; + +-- name: GetMessageBoxesByIDs :many +SELECT + wanted.box_id AS requested_box_id, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.pts, + m.media::text AS media_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at +FROM unnest(@box_ids::int[]) WITH ORDINALITY AS wanted(box_id, ord) +JOIN message_boxes m + ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.box_id = wanted.box_id + AND NOT m.deleted +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +ORDER BY wanted.ord ASC; + +-- name: GetMessageBoxForEdit :one +SELECT + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND box_id = sqlc.arg(box_id)::int + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND NOT deleted +LIMIT 1 +FOR UPDATE; + +-- name: ListVisibleMessageBoxesByPrivateMessage :many +SELECT + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE message_sender_id = sqlc.arg(message_sender_id)::bigint + AND private_message_id = sqlc.arg(private_message_id)::bigint + AND NOT deleted +ORDER BY owner_user_id ASC, box_id ASC +FOR UPDATE; + +-- name: UpdatePrivateMessageEdit :exec +UPDATE private_messages +SET body = sqlc.arg(body)::text, + entities = sqlc.arg(entities_json)::jsonb, + edit_date = sqlc.arg(edit_date)::int +WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint + AND id = sqlc.arg(private_message_id)::bigint; + +-- name: UpdateMessageBoxEdit :one +UPDATE message_boxes +SET body = sqlc.arg(body)::text, + entities = sqlc.arg(entities_json)::jsonb, + edit_date = sqlc.arg(edit_date)::int, + pts = sqlc.arg(pts)::int +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND box_id = sqlc.arg(box_id)::int + AND NOT deleted +RETURNING + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json; + +-- name: GetDialogReadStateForUpdate :one +SELECT + user_id, + peer_type, + peer_id, + top_message_id, + read_inbox_max_id, + unread_count +FROM dialogs +WHERE user_id = sqlc.arg(user_id)::bigint + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint +FOR UPDATE; + +-- name: LatestIncomingReadReceiptCandidate :one +SELECT + m.message_sender_id, + m.private_message_id, + sender_box.owner_user_id AS sender_owner_user_id, + sender_box.box_id AS sender_box_id +FROM message_boxes m +JOIN message_boxes sender_box + ON sender_box.message_sender_id = m.message_sender_id + AND sender_box.private_message_id = m.private_message_id + AND sender_box.owner_user_id = m.message_sender_id + AND sender_box.outgoing + AND NOT sender_box.deleted +WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = sqlc.arg(peer_type)::text + AND m.peer_id = sqlc.arg(peer_id)::bigint + AND NOT m.outgoing + AND NOT m.deleted + AND m.box_id > sqlc.arg(old_read_inbox_max_id)::int + AND m.box_id <= sqlc.arg(new_read_inbox_max_id)::int +ORDER BY m.box_id DESC +LIMIT 1; + +-- name: UpdateDialogReadInbox :one +UPDATE dialogs d +SET + read_inbox_max_id = GREATEST(d.read_inbox_max_id, sqlc.arg(read_inbox_max_id)::int), + unread_count = ( + SELECT count(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND NOT m.outgoing + AND m.box_id > GREATEST(d.read_inbox_max_id, sqlc.arg(read_inbox_max_id)::int) + ), + unread_mark = false, + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = sqlc.arg(peer_type)::text + AND d.peer_id = sqlc.arg(peer_id)::bigint +RETURNING + d.read_inbox_max_id, + d.unread_count; + +-- name: UpdateDialogReadOutbox :one +UPDATE dialogs +SET + read_outbox_max_id = GREATEST(read_outbox_max_id, sqlc.arg(read_outbox_max_id)::int), + updated_at = now() +WHERE user_id = sqlc.arg(user_id)::bigint + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND read_outbox_max_id < sqlc.arg(read_outbox_max_id)::int +RETURNING read_outbox_max_id; + +-- name: GetOutboxMessageForReadDate :one +SELECT box_id +FROM message_boxes +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND box_id = sqlc.arg(box_id)::int + AND outgoing + AND NOT deleted +LIMIT 1; + +-- name: GetOutboxReadDate :one +SELECT COALESCE(MIN(date), 0)::int AS read_date +FROM user_update_events +WHERE user_id = sqlc.arg(user_id)::bigint + AND event_type = 'read_history_outbox' + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND max_id >= sqlc.arg(message_id)::int; + +-- name: DeleteMessageBoxesByIDs :many +WITH updated AS ( + UPDATE message_boxes m + SET deleted = true + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.box_id = ANY(sqlc.arg(box_ids)::int[]) + AND NOT m.deleted + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY box_id ASC; + +-- name: DeleteMessageBoxesByPeer :many +WITH updated AS ( + UPDATE message_boxes m + SET deleted = true + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = sqlc.arg(peer_type)::text + AND m.peer_id = sqlc.arg(peer_id)::bigint + AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND NOT m.deleted + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY box_id ASC; + +-- name: DeleteMessageBoxesByPeerBatch :many +WITH target AS ( + SELECT + m.owner_user_id, + m.box_id + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = sqlc.arg(peer_type)::text + AND m.peer_id = sqlc.arg(peer_id)::bigint + AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND NOT m.deleted + ORDER BY m.box_id DESC + LIMIT sqlc.arg(limit_count)::int + FOR UPDATE SKIP LOCKED +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM target t + WHERE m.owner_user_id = t.owner_user_id + AND m.box_id = t.box_id + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY box_id ASC; + +-- name: HasDeletableMessageBoxByPeer :one +SELECT EXISTS ( + SELECT 1 + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = sqlc.arg(peer_type)::text + AND m.peer_id = sqlc.arg(peer_id)::bigint + AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND NOT m.deleted + LIMIT 1 +)::boolean AS more; + +-- name: DeleteMessageBoxesByPrivateMessages :many +WITH requested AS ( + SELECT + (sqlc.arg(message_sender_ids)::bigint[])[i] AS message_sender_id, + (sqlc.arg(private_message_ids)::bigint[])[i] AS private_message_id + FROM generate_subscripts(sqlc.arg(private_message_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(message_sender_ids)::bigint[]) +), +deduped AS ( + SELECT DISTINCT message_sender_id, private_message_id + FROM requested +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM deduped d + WHERE m.message_sender_id = d.message_sender_id + AND m.private_message_id = d.private_message_id + AND NOT m.deleted + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY owner_user_id ASC, box_id ASC; + +-- name: TopVisibleMessageBoxByPeer :one +SELECT + box_id, + message_date +FROM message_boxes +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND NOT deleted +ORDER BY box_id DESC +LIMIT 1; diff --git a/internal/store/postgres/queries/temp_auth_key.sql b/internal/store/postgres/queries/temp_auth_key.sql new file mode 100644 index 00000000..c4e17928 --- /dev/null +++ b/internal/store/postgres/queries/temp_auth_key.sql @@ -0,0 +1,23 @@ +-- name: UpsertTempAuthKeyBinding :exec +INSERT INTO temp_auth_key_bindings ( + temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message +) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (temp_auth_key_id) DO UPDATE SET + perm_auth_key_id = EXCLUDED.perm_auth_key_id, + nonce = EXCLUDED.nonce, + temp_session_id = EXCLUDED.temp_session_id, + expires_at = EXCLUDED.expires_at, + encrypted_message = EXCLUDED.encrypted_message, + created_at = now(); + +-- name: GetTempAuthKeyBinding :one +SELECT + temp_auth_key_id, + perm_auth_key_id, + nonce, + temp_session_id, + expires_at, + encrypted_message +FROM temp_auth_key_bindings +WHERE temp_auth_key_id = $1; diff --git a/internal/store/postgres/queries/update_state.sql b/internal/store/postgres/queries/update_state.sql new file mode 100644 index 00000000..3ed6b22e --- /dev/null +++ b/internal/store/postgres/queries/update_state.sql @@ -0,0 +1,24 @@ +-- name: GetUpdateState :one +SELECT auth_key_id, user_id, pts, qts, date, seq +FROM update_states +WHERE auth_key_id = $1 + AND user_id = $2; + +-- name: UpsertUpdateState :exec +INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (auth_key_id, user_id) DO UPDATE SET + pts = EXCLUDED.pts, + qts = EXCLUDED.qts, + date = EXCLUDED.date, + seq = EXCLUDED.seq, + updated_at = now(); + +-- name: DeleteUpdateState :exec +DELETE FROM update_states +WHERE auth_key_id = $1 + AND user_id = $2; + +-- name: DeleteUpdateStatesByAuthKey :exec +DELETE FROM update_states +WHERE auth_key_id = $1; diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql new file mode 100644 index 00000000..c7f68c41 --- /dev/null +++ b/internal/store/postgres/queries/user.sql @@ -0,0 +1,104 @@ +-- name: GetUserByID :one +SELECT * FROM users WHERE id = $1; + +-- name: GetUsersByIDs :many +SELECT * +FROM users +WHERE id = ANY(sqlc.arg(ids)::bigint[]) +ORDER BY id; + +-- name: GetUserByPhone :one +SELECT * FROM users WHERE phone = $1; + +-- name: GetUsersByPhones :many +SELECT * +FROM users +WHERE phone = ANY(sqlc.arg(phones)::text[]) +ORDER BY id; + +-- name: GetUserByUsername :one +SELECT * FROM users WHERE lower(username) = lower($1) AND username <> ''; + +-- name: SearchUsers :many +WITH matched AS ( + SELECT + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.about, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + (c.contact_user_id IS NOT NULL)::boolean AS contact, + COALESCE(c.mutual, false)::boolean AS mutual, + CASE + WHEN sqlc.arg(phone_query)::text <> '' AND u.phone = sqlc.arg(phone_query)::text THEN 0 + WHEN lower(u.username) = sqlc.arg(query_lower)::text THEN 1 + WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = sqlc.arg(query_lower)::text THEN 2 + WHEN lower(u.first_name) = sqlc.arg(query_lower)::text THEN 3 + WHEN c.contact_user_id IS NOT NULL THEN 4 + ELSE 5 + END AS rank + FROM users u + LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id + WHERE u.id <> sqlc.arg(current_user_id)::bigint + AND sqlc.arg(query_lower)::text <> '' + AND ( + (sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%') + OR lower(u.username) LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(u.first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(u.last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(c.contact_first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(c.contact_last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + ) +) +SELECT + id, + access_hash, + phone, + first_name, + last_name, + about, + username, + country_code, + verified, + support, + last_seen_at, + contact, + mutual +FROM matched +ORDER BY contact DESC, rank, id +LIMIT sqlc.arg(limit_count); + +-- name: CreateUser :one +INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: UpdateUserUsername :one +UPDATE users +SET username = $2, + updated_at = now() +WHERE id = $1 +RETURNING *; + +-- name: UpdateUserLastSeen :exec +UPDATE users +SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint), + updated_at = now() +WHERE id = sqlc.arg(id)::bigint; + +-- name: UpdateUserProfile :one +UPDATE users +SET first_name = $2, + last_name = $3, + about = $4, + updated_at = now() +WHERE id = $1 +RETURNING *; diff --git a/internal/store/postgres/queries/user_update_event.sql b/internal/store/postgres/queries/user_update_event.sql new file mode 100644 index 00000000..824c4a3b --- /dev/null +++ b/internal/store/postgres/queries/user_update_event.sql @@ -0,0 +1,464 @@ +-- name: AppendUserUpdateEvent :exec +INSERT INTO user_update_events ( + user_id, + pts, + pts_count, + date, + event_type, + event_bool, + event_peers, + peer_settings, + message_ids, + dialog_filter, + filter_order, + folder_peers, + message_box_id, + peer_type, + peer_id, + filter_id, + max_id, + still_unread_count, + tags_enabled +) VALUES ( + $1, + $2, + $3, + $4, + $5, + sqlc.arg(event_bool)::boolean, + sqlc.arg(event_peers)::jsonb, + sqlc.arg(peer_settings)::jsonb, + sqlc.arg(message_ids)::jsonb, + sqlc.arg(dialog_filter)::jsonb, + sqlc.arg(filter_order)::jsonb, + sqlc.arg(folder_peers)::jsonb, + sqlc.narg(message_box_id), + sqlc.narg(peer_type)::text, + sqlc.narg(peer_id)::bigint, + sqlc.arg(filter_id)::int, + sqlc.arg(max_id)::int, + sqlc.arg(still_unread_count)::int, + sqlc.arg(tags_enabled)::boolean +) +ON CONFLICT (user_id, pts) DO NOTHING; + +-- name: ListUserUpdateEventsAfter :many +SELECT + e.user_id, + e.pts, + e.pts_count, + e.date, + e.event_type, + e.event_bool, + COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, + COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, + COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, + COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json, + COALESCE(e.filter_order::text, '[]')::text AS filter_order_json, + COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, + COALESCE(e.peer_type, '')::text AS event_peer_type, + COALESCE(e.peer_id, 0)::bigint AS event_peer_id, + e.filter_id, + e.max_id, + e.still_unread_count, + e.tags_enabled, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.private_message_id, 0)::bigint AS private_message_id, + COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id, + COALESCE(m.peer_type, '')::text AS peer_type, + COALESCE(m.peer_id, 0)::bigint AS peer_id, + COALESCE(m.from_user_id, 0)::bigint AS from_user_id, + COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.edit_date, 0)::int AS edit_date, + COALESCE(m.outgoing, false)::boolean AS outgoing, + COALESCE(m.body, '')::text AS body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + COALESCE(m.silent, false)::boolean AS silent, + COALESCE(m.noforwards, false)::boolean AS noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_id, + COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, + COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id, + COALESCE(m.quote_text, '')::text AS quote_text, + COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, + COALESCE(m.quote_offset, 0)::int AS quote_offset, + COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, + COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.media::text, '{}')::text AS media_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, + COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash, + COALESCE(fwd_u.phone, '')::text AS fwd_user_phone, + COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name, + COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name, + COALESCE(fwd_u.username, '')::text AS fwd_user_username, + COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code, + COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified, + COALESCE(fwd_u.support, false)::boolean AS fwd_user_support, + COALESCE(reply_u.id, 0)::bigint AS reply_user_id, + COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash, + COALESCE(reply_u.phone, '')::text AS reply_user_phone, + COALESCE(reply_u.first_name, '')::text AS reply_user_first_name, + COALESCE(reply_u.last_name, '')::text AS reply_user_last_name, + COALESCE(reply_u.username, '')::text AS reply_user_username, + COALESCE(reply_u.country_code, '')::text AS reply_user_country_code, + COALESCE(reply_u.verified, false)::boolean AS reply_user_verified, + COALESCE(reply_u.support, false)::boolean AS reply_user_support, + COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id, + COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash, + COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id, + COALESCE(fwd_ch.title, '')::text AS fwd_channel_title, + COALESCE(fwd_ch.about, '')::text AS fwd_channel_about, + COALESCE(fwd_ch.username, '')::text AS fwd_channel_username, + COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast, + COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup, + COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum, + COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards, + COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures, + COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden, + COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds, + COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights, + COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count, + COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count, + COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count, + COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count, + COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id, + COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id, + COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts, + COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period, + COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date, + COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted, + COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id, + COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash, + COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id, + COALESCE(reply_ch.title, '')::text AS reply_channel_title, + COALESCE(reply_ch.about, '')::text AS reply_channel_about, + COALESCE(reply_ch.username, '')::text AS reply_channel_username, + COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast, + COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup, + COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum, + COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards, + COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures, + COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden, + COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds, + COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights, + COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count, + COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count, + COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count, + COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count, + COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id, + COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id, + COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts, + COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period, + COALESCE(reply_ch.date, 0)::int AS reply_channel_date, + COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted +FROM user_update_events e +LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id +LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id +LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id +LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id +WHERE e.user_id = $1 + AND e.pts > $2 +ORDER BY e.pts ASC +LIMIT sqlc.arg(limit_count); + +-- name: MaxUserPts :one +SELECT COALESCE(MAX(pts), 0)::int AS max_pts +FROM user_update_events +WHERE user_id = $1; + +-- name: RecentUserPts :many +-- 取某 user 最近的一段 pts(降序),供计算「最大连续已提交 pts」用。 +-- 只看顶部窗口:瞬时空洞只可能出现在最近在途事务区,窗口足够大即可覆盖其下方连续。 +SELECT pts, pts_count +FROM user_update_events +WHERE user_id = $1 +ORDER BY pts DESC +LIMIT sqlc.arg(window_size); + +-- name: EnsureUserUpdateWatermark :exec +INSERT INTO user_update_watermarks (user_id, contiguous_pts) +VALUES ($1, 0) +ON CONFLICT (user_id) DO NOTHING; + +-- name: GetUserUpdateWatermark :one +SELECT contiguous_pts +FROM user_update_watermarks +WHERE user_id = $1; + +-- name: LockUserUpdateWatermark :one +SELECT contiguous_pts +FROM user_update_watermarks +WHERE user_id = $1 +FOR UPDATE; + +-- name: NextUserPtsAfter :many +SELECT pts, pts_count +FROM user_update_events +WHERE user_id = $1 + AND pts > $2 +ORDER BY pts ASC +LIMIT sqlc.arg(limit_count); + +-- name: SaveUserUpdateWatermark :exec +INSERT INTO user_update_watermarks (user_id, contiguous_pts, updated_at) +VALUES ($1, $2, now()) +ON CONFLICT (user_id) DO UPDATE SET + contiguous_pts = GREATEST(user_update_watermarks.contiguous_pts, EXCLUDED.contiguous_pts), + updated_at = now(); + +-- name: EnqueueDispatch :exec +INSERT INTO dispatch_outbox ( + target_user_id, + pts, + event_type, + exclude_auth_key_id, + exclude_session_id +) VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT DO NOTHING; + +-- name: ClaimDispatchOutbox :many +WITH picked AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE ( + status = 'pending' + AND next_attempt_at <= now() + ) + OR ( + status = 'dispatching' + AND updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int) + ) + ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC + LIMIT sqlc.arg(limit_count) + FOR UPDATE SKIP LOCKED +) +UPDATE dispatch_outbox d +SET + status = 'dispatching', + attempts = d.attempts + 1, + updated_at = now() +FROM picked p +WHERE d.target_user_id = p.target_user_id + AND d.id = p.id +RETURNING + d.id, + d.target_user_id, + d.pts, + d.event_type, + d.exclude_auth_key_id, + d.exclude_session_id, + d.attempts; + +-- name: MarkDispatchDelivered :exec +-- 方案 A:投递成功即删除。outbox 是任务队列,delivered 行无保留价值 +-- (消息在 message_boxes、离线补偿在 user_update_events),删除让表维持「未完成任务」小稳态。 +DELETE FROM dispatch_outbox +WHERE target_user_id = $1 + AND id = $2; + +-- name: MarkDispatchFailed :exec +UPDATE dispatch_outbox +SET + status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END, + next_attempt_at = CASE + WHEN attempts >= 5 THEN next_attempt_at + ELSE now() + make_interval(secs => LEAST(60, attempts * attempts)) + END, + last_error = $3, + updated_at = now() +WHERE target_user_id = $1 + AND id = $2; + +-- name: BatchListDispatchEvents :many +-- 按 (user_id, pts) 精确批量取账号事件,供 outbox worker 一次性加载一批 claim 的事件详情, +-- 取代逐条 ListUserUpdateEventsAfter。列与 ListUserUpdateEventsAfter 完全一致以复用转换逻辑。 +SELECT + e.user_id, + e.pts, + e.pts_count, + e.date, + e.event_type, + e.event_bool, + COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, + COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, + COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, + COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json, + COALESCE(e.filter_order::text, '[]')::text AS filter_order_json, + COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, + COALESCE(e.peer_type, '')::text AS event_peer_type, + COALESCE(e.peer_id, 0)::bigint AS event_peer_id, + e.filter_id, + e.max_id, + e.still_unread_count, + e.tags_enabled, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.private_message_id, 0)::bigint AS private_message_id, + COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id, + COALESCE(m.peer_type, '')::text AS peer_type, + COALESCE(m.peer_id, 0)::bigint AS peer_id, + COALESCE(m.from_user_id, 0)::bigint AS from_user_id, + COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.edit_date, 0)::int AS edit_date, + COALESCE(m.outgoing, false)::boolean AS outgoing, + COALESCE(m.body, '')::text AS body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + COALESCE(m.silent, false)::boolean AS silent, + COALESCE(m.noforwards, false)::boolean AS noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_id, + COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, + COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id, + COALESCE(m.quote_text, '')::text AS quote_text, + COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, + COALESCE(m.quote_offset, 0)::int AS quote_offset, + COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, + COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.media::text, '{}')::text AS media_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, + COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash, + COALESCE(fwd_u.phone, '')::text AS fwd_user_phone, + COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name, + COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name, + COALESCE(fwd_u.username, '')::text AS fwd_user_username, + COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code, + COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified, + COALESCE(fwd_u.support, false)::boolean AS fwd_user_support, + COALESCE(reply_u.id, 0)::bigint AS reply_user_id, + COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash, + COALESCE(reply_u.phone, '')::text AS reply_user_phone, + COALESCE(reply_u.first_name, '')::text AS reply_user_first_name, + COALESCE(reply_u.last_name, '')::text AS reply_user_last_name, + COALESCE(reply_u.username, '')::text AS reply_user_username, + COALESCE(reply_u.country_code, '')::text AS reply_user_country_code, + COALESCE(reply_u.verified, false)::boolean AS reply_user_verified, + COALESCE(reply_u.support, false)::boolean AS reply_user_support, + COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id, + COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash, + COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id, + COALESCE(fwd_ch.title, '')::text AS fwd_channel_title, + COALESCE(fwd_ch.about, '')::text AS fwd_channel_about, + COALESCE(fwd_ch.username, '')::text AS fwd_channel_username, + COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast, + COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup, + COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum, + COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards, + COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures, + COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden, + COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds, + COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights, + COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count, + COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count, + COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count, + COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count, + COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id, + COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id, + COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts, + COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period, + COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date, + COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted, + COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id, + COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash, + COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id, + COALESCE(reply_ch.title, '')::text AS reply_channel_title, + COALESCE(reply_ch.about, '')::text AS reply_channel_about, + COALESCE(reply_ch.username, '')::text AS reply_channel_username, + COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast, + COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup, + COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum, + COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards, + COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures, + COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden, + COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds, + COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights, + COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count, + COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count, + COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count, + COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count, + COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id, + COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id, + COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts, + COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period, + COALESCE(reply_ch.date, 0)::int AS reply_channel_date, + COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted +FROM unnest(@user_ids::bigint[]) WITH ORDINALITY AS u(user_id, ord) +JOIN unnest(@pts_list::int[]) WITH ORDINALITY AS p(pts, ord) USING (ord) +JOIN user_update_events e ON e.user_id = u.user_id AND e.pts = p.pts +LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id +LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id +LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id +LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id; + +-- name: MarkDispatchDeliveredBatch :exec +-- 批量删除一批已投递的 (target_user_id, id);target_user_id 入 WHERE 保证分区裁剪。 +DELETE FROM dispatch_outbox d +USING unnest(@target_user_ids::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord) +JOIN unnest(@ids::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord) +WHERE d.target_user_id = tu.target_user_id + AND d.id = di.id; + +-- name: DeleteFailedDispatchOutbox :one +WITH doomed AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE status = 'failed' + AND updated_at < now() - make_interval(secs => sqlc.arg(older_than_seconds)::int) + ORDER BY updated_at ASC, target_user_id ASC, id ASC + LIMIT sqlc.arg(limit_count) +), +deleted AS ( + DELETE FROM dispatch_outbox d + USING doomed x + WHERE d.target_user_id = x.target_user_id + AND d.id = x.id + RETURNING d.id +) +SELECT count(*)::int AS deleted_count +FROM deleted; diff --git a/internal/store/postgres/sqlcgen/account.sql.go b/internal/store/postgres/sqlcgen/account.sql.go new file mode 100644 index 00000000..71a4610d --- /dev/null +++ b/internal/store/postgres/sqlcgen/account.sql.go @@ -0,0 +1,87 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: account.sql + +package sqlcgen + +import ( + "context" +) + +const getPasswordByUser = `-- name: GetPasswordByUser :one +SELECT + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random +FROM account_passwords +WHERE user_id = $1 +` + +type GetPasswordByUserRow struct { + UserID int64 + HasRecovery bool + HasSecureValues bool + HasPassword bool + Hint string + EmailUnconfirmedPattern string + LoginEmailPattern string + SecureRandom []byte +} + +func (q *Queries) GetPasswordByUser(ctx context.Context, userID int64) (GetPasswordByUserRow, error) { + row := q.db.QueryRow(ctx, getPasswordByUser, userID) + var i GetPasswordByUserRow + err := row.Scan( + &i.UserID, + &i.HasRecovery, + &i.HasSecureValues, + &i.HasPassword, + &i.Hint, + &i.EmailUnconfirmedPattern, + &i.LoginEmailPattern, + &i.SecureRandom, + ) + return i, err +} + +const upsertPassword = `-- name: UpsertPassword :exec +INSERT INTO account_passwords ( + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (user_id) DO UPDATE SET + has_recovery = EXCLUDED.has_recovery, + has_secure_values = EXCLUDED.has_secure_values, + has_password = EXCLUDED.has_password, + hint = EXCLUDED.hint, + email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern, + login_email_pattern = EXCLUDED.login_email_pattern, + secure_random = EXCLUDED.secure_random, + updated_at = now() +` + +type UpsertPasswordParams struct { + UserID int64 + HasRecovery bool + HasSecureValues bool + HasPassword bool + Hint string + EmailUnconfirmedPattern string + LoginEmailPattern string + SecureRandom []byte +} + +func (q *Queries) UpsertPassword(ctx context.Context, arg UpsertPasswordParams) error { + _, err := q.db.Exec(ctx, upsertPassword, + arg.UserID, + arg.HasRecovery, + arg.HasSecureValues, + arg.HasPassword, + arg.Hint, + arg.EmailUnconfirmedPattern, + arg.LoginEmailPattern, + arg.SecureRandom, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/authkey.sql.go b/internal/store/postgres/sqlcgen/authkey.sql.go new file mode 100644 index 00000000..b3ea11d4 --- /dev/null +++ b/internal/store/postgres/sqlcgen/authkey.sql.go @@ -0,0 +1,46 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: authkey.sql + +package sqlcgen + +import ( + "context" +) + +const getAuthKey = `-- name: GetAuthKey :one +SELECT auth_key_id, body, server_salt, created_at +FROM auth_keys +WHERE auth_key_id = $1 +` + +func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (AuthKey, error) { + row := q.db.QueryRow(ctx, getAuthKey, authKeyID) + var i AuthKey + err := row.Scan( + &i.AuthKeyID, + &i.Body, + &i.ServerSalt, + &i.CreatedAt, + ) + return i, err +} + +const upsertAuthKey = `-- name: UpsertAuthKey :exec +INSERT INTO auth_keys (auth_key_id, body, server_salt) +VALUES ($1, $2, $3) +ON CONFLICT (auth_key_id) DO UPDATE +SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt +` + +type UpsertAuthKeyParams struct { + AuthKeyID int64 + Body []byte + ServerSalt int64 +} + +func (q *Queries) UpsertAuthKey(ctx context.Context, arg UpsertAuthKeyParams) error { + _, err := q.db.Exec(ctx, upsertAuthKey, arg.AuthKeyID, arg.Body, arg.ServerSalt) + return err +} diff --git a/internal/store/postgres/sqlcgen/authorization.sql.go b/internal/store/postgres/sqlcgen/authorization.sql.go new file mode 100644 index 00000000..9739ea5f --- /dev/null +++ b/internal/store/postgres/sqlcgen/authorization.sql.go @@ -0,0 +1,124 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: authorization.sql + +package sqlcgen + +import ( + "context" +) + +const deleteAuthorization = `-- name: DeleteAuthorization :exec +DELETE FROM authorizations WHERE auth_key_id = $1 +` + +func (q *Queries) DeleteAuthorization(ctx context.Context, authKeyID int64) error { + _, err := q.db.Exec(ctx, deleteAuthorization, authKeyID) + return err +} + +const getAuthorizationByAuthKey = `-- name: GetAuthorizationByAuthKey :one +SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at FROM authorizations WHERE auth_key_id = $1 +` + +func (q *Queries) GetAuthorizationByAuthKey(ctx context.Context, authKeyID int64) (Authorization, error) { + row := q.db.QueryRow(ctx, getAuthorizationByAuthKey, authKeyID) + var i Authorization + err := row.Scan( + &i.AuthKeyID, + &i.UserID, + &i.Hash, + &i.Layer, + &i.DeviceModel, + &i.Platform, + &i.SystemVersion, + &i.ApiID, + &i.AppVersion, + &i.Ip, + &i.CreatedAt, + &i.ActiveAt, + ) + return i, err +} + +const listAuthorizationsByUser = `-- name: ListAuthorizationsByUser :many +SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at FROM authorizations +WHERE user_id = $1 +ORDER BY active_at DESC, auth_key_id DESC +` + +func (q *Queries) ListAuthorizationsByUser(ctx context.Context, userID int64) ([]Authorization, error) { + rows, err := q.db.Query(ctx, listAuthorizationsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Authorization + for rows.Next() { + var i Authorization + if err := rows.Scan( + &i.AuthKeyID, + &i.UserID, + &i.Hash, + &i.Layer, + &i.DeviceModel, + &i.Platform, + &i.SystemVersion, + &i.ApiID, + &i.AppVersion, + &i.Ip, + &i.CreatedAt, + &i.ActiveAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertAuthorization = `-- name: UpsertAuthorization :exec +INSERT INTO authorizations (auth_key_id, user_id, layer, device_model, platform, system_version, api_id, app_version, ip) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (auth_key_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + layer = EXCLUDED.layer, + device_model = EXCLUDED.device_model, + platform = EXCLUDED.platform, + system_version = EXCLUDED.system_version, + api_id = EXCLUDED.api_id, + app_version = EXCLUDED.app_version, + ip = EXCLUDED.ip, + active_at = now() +` + +type UpsertAuthorizationParams struct { + AuthKeyID int64 + UserID int64 + Layer int32 + DeviceModel string + Platform string + SystemVersion string + ApiID int32 + AppVersion string + Ip string +} + +func (q *Queries) UpsertAuthorization(ctx context.Context, arg UpsertAuthorizationParams) error { + _, err := q.db.Exec(ctx, upsertAuthorization, + arg.AuthKeyID, + arg.UserID, + arg.Layer, + arg.DeviceModel, + arg.Platform, + arg.SystemVersion, + arg.ApiID, + arg.AppVersion, + arg.Ip, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/contact.sql.go b/internal/store/postgres/sqlcgen/contact.sql.go new file mode 100644 index 00000000..ca02eadc --- /dev/null +++ b/internal/store/postgres/sqlcgen/contact.sql.go @@ -0,0 +1,426 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: contact.sql + +package sqlcgen + +import ( + "context" +) + +const deleteContacts = `-- name: DeleteContacts :one +WITH deleted AS ( + DELETE FROM contacts + WHERE user_id = $1::bigint + AND contact_user_id = ANY($2::bigint[]) + RETURNING contact_user_id +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = false, + updated_at = now() + FROM deleted d + WHERE c.user_id = d.contact_user_id + AND c.contact_user_id = $1::bigint + RETURNING c.user_id +) +SELECT COUNT(*)::int AS deleted_count +FROM deleted +` + +type DeleteContactsParams struct { + UserID int64 + ContactUserIds []int64 +} + +func (q *Queries) DeleteContacts(ctx context.Context, arg DeleteContactsParams) (int32, error) { + row := q.db.QueryRow(ctx, deleteContacts, arg.UserID, arg.ContactUserIds) + var deleted_count int32 + err := row.Scan(&deleted_count) + return deleted_count, err +} + +const getContact = `-- name: GetContact :one +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 + AND c.contact_user_id = $2 +` + +type GetContactParams struct { + UserID int64 + ContactUserID int64 +} + +type GetContactRow struct { + ContactUserID int64 + Mutual bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + LastSeenAt int64 +} + +func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) { + row := q.db.QueryRow(ctx, getContact, arg.UserID, arg.ContactUserID) + var i GetContactRow + err := row.Scan( + &i.ContactUserID, + &i.Mutual, + &i.ContactPhone, + &i.ContactFirstName, + &i.ContactLastName, + &i.Note, + &i.NoteEntitiesJson, + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + ) + return i, err +} + +const listContactsByUser = `-- name: ListContactsByUser :many +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 +ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u.id +` + +type ListContactsByUserRow struct { + ContactUserID int64 + Mutual bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + LastSeenAt int64 +} + +func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) { + rows, err := q.db.Query(ctx, listContactsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListContactsByUserRow + for rows.Next() { + var i ListContactsByUserRow + if err := rows.Scan( + &i.ContactUserID, + &i.Mutual, + &i.ContactPhone, + &i.ContactFirstName, + &i.ContactLastName, + &i.Note, + &i.NoteEntitiesJson, + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateContactNote = `-- name: UpdateContactNote :one +WITH updated AS ( + UPDATE contacts c + SET note = $1::text, + note_entities = $2::jsonb, + updated_at = now() + WHERE c.user_id = $3::bigint + AND c.contact_user_id = $4::bigint + RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM updated c +JOIN users u ON u.id = c.contact_user_id +` + +type UpdateContactNoteParams struct { + Note string + NoteEntities []byte + UserID int64 + ContactUserID int64 +} + +type UpdateContactNoteRow struct { + ContactUserID int64 + Mutual bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + LastSeenAt int64 +} + +func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) { + row := q.db.QueryRow(ctx, updateContactNote, + arg.Note, + arg.NoteEntities, + arg.UserID, + arg.ContactUserID, + ) + var i UpdateContactNoteRow + err := row.Scan( + &i.ContactUserID, + &i.Mutual, + &i.ContactPhone, + &i.ContactFirstName, + &i.ContactLastName, + &i.Note, + &i.NoteEntitiesJson, + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + ) + return i, err +} + +const upsertContact = `-- name: UpsertContact :one +WITH reverse AS ( + SELECT EXISTS ( + SELECT 1 + FROM contacts + WHERE user_id = $1::bigint + AND contact_user_id = $2::bigint + )::boolean AS mutual +), +upserted AS ( + INSERT INTO contacts ( + user_id, + contact_user_id, + contact_phone, + contact_first_name, + contact_last_name, + note, + note_entities, + mutual + ) + SELECT + $2::bigint, + $1::bigint, + $3::text, + $4::text, + $5::text, + $6::text, + $7::jsonb, + reverse.mutual + FROM reverse + ON CONFLICT (user_id, contact_user_id) DO UPDATE SET + contact_phone = EXCLUDED.contact_phone, + contact_first_name = EXCLUDED.contact_first_name, + contact_last_name = EXCLUDED.contact_last_name, + note = EXCLUDED.note, + note_entities = EXCLUDED.note_entities, + mutual = contacts.mutual OR EXCLUDED.mutual, + updated_at = now() + RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = true, + updated_at = now() + WHERE c.user_id = $1::bigint + AND c.contact_user_id = $2::bigint + AND NOT c.mutual + RETURNING c.user_id +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed +FROM upserted c +JOIN users u ON u.id = c.contact_user_id +` + +type UpsertContactParams struct { + ContactUserID int64 + UserID int64 + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntities []byte +} + +type UpsertContactRow struct { + ContactUserID int64 + Mutual bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + LastSeenAt int64 + ReverseMutualChanged bool +} + +func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) { + row := q.db.QueryRow(ctx, upsertContact, + arg.ContactUserID, + arg.UserID, + arg.ContactPhone, + arg.ContactFirstName, + arg.ContactLastName, + arg.Note, + arg.NoteEntities, + ) + var i UpsertContactRow + err := row.Scan( + &i.ContactUserID, + &i.Mutual, + &i.ContactPhone, + &i.ContactFirstName, + &i.ContactLastName, + &i.Note, + &i.NoteEntitiesJson, + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + &i.ReverseMutualChanged, + ) + return i, err +} diff --git a/internal/store/postgres/sqlcgen/db.go b/internal/store/postgres/sqlcgen/db.go new file mode 100644 index 00000000..38811fcc --- /dev/null +++ b/internal/store/postgres/sqlcgen/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcgen + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/postgres/sqlcgen/dialog.sql.go b/internal/store/postgres/sqlcgen/dialog.sql.go new file mode 100644 index 00000000..a557f0c2 --- /dev/null +++ b/internal/store/postgres/sqlcgen/dialog.sql.go @@ -0,0 +1,1592 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dialog.sql + +package sqlcgen + +import ( + "context" +) + +const clearDialogAfterHistoryDelete = `-- name: ClearDialogAfterHistoryDelete :exec +UPDATE dialogs d +SET + top_message_id = 0, + top_message_date = 0, + read_inbox_max_id = GREATEST(d.read_inbox_max_id, d.top_message_id), + read_outbox_max_id = GREATEST(d.read_outbox_max_id, d.top_message_id), + unread_count = 0, + unread_mark = false, + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +WHERE d.user_id = $1::bigint + AND d.peer_type = $2::text + AND d.peer_id = $3::bigint +` + +type ClearDialogAfterHistoryDeleteParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) ClearDialogAfterHistoryDelete(ctx context.Context, arg ClearDialogAfterHistoryDeleteParams) error { + _, err := q.db.Exec(ctx, clearDialogAfterHistoryDelete, arg.UserID, arg.PeerType, arg.PeerID) + return err +} + +const clearDialogDrafts = `-- name: ClearDialogDrafts :many +WITH doomed AS ( + SELECT d.user_id, d.peer_type, d.peer_id, d.top_message_id + FROM dialog_drafts d + WHERE d.user_id = $1 + ORDER BY d.date DESC, d.peer_type ASC, d.peer_id DESC, d.top_message_id DESC + LIMIT $2 +), +deleted AS ( + DELETE FROM dialog_drafts d + USING doomed + WHERE d.user_id = doomed.user_id + AND d.peer_type = doomed.peer_type + AND d.peer_id = doomed.peer_id + AND d.top_message_id = doomed.top_message_id + RETURNING d.draft::text AS draft_json +) +SELECT draft_json +FROM deleted +` + +type ClearDialogDraftsParams struct { + UserID int64 + LimitCount int32 +} + +func (q *Queries) ClearDialogDrafts(ctx context.Context, arg ClearDialogDraftsParams) ([]string, error) { + rows, err := q.db.Query(ctx, clearDialogDrafts, arg.UserID, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var draft_json string + if err := rows.Scan(&draft_json); err != nil { + return nil, err + } + items = append(items, draft_json) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const clearPinnedDialogsNotInOrder = `-- name: ClearPinnedDialogsNotInOrder :exec +WITH requested AS ( + SELECT + ($2::text[])[i] AS peer_type, + ($3::bigint[])[i] AS peer_id + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) +) +UPDATE dialogs d +SET pinned = false, + pinned_order = 0, + updated_at = now() +WHERE d.user_id = $1::bigint + AND d.pinned + AND NOT EXISTS ( + SELECT 1 + FROM requested r + WHERE r.peer_type = d.peer_type + AND r.peer_id = d.peer_id + ) +` + +type ClearPinnedDialogsNotInOrderParams struct { + UserID int64 + PeerTypes []string + PeerIds []int64 +} + +func (q *Queries) ClearPinnedDialogsNotInOrder(ctx context.Context, arg ClearPinnedDialogsNotInOrderParams) error { + _, err := q.db.Exec(ctx, clearPinnedDialogsNotInOrder, arg.UserID, arg.PeerTypes, arg.PeerIds) + return err +} + +const deleteDialogByPeer = `-- name: DeleteDialogByPeer :exec +DELETE FROM dialogs +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 +` + +type DeleteDialogByPeerParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteDialogByPeer(ctx context.Context, arg DeleteDialogByPeerParams) error { + _, err := q.db.Exec(ctx, deleteDialogByPeer, arg.UserID, arg.PeerType, arg.PeerID) + return err +} + +const deleteDialogDraft = `-- name: DeleteDialogDraft :one +WITH deleted AS ( + DELETE FROM dialog_drafts + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND top_message_id = $4 + RETURNING user_id +) +SELECT EXISTS (SELECT 1 FROM deleted)::boolean AS changed +` + +type DeleteDialogDraftParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 +} + +func (q *Queries) DeleteDialogDraft(ctx context.Context, arg DeleteDialogDraftParams) (bool, error) { + row := q.db.QueryRow(ctx, deleteDialogDraft, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + ) + var changed bool + err := row.Scan(&changed) + return changed, err +} + +const deleteDialogFolder = `-- name: DeleteDialogFolder :exec +DELETE FROM dialog_filters +WHERE user_id = $1 + AND filter_id = $2 +` + +type DeleteDialogFolderParams struct { + UserID int64 + FilterID int32 +} + +func (q *Queries) DeleteDialogFolder(ctx context.Context, arg DeleteDialogFolderParams) error { + _, err := q.db.Exec(ctx, deleteDialogFolder, arg.UserID, arg.FilterID) + return err +} + +const editDialogPeerFolders = `-- name: EditDialogPeerFolders :exec +WITH requested AS ( + SELECT + ($2::text[])[i] AS peer_type, + ($3::bigint[])[i] AS peer_id, + ($4::int[])[i] AS folder_id + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) + AND i <= cardinality($4::int[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + folder_id + FROM requested + WHERE folder_id IN (0, 1) + ORDER BY peer_type, peer_id +) +UPDATE dialogs d +SET folder_id = deduped.folder_id, + updated_at = now() +FROM deduped +WHERE d.user_id = $1::bigint + AND d.peer_type = deduped.peer_type + AND d.peer_id = deduped.peer_id +` + +type EditDialogPeerFoldersParams struct { + UserID int64 + PeerTypes []string + PeerIds []int64 + FolderIds []int32 +} + +func (q *Queries) EditDialogPeerFolders(ctx context.Context, arg EditDialogPeerFoldersParams) error { + _, err := q.db.Exec(ctx, editDialogPeerFolders, + arg.UserID, + arg.PeerTypes, + arg.PeerIds, + arg.FolderIds, + ) + return err +} + +const getDialogFolder = `-- name: GetDialogFolder :one +SELECT + filter_id, + is_chatlist, + filter::text AS filter_json +FROM dialog_filters +WHERE user_id = $1 + AND filter_id = $2 +` + +type GetDialogFolderParams struct { + UserID int64 + FilterID int32 +} + +type GetDialogFolderRow struct { + FilterID int32 + IsChatlist bool + FilterJson string +} + +func (q *Queries) GetDialogFolder(ctx context.Context, arg GetDialogFolderParams) (GetDialogFolderRow, error) { + row := q.db.QueryRow(ctx, getDialogFolder, arg.UserID, arg.FilterID) + var i GetDialogFolderRow + err := row.Scan(&i.FilterID, &i.IsChatlist, &i.FilterJson) + return i, err +} + +const getDialogFolderTags = `-- name: GetDialogFolderTags :one +SELECT tags_enabled +FROM dialog_filter_settings +WHERE user_id = $1 +` + +func (q *Queries) GetDialogFolderTags(ctx context.Context, userID int64) (bool, error) { + row := q.db.QueryRow(ctx, getDialogFolderTags, userID) + var tags_enabled bool + err := row.Scan(&tags_enabled) + return tags_enabled, err +} + +const getPeerSettingsBarHidden = `-- name: GetPeerSettingsBarHidden :one +SELECT hidden_peer_settings_bar +FROM dialogs +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 +` + +type GetPeerSettingsBarHiddenParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) GetPeerSettingsBarHidden(ctx context.Context, arg GetPeerSettingsBarHiddenParams) (bool, error) { + row := q.db.QueryRow(ctx, getPeerSettingsBarHidden, arg.UserID, arg.PeerType, arg.PeerID) + var hidden_peer_settings_bar bool + err := row.Scan(&hidden_peer_settings_bar) + return hidden_peer_settings_bar, err +} + +const listDialogDrafts = `-- name: ListDialogDrafts :many +SELECT draft::text AS draft_json +FROM dialog_drafts +WHERE user_id = $1 +ORDER BY date DESC, peer_type ASC, peer_id DESC, top_message_id DESC +LIMIT $2 +` + +type ListDialogDraftsParams struct { + UserID int64 + LimitCount int32 +} + +func (q *Queries) ListDialogDrafts(ctx context.Context, arg ListDialogDraftsParams) ([]string, error) { + rows, err := q.db.Query(ctx, listDialogDrafts, arg.UserID, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var draft_json string + if err := rows.Scan(&draft_json); err != nil { + return nil, err + } + items = append(items, draft_json) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogFolders = `-- name: ListDialogFolders :many +SELECT + filter_id, + is_chatlist, + filter::text AS filter_json +FROM dialog_filters +WHERE user_id = $1 +ORDER BY order_value ASC, filter_id ASC +` + +type ListDialogFoldersRow struct { + FilterID int32 + IsChatlist bool + FilterJson string +} + +func (q *Queries) ListDialogFolders(ctx context.Context, userID int64) ([]ListDialogFoldersRow, error) { + rows, err := q.db.Query(ctx, listDialogFolders, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogFoldersRow + for rows.Next() { + var i ListDialogFoldersRow + if err := rows.Scan(&i.FilterID, &i.IsChatlist, &i.FilterJson); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogSummaryByUser = `-- name: ListDialogSummaryByUser :many +SELECT + d.peer_type, + d.peer_id, + d.folder_id, + d.top_message_id, + d.top_message_date, + d.read_inbox_max_id, + d.read_outbox_max_id, + d.unread_count, + d.unread_mentions_count, + d.unread_reactions_count, + d.pinned, + d.pinned_order, + d.unread_mark, + d.hidden_peer_settings_bar +FROM dialogs d +LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id +WHERE d.user_id = $1 + AND ( + NOT $2::boolean + OR ( + $3::int < 2 + AND d.folder_id = $3::int + ) + OR ( + $3::int >= 2 + AND NOT ($4::boolean AND d.folder_id = 1) + AND NOT ($5::boolean AND d.unread_count = 0 AND NOT d.unread_mark) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($6::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($7::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + AND ( + EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($8::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($9::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($10::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($11::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR ($12::boolean AND c.contact_user_id IS NOT NULL) + OR ($13::boolean AND c.contact_user_id IS NULL) + ) + ) + ) + AND (NOT $14::boolean OR d.pinned) + AND (NOT $15::boolean OR NOT d.pinned) +ORDER BY + d.pinned DESC, + CASE WHEN d.pinned THEN COALESCE(NULLIF(d.pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + d.top_message_date DESC, + d.top_message_id DESC, + d.peer_id DESC +` + +type ListDialogSummaryByUserParams struct { + UserID int64 + HasFolderID bool + FolderID int32 + FolderExcludeArchived bool + FolderExcludeRead bool + FolderExcludePeerTypes []string + FolderExcludePeerIds []int64 + FolderIncludePeerTypes []string + FolderIncludePeerIds []int64 + FolderPinnedPeerTypes []string + FolderPinnedPeerIds []int64 + FolderContacts bool + FolderNonContacts bool + PinnedOnly bool + ExcludePinned bool +} + +type ListDialogSummaryByUserRow struct { + PeerType string + PeerID int64 + FolderID int32 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + PinnedOrder int32 + UnreadMark bool + HiddenPeerSettingsBar bool +} + +func (q *Queries) ListDialogSummaryByUser(ctx context.Context, arg ListDialogSummaryByUserParams) ([]ListDialogSummaryByUserRow, error) { + rows, err := q.db.Query(ctx, listDialogSummaryByUser, + arg.UserID, + arg.HasFolderID, + arg.FolderID, + arg.FolderExcludeArchived, + arg.FolderExcludeRead, + arg.FolderExcludePeerTypes, + arg.FolderExcludePeerIds, + arg.FolderIncludePeerTypes, + arg.FolderIncludePeerIds, + arg.FolderPinnedPeerTypes, + arg.FolderPinnedPeerIds, + arg.FolderContacts, + arg.FolderNonContacts, + arg.PinnedOnly, + arg.ExcludePinned, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogSummaryByUserRow + for rows.Next() { + var i ListDialogSummaryByUserRow + if err := rows.Scan( + &i.PeerType, + &i.PeerID, + &i.FolderID, + &i.TopMessageID, + &i.TopMessageDate, + &i.ReadInboxMaxID, + &i.ReadOutboxMaxID, + &i.UnreadCount, + &i.UnreadMentionsCount, + &i.UnreadReactionsCount, + &i.Pinned, + &i.PinnedOrder, + &i.UnreadMark, + &i.HiddenPeerSettingsBar, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogUnreadMarks = `-- name: ListDialogUnreadMarks :many +SELECT + peer_type, + peer_id +FROM dialogs +WHERE user_id = $1 + AND unread_mark +ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC +` + +type ListDialogUnreadMarksRow struct { + PeerType string + PeerID int64 +} + +func (q *Queries) ListDialogUnreadMarks(ctx context.Context, userID int64) ([]ListDialogUnreadMarksRow, error) { + rows, err := q.db.Query(ctx, listDialogUnreadMarks, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogUnreadMarksRow + for rows.Next() { + var i ListDialogUnreadMarksRow + if err := rows.Scan(&i.PeerType, &i.PeerID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogsByPeers = `-- name: ListDialogsByPeers :many +WITH requested AS ( + SELECT + ($1::text[])[i] AS peer_type, + ($2::bigint[])[i] AS peer_id, + i::int AS ord + FROM generate_subscripts($2::bigint[], 1) AS g(i) + WHERE i <= cardinality($1::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + ord + FROM requested + ORDER BY peer_type, peer_id, ord +), +base AS ( + SELECT + $3::bigint AS user_id, + r.peer_type, + r.peer_id, + COALESCE(d.folder_id, 0)::int AS folder_id, + COALESCE(d.top_message_id, 0)::int AS top_message_id, + COALESCE(d.top_message_date, 0)::int AS top_message_date, + COALESCE(d.read_inbox_max_id, 0)::int AS read_inbox_max_id, + COALESCE(d.read_outbox_max_id, 0)::int AS read_outbox_max_id, + COALESCE(d.unread_count, 0)::int AS unread_count, + COALESCE(d.unread_mentions_count, 0)::int AS unread_mentions_count, + COALESCE(d.unread_reactions_count, 0)::int AS unread_reactions_count, + COALESCE(d.pinned, false)::boolean AS pinned, + COALESCE(d.pinned_order, 0)::int AS pinned_order, + COALESCE(d.unread_mark, false)::boolean AS unread_mark, + COALESCE(d.hidden_peer_settings_bar, false)::boolean AS hidden_peer_settings_bar, + COALESCE(u.id, 0)::bigint AS peer_user_id, + COALESCE(u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name, + COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name, + COALESCE(u.username, '')::text AS peer_username, + COALESCE(u.country_code, '')::text AS peer_country_code, + COALESCE(u.verified, false)::boolean AS peer_verified, + COALESCE(u.support, false)::boolean AS peer_support, + COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at, + (c.contact_user_id IS NOT NULL)::boolean AS peer_contact, + COALESCE(c.mutual, false)::boolean AS peer_mutual, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id, + COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.outgoing, false)::boolean AS message_outgoing, + COALESCE(m.body, '')::text AS message_body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + r.ord + FROM deduped r + LEFT JOIN dialogs d + ON d.user_id = $3::bigint + AND d.peer_type = r.peer_type + AND d.peer_id = r.peer_id + LEFT JOIN users u ON r.peer_type = 'user' AND u.id = r.peer_id + LEFT JOIN contacts c ON r.peer_type = 'user' AND c.user_id = $3::bigint AND c.contact_user_id = r.peer_id + LEFT JOIN message_boxes m ON m.owner_user_id = $3::bigint AND m.box_id = d.top_message_id AND NOT m.deleted +) +SELECT + user_id, + peer_type::text AS peer_type, + peer_id::bigint AS peer_id, + folder_id, + top_message_id, + top_message_date, + read_inbox_max_id, + read_outbox_max_id, + unread_count, + unread_mentions_count, + unread_reactions_count, + pinned, + pinned_order, + unread_mark, + hidden_peer_settings_bar, + peer_user_id, + peer_access_hash, + peer_phone, + peer_first_name, + peer_last_name, + peer_username, + peer_country_code, + peer_verified, + peer_support, + peer_last_seen_at, + peer_contact, + peer_mutual, + message_id, + message_from_user_id, + message_date, + message_outgoing, + message_body, + message_entities_json +FROM base +ORDER BY ord +` + +type ListDialogsByPeersParams struct { + PeerTypes []string + PeerIds []int64 + UserID int64 +} + +type ListDialogsByPeersRow struct { + UserID int64 + PeerType string + PeerID int64 + FolderID int32 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + PinnedOrder int32 + UnreadMark bool + HiddenPeerSettingsBar bool + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerLastSeenAt int64 + PeerContact bool + PeerMutual bool + MessageID int32 + MessageFromUserID int64 + MessageDate int32 + MessageOutgoing bool + MessageBody string + MessageEntitiesJson string +} + +func (q *Queries) ListDialogsByPeers(ctx context.Context, arg ListDialogsByPeersParams) ([]ListDialogsByPeersRow, error) { + rows, err := q.db.Query(ctx, listDialogsByPeers, arg.PeerTypes, arg.PeerIds, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogsByPeersRow + for rows.Next() { + var i ListDialogsByPeersRow + if err := rows.Scan( + &i.UserID, + &i.PeerType, + &i.PeerID, + &i.FolderID, + &i.TopMessageID, + &i.TopMessageDate, + &i.ReadInboxMaxID, + &i.ReadOutboxMaxID, + &i.UnreadCount, + &i.UnreadMentionsCount, + &i.UnreadReactionsCount, + &i.Pinned, + &i.PinnedOrder, + &i.UnreadMark, + &i.HiddenPeerSettingsBar, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerLastSeenAt, + &i.PeerContact, + &i.PeerMutual, + &i.MessageID, + &i.MessageFromUserID, + &i.MessageDate, + &i.MessageOutgoing, + &i.MessageBody, + &i.MessageEntitiesJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogsByUser = `-- name: ListDialogsByUser :many +WITH base AS ( + SELECT + d.user_id, + d.peer_type, + d.peer_id, + d.folder_id, + d.top_message_id, + d.top_message_date, + d.read_inbox_max_id, + d.read_outbox_max_id, + d.unread_count, + d.unread_mentions_count, + d.unread_reactions_count, + d.pinned, + d.pinned_order, + d.unread_mark, + d.hidden_peer_settings_bar, + COALESCE(u.id, 0)::bigint AS peer_user_id, + COALESCE(u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name, + COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name, + COALESCE(u.username, '')::text AS peer_username, + COALESCE(u.country_code, '')::text AS peer_country_code, + COALESCE(u.verified, false)::boolean AS peer_verified, + COALESCE(u.support, false)::boolean AS peer_support, + COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at, + (c.contact_user_id IS NOT NULL)::boolean AS peer_contact, + COALESCE(c.mutual, false)::boolean AS peer_mutual, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id, + COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.outgoing, false)::boolean AS message_outgoing, + COALESCE(m.body, '')::text AS message_body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json + FROM dialogs d + LEFT JOIN users u ON d.peer_type = 'user' AND u.id = d.peer_id + LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id + LEFT JOIN message_boxes m ON m.owner_user_id = d.user_id AND m.box_id = d.top_message_id AND NOT m.deleted + WHERE d.user_id = $1 + AND ( + NOT $3::boolean + OR ( + $4::int < 2 + AND d.folder_id = $4::int + ) + OR ( + $4::int >= 2 + AND NOT ($5::boolean AND d.folder_id = 1) + AND NOT ($6::boolean AND d.unread_count = 0 AND NOT d.unread_mark) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($7::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($8::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + AND ( + EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($9::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($10::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($11::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($12::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR ($13::boolean AND c.contact_user_id IS NOT NULL) + OR ($14::boolean AND c.contact_user_id IS NULL) + ) + ) + ) + AND (NOT $15::boolean OR d.pinned) + AND (NOT $16::boolean OR NOT d.pinned) +), +paged AS ( + SELECT user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, pinned, pinned_order, unread_mark, hidden_peer_settings_bar, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, peer_contact, peer_mutual, message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json + FROM base + WHERE ( + ($17::int <= 0 AND $18::int <= 0) + OR ( + $17::int > 0 + AND ( + top_message_date < $17::int + OR ( + top_message_date = $17::int + AND ( + $18::int <= 0 + OR top_message_id < $18::int + OR ( + top_message_id = $18::int + AND $19::boolean + AND peer_id < $20::bigint + ) + ) + ) + ) + ) + OR ( + $17::int <= 0 + AND $18::int > 0 + AND ( + top_message_id < $18::int + OR ( + top_message_id = $18::int + AND $19::boolean + AND peer_id < $20::bigint + ) + ) + ) + ) +) +SELECT + user_id, + peer_type::text AS peer_type, + peer_id::bigint AS peer_id, + folder_id, + top_message_id, + top_message_date, + read_inbox_max_id, + read_outbox_max_id, + unread_count, + unread_mentions_count, + unread_reactions_count, + pinned, + pinned_order, + unread_mark, + hidden_peer_settings_bar, + peer_user_id, + peer_access_hash, + peer_phone, + peer_first_name, + peer_last_name, + peer_username, + peer_country_code, + peer_verified, + peer_support, + peer_last_seen_at, + peer_contact, + peer_mutual, + message_id, + message_from_user_id, + message_date, + message_outgoing, + message_body, + message_entities_json +FROM paged +ORDER BY + pinned DESC, + CASE WHEN pinned THEN COALESCE(NULLIF(pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + top_message_date DESC, + top_message_id DESC, + peer_id DESC +LIMIT $2 +` + +type ListDialogsByUserParams struct { + UserID int64 + LimitCount int32 + HasFolderID bool + FolderID int32 + FolderExcludeArchived bool + FolderExcludeRead bool + FolderExcludePeerTypes []string + FolderExcludePeerIds []int64 + FolderIncludePeerTypes []string + FolderIncludePeerIds []int64 + FolderPinnedPeerTypes []string + FolderPinnedPeerIds []int64 + FolderContacts bool + FolderNonContacts bool + PinnedOnly bool + ExcludePinned bool + OffsetDate int32 + OffsetID int32 + HasOffsetPeer bool + OffsetPeerID int64 +} + +type ListDialogsByUserRow struct { + UserID int64 + PeerType string + PeerID int64 + FolderID int32 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + PinnedOrder int32 + UnreadMark bool + HiddenPeerSettingsBar bool + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerLastSeenAt int64 + PeerContact bool + PeerMutual bool + MessageID int32 + MessageFromUserID int64 + MessageDate int32 + MessageOutgoing bool + MessageBody string + MessageEntitiesJson string +} + +func (q *Queries) ListDialogsByUser(ctx context.Context, arg ListDialogsByUserParams) ([]ListDialogsByUserRow, error) { + rows, err := q.db.Query(ctx, listDialogsByUser, + arg.UserID, + arg.LimitCount, + arg.HasFolderID, + arg.FolderID, + arg.FolderExcludeArchived, + arg.FolderExcludeRead, + arg.FolderExcludePeerTypes, + arg.FolderExcludePeerIds, + arg.FolderIncludePeerTypes, + arg.FolderIncludePeerIds, + arg.FolderPinnedPeerTypes, + arg.FolderPinnedPeerIds, + arg.FolderContacts, + arg.FolderNonContacts, + arg.PinnedOnly, + arg.ExcludePinned, + arg.OffsetDate, + arg.OffsetID, + arg.HasOffsetPeer, + arg.OffsetPeerID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogsByUserRow + for rows.Next() { + var i ListDialogsByUserRow + if err := rows.Scan( + &i.UserID, + &i.PeerType, + &i.PeerID, + &i.FolderID, + &i.TopMessageID, + &i.TopMessageDate, + &i.ReadInboxMaxID, + &i.ReadOutboxMaxID, + &i.UnreadCount, + &i.UnreadMentionsCount, + &i.UnreadReactionsCount, + &i.Pinned, + &i.PinnedOrder, + &i.UnreadMark, + &i.HiddenPeerSettingsBar, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerLastSeenAt, + &i.PeerContact, + &i.PeerMutual, + &i.MessageID, + &i.MessageFromUserID, + &i.MessageDate, + &i.MessageOutgoing, + &i.MessageBody, + &i.MessageEntitiesJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markDialogRead = `-- name: MarkDialogRead :one +WITH target AS ( + SELECT + d.user_id, + d.peer_type, + d.peer_id, + d.top_message_id, + d.read_inbox_max_id, + d.unread_count + FROM dialogs d + WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3 +), +updated AS ( +UPDATE dialogs d +SET + read_inbox_max_id = GREATEST( + d.read_inbox_max_id, + CASE WHEN $4::int > 0 THEN $4::int ELSE d.top_message_id END + ), + unread_count = 0, + unread_mark = false, + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +FROM target +WHERE d.user_id = target.user_id + AND d.peer_type = target.peer_type + AND d.peer_id = target.peer_id +RETURNING + d.user_id, + d.peer_type, + d.peer_id, + d.read_inbox_max_id, + d.unread_count, + ( + target.unread_count > 0 + OR ( + CASE WHEN $4::int > 0 THEN $4::int ELSE target.top_message_id END + ) > target.read_inbox_max_id + )::boolean AS changed +) +SELECT + user_id, + peer_type, + peer_id, + read_inbox_max_id, + unread_count, + changed +FROM updated +` + +type MarkDialogReadParams struct { + UserID int64 + PeerType string + PeerID int64 + MaxID int32 +} + +type MarkDialogReadRow struct { + UserID int64 + PeerType string + PeerID int64 + ReadInboxMaxID int32 + UnreadCount int32 + Changed bool +} + +func (q *Queries) MarkDialogRead(ctx context.Context, arg MarkDialogReadParams) (MarkDialogReadRow, error) { + row := q.db.QueryRow(ctx, markDialogRead, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.MaxID, + ) + var i MarkDialogReadRow + err := row.Scan( + &i.UserID, + &i.PeerType, + &i.PeerID, + &i.ReadInboxMaxID, + &i.UnreadCount, + &i.Changed, + ) + return i, err +} + +const refreshDialogAfterMessageDelete = `-- name: RefreshDialogAfterMessageDelete :exec +UPDATE dialogs d +SET + top_message_id = $1::int, + top_message_date = $2::int, + unread_count = ( + SELECT COUNT(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND NOT m.outgoing + AND m.box_id > d.read_inbox_max_id + ), + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +WHERE d.user_id = $3::bigint + AND d.peer_type = $4::text + AND d.peer_id = $5::bigint +` + +type RefreshDialogAfterMessageDeleteParams struct { + TopMessageID int32 + TopMessageDate int32 + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) RefreshDialogAfterMessageDelete(ctx context.Context, arg RefreshDialogAfterMessageDeleteParams) error { + _, err := q.db.Exec(ctx, refreshDialogAfterMessageDelete, + arg.TopMessageID, + arg.TopMessageDate, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + return err +} + +const reorderDialogFolders = `-- name: ReorderDialogFolders :exec +WITH requested AS ( + SELECT filter_id, ord::int AS order_value + FROM unnest($2::int[]) WITH ORDINALITY AS t(filter_id, ord) +), +deduped AS ( + SELECT DISTINCT ON (filter_id) + filter_id, + order_value + FROM requested + WHERE filter_id >= 2 + ORDER BY filter_id, order_value +) +UPDATE dialog_filters f +SET order_value = deduped.order_value, + updated_at = now() +FROM deduped +WHERE f.user_id = $1::bigint + AND f.filter_id = deduped.filter_id +` + +type ReorderDialogFoldersParams struct { + UserID int64 + FilterIds []int32 +} + +func (q *Queries) ReorderDialogFolders(ctx context.Context, arg ReorderDialogFoldersParams) error { + _, err := q.db.Exec(ctx, reorderDialogFolders, arg.UserID, arg.FilterIds) + return err +} + +const reorderPinnedDialogs = `-- name: ReorderPinnedDialogs :exec +WITH requested AS ( + SELECT + ($2::text[])[i] AS peer_type, + ($3::bigint[])[i] AS peer_id, + i::int AS pos + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + pos::int AS ord + FROM requested + ORDER BY peer_type, peer_id, pos +) +UPDATE dialogs d +SET pinned = true, + pinned_order = deduped.ord, + updated_at = now() +FROM deduped +WHERE d.user_id = $1::bigint + AND d.peer_type = deduped.peer_type + AND d.peer_id = deduped.peer_id +` + +type ReorderPinnedDialogsParams struct { + UserID int64 + PeerTypes []string + PeerIds []int64 +} + +func (q *Queries) ReorderPinnedDialogs(ctx context.Context, arg ReorderPinnedDialogsParams) error { + _, err := q.db.Exec(ctx, reorderPinnedDialogs, arg.UserID, arg.PeerTypes, arg.PeerIds) + return err +} + +const setDialogFolderTags = `-- name: SetDialogFolderTags :exec +INSERT INTO dialog_filter_settings ( + user_id, + tags_enabled +) VALUES ( + $1, + $2 +) +ON CONFLICT (user_id) DO UPDATE SET + tags_enabled = EXCLUDED.tags_enabled, + updated_at = now() +` + +type SetDialogFolderTagsParams struct { + UserID int64 + TagsEnabled bool +} + +func (q *Queries) SetDialogFolderTags(ctx context.Context, arg SetDialogFolderTagsParams) error { + _, err := q.db.Exec(ctx, setDialogFolderTags, arg.UserID, arg.TagsEnabled) + return err +} + +const setDialogPinned = `-- name: SetDialogPinned :one +WITH next_order AS ( + SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value + FROM dialogs + WHERE user_id = $1::bigint + AND pinned +), +updated AS ( + UPDATE dialogs d + SET pinned = $2::boolean, + pinned_order = CASE + WHEN $2::boolean THEN + CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END + ELSE 0 + END, + updated_at = now() + FROM next_order + WHERE d.user_id = $1::bigint + AND d.peer_type = $3::text + AND d.peer_id = $4::bigint + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed +` + +type SetDialogPinnedParams struct { + UserID int64 + Pinned bool + PeerType string + PeerID int64 +} + +func (q *Queries) SetDialogPinned(ctx context.Context, arg SetDialogPinnedParams) (bool, error) { + row := q.db.QueryRow(ctx, setDialogPinned, + arg.UserID, + arg.Pinned, + arg.PeerType, + arg.PeerID, + ) + var changed bool + err := row.Scan(&changed) + return changed, err +} + +const setDialogUnreadMark = `-- name: SetDialogUnreadMark :one +WITH updated AS ( + UPDATE dialogs d + SET unread_mark = $1::boolean, + updated_at = now() + WHERE d.user_id = $2::bigint + AND d.peer_type = $3::text + AND d.peer_id = $4::bigint + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed +` + +type SetDialogUnreadMarkParams struct { + Unread bool + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) SetDialogUnreadMark(ctx context.Context, arg SetDialogUnreadMarkParams) (bool, error) { + row := q.db.QueryRow(ctx, setDialogUnreadMark, + arg.Unread, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + var changed bool + err := row.Scan(&changed) + return changed, err +} + +const setPeerSettingsBarHidden = `-- name: SetPeerSettingsBarHidden :one +WITH updated AS ( + UPDATE dialogs d + SET hidden_peer_settings_bar = true, + updated_at = now() + WHERE d.user_id = $1::bigint + AND d.peer_type = $2::text + AND d.peer_id = $3::bigint + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed +` + +type SetPeerSettingsBarHiddenParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) SetPeerSettingsBarHidden(ctx context.Context, arg SetPeerSettingsBarHiddenParams) (bool, error) { + row := q.db.QueryRow(ctx, setPeerSettingsBarHidden, arg.UserID, arg.PeerType, arg.PeerID) + var changed bool + err := row.Scan(&changed) + return changed, err +} + +const upsertDialog = `-- name: UpsertDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + read_inbox_max_id, + read_outbox_max_id, + unread_count, + unread_mentions_count, + unread_reactions_count, + pinned, + unread_mark +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 +) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + read_inbox_max_id = EXCLUDED.read_inbox_max_id, + read_outbox_max_id = EXCLUDED.read_outbox_max_id, + unread_count = EXCLUDED.unread_count, + unread_mentions_count = EXCLUDED.unread_mentions_count, + unread_reactions_count = EXCLUDED.unread_reactions_count, + pinned = EXCLUDED.pinned, + unread_mark = EXCLUDED.unread_mark, + updated_at = now() +` + +type UpsertDialogParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + UnreadMark bool +} + +func (q *Queries) UpsertDialog(ctx context.Context, arg UpsertDialogParams) error { + _, err := q.db.Exec(ctx, upsertDialog, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + arg.TopMessageDate, + arg.ReadInboxMaxID, + arg.ReadOutboxMaxID, + arg.UnreadCount, + arg.UnreadMentionsCount, + arg.UnreadReactionsCount, + arg.Pinned, + arg.UnreadMark, + ) + return err +} + +const upsertDialogDraft = `-- name: UpsertDialogDraft :exec +INSERT INTO dialog_drafts ( + user_id, + peer_type, + peer_id, + top_message_id, + date, + draft +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6::jsonb +) +ON CONFLICT (user_id, peer_type, peer_id, top_message_id) DO UPDATE SET + date = EXCLUDED.date, + draft = EXCLUDED.draft, + updated_at = now() +` + +type UpsertDialogDraftParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + Date int32 + DraftJson []byte +} + +func (q *Queries) UpsertDialogDraft(ctx context.Context, arg UpsertDialogDraftParams) error { + _, err := q.db.Exec(ctx, upsertDialogDraft, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + arg.Date, + arg.DraftJson, + ) + return err +} + +const upsertDialogFolder = `-- name: UpsertDialogFolder :exec +INSERT INTO dialog_filters ( + user_id, + filter_id, + is_chatlist, + filter, + order_value +) VALUES ( + $1, + $2, + $3, + $4::jsonb, + COALESCE( + (SELECT order_value FROM dialog_filters WHERE user_id = $1 AND filter_id = $2), + (SELECT COALESCE(MAX(order_value), 0) + 1 FROM dialog_filters WHERE user_id = $1) + ) +) +ON CONFLICT (user_id, filter_id) DO UPDATE SET + is_chatlist = EXCLUDED.is_chatlist, + filter = EXCLUDED.filter, + updated_at = now() +` + +type UpsertDialogFolderParams struct { + UserID int64 + FilterID int32 + IsChatlist bool + FilterJson []byte +} + +func (q *Queries) UpsertDialogFolder(ctx context.Context, arg UpsertDialogFolderParams) error { + _, err := q.db.Exec(ctx, upsertDialogFolder, + arg.UserID, + arg.FilterID, + arg.IsChatlist, + arg.FilterJson, + ) + return err +} + +const upsertInboxDialog = `-- name: UpsertInboxDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + unread_count +) VALUES ( + $1, $2, $3, $4, $5, 1 +) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + unread_count = dialogs.unread_count + 1, + updated_at = now() +` + +type UpsertInboxDialogParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + TopMessageDate int32 +} + +func (q *Queries) UpsertInboxDialog(ctx context.Context, arg UpsertInboxDialogParams) error { + _, err := q.db.Exec(ctx, upsertInboxDialog, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + arg.TopMessageDate, + ) + return err +} + +const upsertOutboxDialog = `-- name: UpsertOutboxDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + unread_count +) VALUES ( + $1, $2, $3, $4, $5, 0 +) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + updated_at = now() +` + +type UpsertOutboxDialogParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + TopMessageDate int32 +} + +func (q *Queries) UpsertOutboxDialog(ctx context.Context, arg UpsertOutboxDialogParams) error { + _, err := q.db.Exec(ctx, upsertOutboxDialog, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + arg.TopMessageDate, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/help.sql.go b/internal/store/postgres/sqlcgen/help.sql.go new file mode 100644 index 00000000..09def2fc --- /dev/null +++ b/internal/store/postgres/sqlcgen/help.sql.go @@ -0,0 +1,159 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: help.sql + +package sqlcgen + +import ( + "context" +) + +const getAppConfig = `-- name: GetAppConfig :one +SELECT client, hash, config_json::text AS config_json +FROM app_configs +WHERE client = $1 +` + +type GetAppConfigRow struct { + Client string + Hash int32 + ConfigJson string +} + +func (q *Queries) GetAppConfig(ctx context.Context, client string) (GetAppConfigRow, error) { + row := q.db.QueryRow(ctx, getAppConfig, client) + var i GetAppConfigRow + err := row.Scan(&i.Client, &i.Hash, &i.ConfigJson) + return i, err +} + +const listCountries = `-- name: ListCountries :many +SELECT + c.iso2, + c.default_name, + c.name, + c.hidden, + cc.country_code, + cc.prefixes, + cc.patterns +FROM countries c +JOIN country_codes cc ON cc.iso2 = c.iso2 +ORDER BY c.order_index, c.iso2, cc.order_index, cc.country_code +` + +type ListCountriesRow struct { + Iso2 string + DefaultName string + Name string + Hidden bool + CountryCode string + Prefixes []string + Patterns []string +} + +func (q *Queries) ListCountries(ctx context.Context) ([]ListCountriesRow, error) { + rows, err := q.db.Query(ctx, listCountries) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCountriesRow + for rows.Next() { + var i ListCountriesRow + if err := rows.Scan( + &i.Iso2, + &i.DefaultName, + &i.Name, + &i.Hidden, + &i.CountryCode, + &i.Prefixes, + &i.Patterns, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertAppConfig = `-- name: UpsertAppConfig :exec +INSERT INTO app_configs (client, hash, config_json) +VALUES ($1, $2, $3::jsonb) +ON CONFLICT (client) DO UPDATE SET + hash = EXCLUDED.hash, + config_json = EXCLUDED.config_json, + updated_at = now() +` + +type UpsertAppConfigParams struct { + Client string + Hash int32 + ConfigJson []byte +} + +func (q *Queries) UpsertAppConfig(ctx context.Context, arg UpsertAppConfigParams) error { + _, err := q.db.Exec(ctx, upsertAppConfig, arg.Client, arg.Hash, arg.ConfigJson) + return err +} + +const upsertCountry = `-- name: UpsertCountry :exec +INSERT INTO countries (iso2, default_name, name, hidden, order_index) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (iso2) DO UPDATE SET + default_name = EXCLUDED.default_name, + name = EXCLUDED.name, + hidden = EXCLUDED.hidden, + order_index = EXCLUDED.order_index, + updated_at = now() +` + +type UpsertCountryParams struct { + Iso2 string + DefaultName string + Name string + Hidden bool + OrderIndex int32 +} + +func (q *Queries) UpsertCountry(ctx context.Context, arg UpsertCountryParams) error { + _, err := q.db.Exec(ctx, upsertCountry, + arg.Iso2, + arg.DefaultName, + arg.Name, + arg.Hidden, + arg.OrderIndex, + ) + return err +} + +const upsertCountryCode = `-- name: UpsertCountryCode :exec +INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (iso2, country_code) DO UPDATE SET + prefixes = EXCLUDED.prefixes, + patterns = EXCLUDED.patterns, + order_index = EXCLUDED.order_index +` + +type UpsertCountryCodeParams struct { + Iso2 string + CountryCode string + Prefixes []string + Patterns []string + OrderIndex int32 +} + +func (q *Queries) UpsertCountryCode(ctx context.Context, arg UpsertCountryCodeParams) error { + _, err := q.db.Exec(ctx, upsertCountryCode, + arg.Iso2, + arg.CountryCode, + arg.Prefixes, + arg.Patterns, + arg.OrderIndex, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/langpack.sql.go b/internal/store/postgres/sqlcgen/langpack.sql.go new file mode 100644 index 00000000..5839429a --- /dev/null +++ b/internal/store/postgres/sqlcgen/langpack.sql.go @@ -0,0 +1,250 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: langpack.sql + +package sqlcgen + +import ( + "context" +) + +const getLangPackMeta = `-- name: GetLangPackMeta :one +SELECT lang_pack, lang_code, version, strings_count +FROM lang_packs +WHERE lang_pack = $1 AND lang_code = $2 +` + +type GetLangPackMetaParams struct { + LangPack string + LangCode string +} + +type GetLangPackMetaRow struct { + LangPack string + LangCode string + Version int32 + StringsCount int32 +} + +func (q *Queries) GetLangPackMeta(ctx context.Context, arg GetLangPackMetaParams) (GetLangPackMetaRow, error) { + row := q.db.QueryRow(ctx, getLangPackMeta, arg.LangPack, arg.LangCode) + var i GetLangPackMetaRow + err := row.Scan( + &i.LangPack, + &i.LangCode, + &i.Version, + &i.StringsCount, + ) + return i, err +} + +const getLangPackStringsByKeys = `-- name: GetLangPackStringsByKeys :many +SELECT + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +FROM lang_pack_strings +WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY($3::text[]) AND NOT deleted +ORDER BY key +` + +type GetLangPackStringsByKeysParams struct { + LangPack string + LangCode string + Keys []string +} + +type GetLangPackStringsByKeysRow struct { + LangPack string + LangCode string + Key string + Version int32 + Pluralized bool + Value string + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool +} + +func (q *Queries) GetLangPackStringsByKeys(ctx context.Context, arg GetLangPackStringsByKeysParams) ([]GetLangPackStringsByKeysRow, error) { + rows, err := q.db.Query(ctx, getLangPackStringsByKeys, arg.LangPack, arg.LangCode, arg.Keys) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetLangPackStringsByKeysRow + for rows.Next() { + var i GetLangPackStringsByKeysRow + if err := rows.Scan( + &i.LangPack, + &i.LangCode, + &i.Key, + &i.Version, + &i.Pluralized, + &i.Value, + &i.ZeroValue, + &i.OneValue, + &i.TwoValue, + &i.FewValue, + &i.ManyValue, + &i.OtherValue, + &i.Deleted, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLangPackStrings = `-- name: ListLangPackStrings :many +SELECT + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +FROM lang_pack_strings +WHERE lang_pack = $1 AND lang_code = $2 AND NOT deleted +ORDER BY key +` + +type ListLangPackStringsParams struct { + LangPack string + LangCode string +} + +type ListLangPackStringsRow struct { + LangPack string + LangCode string + Key string + Version int32 + Pluralized bool + Value string + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool +} + +func (q *Queries) ListLangPackStrings(ctx context.Context, arg ListLangPackStringsParams) ([]ListLangPackStringsRow, error) { + rows, err := q.db.Query(ctx, listLangPackStrings, arg.LangPack, arg.LangCode) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLangPackStringsRow + for rows.Next() { + var i ListLangPackStringsRow + if err := rows.Scan( + &i.LangPack, + &i.LangCode, + &i.Key, + &i.Version, + &i.Pluralized, + &i.Value, + &i.ZeroValue, + &i.OneValue, + &i.TwoValue, + &i.FewValue, + &i.ManyValue, + &i.OtherValue, + &i.Deleted, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertLangPackMeta = `-- name: UpsertLangPackMeta :exec +INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count) +VALUES ($1, $2, $3, $4) +ON CONFLICT (lang_pack, lang_code) DO UPDATE SET + version = EXCLUDED.version, + strings_count = EXCLUDED.strings_count, + updated_at = now() +` + +type UpsertLangPackMetaParams struct { + LangPack string + LangCode string + Version int32 + StringsCount int32 +} + +func (q *Queries) UpsertLangPackMeta(ctx context.Context, arg UpsertLangPackMetaParams) error { + _, err := q.db.Exec(ctx, upsertLangPackMeta, + arg.LangPack, + arg.LangCode, + arg.Version, + arg.StringsCount, + ) + return err +} + +const upsertLangPackString = `-- name: UpsertLangPackString :exec +INSERT INTO lang_pack_strings ( + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET + version = EXCLUDED.version, + pluralized = EXCLUDED.pluralized, + value = EXCLUDED.value, + zero_value = EXCLUDED.zero_value, + one_value = EXCLUDED.one_value, + two_value = EXCLUDED.two_value, + few_value = EXCLUDED.few_value, + many_value = EXCLUDED.many_value, + other_value = EXCLUDED.other_value, + deleted = EXCLUDED.deleted, + updated_at = now() +` + +type UpsertLangPackStringParams struct { + LangPack string + LangCode string + Key string + Version int32 + Pluralized bool + Value string + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool +} + +func (q *Queries) UpsertLangPackString(ctx context.Context, arg UpsertLangPackStringParams) error { + _, err := q.db.Exec(ctx, upsertLangPackString, + arg.LangPack, + arg.LangCode, + arg.Key, + arg.Version, + arg.Pluralized, + arg.Value, + arg.ZeroValue, + arg.OneValue, + arg.TwoValue, + arg.FewValue, + arg.ManyValue, + arg.OtherValue, + arg.Deleted, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/media.sql.go b/internal/store/postgres/sqlcgen/media.sql.go new file mode 100644 index 00000000..26393db0 --- /dev/null +++ b/internal/store/postgres/sqlcgen/media.sql.go @@ -0,0 +1,1154 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: media.sql + +package sqlcgen + +import ( + "context" +) + +const addProfilePhoto = `-- name: AddProfilePhoto :exec + +INSERT INTO profile_photos (owner_peer_type, owner_peer_id, photo_id, date, active, sort_order) +VALUES ( + $1::text, + $2::bigint, + $3::bigint, + $4::int, + true, + $5::bigint +) +ON CONFLICT (owner_peer_type, owner_peer_id, photo_id) DO UPDATE SET + date = EXCLUDED.date, + active = true, + sort_order = EXCLUDED.sort_order +` + +type AddProfilePhotoParams struct { + OwnerPeerType string + OwnerPeerID int64 + PhotoID int64 + Date int32 + SortOrder int64 +} + +// profile_photos -------------------------------------------------------------- +func (q *Queries) AddProfilePhoto(ctx context.Context, arg AddProfilePhotoParams) error { + _, err := q.db.Exec(ctx, addProfilePhoto, + arg.OwnerPeerType, + arg.OwnerPeerID, + arg.PhotoID, + arg.Date, + arg.SortOrder, + ) + return err +} + +const countAvailableReactions = `-- name: CountAvailableReactions :one +SELECT count(*)::int AS total FROM available_reactions +` + +func (q *Queries) CountAvailableReactions(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, countAvailableReactions) + var total int32 + err := row.Scan(&total) + return total, err +} + +const countProfilePhotos = `-- name: CountProfilePhotos :one +SELECT count(*)::int AS total +FROM profile_photos +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint + AND active +` + +type CountProfilePhotosParams struct { + OwnerPeerType string + OwnerPeerID int64 +} + +func (q *Queries) CountProfilePhotos(ctx context.Context, arg CountProfilePhotosParams) (int32, error) { + row := q.db.QueryRow(ctx, countProfilePhotos, arg.OwnerPeerType, arg.OwnerPeerID) + var total int32 + err := row.Scan(&total) + return total, err +} + +const countStickerSets = `-- name: CountStickerSets :one +SELECT count(*)::int AS total FROM sticker_sets +` + +func (q *Queries) CountStickerSets(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, countStickerSets) + var total int32 + err := row.Scan(&total) + return total, err +} + +const currentProfilePhoto = `-- name: CurrentProfilePhoto :one +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint + AND active +ORDER BY sort_order DESC +LIMIT 1 +` + +type CurrentProfilePhotoParams struct { + OwnerPeerType string + OwnerPeerID int64 +} + +func (q *Queries) CurrentProfilePhoto(ctx context.Context, arg CurrentProfilePhotoParams) (int64, error) { + row := q.db.QueryRow(ctx, currentProfilePhoto, arg.OwnerPeerType, arg.OwnerPeerID) + var photo_id int64 + err := row.Scan(&photo_id) + return photo_id, err +} + +const currentProfilePhotosForOwners = `-- name: CurrentProfilePhotosForOwners :many +SELECT DISTINCT ON (pp.owner_peer_id) + pp.owner_peer_id, + pp.photo_id, + ph.dc_id, + ph.sizes::text AS sizes_json +FROM profile_photos pp +JOIN photos ph ON ph.id = pp.photo_id +WHERE pp.owner_peer_type = $1::text + AND pp.owner_peer_id = ANY($2::bigint[]) + AND pp.active +ORDER BY pp.owner_peer_id, pp.sort_order DESC +` + +type CurrentProfilePhotosForOwnersParams struct { + OwnerPeerType string + OwnerIds []int64 +} + +type CurrentProfilePhotosForOwnersRow struct { + OwnerPeerID int64 + PhotoID int64 + DcID int32 + SizesJson string +} + +func (q *Queries) CurrentProfilePhotosForOwners(ctx context.Context, arg CurrentProfilePhotosForOwnersParams) ([]CurrentProfilePhotosForOwnersRow, error) { + rows, err := q.db.Query(ctx, currentProfilePhotosForOwners, arg.OwnerPeerType, arg.OwnerIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CurrentProfilePhotosForOwnersRow + for rows.Next() { + var i CurrentProfilePhotosForOwnersRow + if err := rows.Scan( + &i.OwnerPeerID, + &i.PhotoID, + &i.DcID, + &i.SizesJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deactivateProfilePhotos = `-- name: DeactivateProfilePhotos :many +UPDATE profile_photos +SET active = false +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint + AND photo_id = ANY($3::bigint[]) + AND active +RETURNING photo_id +` + +type DeactivateProfilePhotosParams struct { + OwnerPeerType string + OwnerPeerID int64 + PhotoIds []int64 +} + +func (q *Queries) DeactivateProfilePhotos(ctx context.Context, arg DeactivateProfilePhotosParams) ([]int64, error) { + rows, err := q.db.Query(ctx, deactivateProfilePhotos, arg.OwnerPeerType, arg.OwnerPeerID, arg.PhotoIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var photo_id int64 + if err := rows.Scan(&photo_id); err != nil { + return nil, err + } + items = append(items, photo_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteUploadParts = `-- name: DeleteUploadParts :exec +DELETE FROM upload_parts +WHERE owner_user_id = $1::bigint + AND file_id = $2::bigint +` + +type DeleteUploadPartsParams struct { + OwnerUserID int64 + FileID int64 +} + +func (q *Queries) DeleteUploadParts(ctx context.Context, arg DeleteUploadPartsParams) error { + _, err := q.db.Exec(ctx, deleteUploadParts, arg.OwnerUserID, arg.FileID) + return err +} + +const getDocument = `-- name: GetDocument :one +SELECT id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes::text AS attributes_json, + thumbs::text AS thumbs_json +FROM documents +WHERE id = $1::bigint +` + +type GetDocumentRow struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + MimeType string + Size int64 + DcID int32 + AttributesJson string + ThumbsJson string +} + +func (q *Queries) GetDocument(ctx context.Context, id int64) (GetDocumentRow, error) { + row := q.db.QueryRow(ctx, getDocument, id) + var i GetDocumentRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.FileReference, + &i.Date, + &i.MimeType, + &i.Size, + &i.DcID, + &i.AttributesJson, + &i.ThumbsJson, + ) + return i, err +} + +const getDocuments = `-- name: GetDocuments :many +SELECT id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes::text AS attributes_json, + thumbs::text AS thumbs_json +FROM documents +WHERE id = ANY($1::bigint[]) +` + +type GetDocumentsRow struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + MimeType string + Size int64 + DcID int32 + AttributesJson string + ThumbsJson string +} + +func (q *Queries) GetDocuments(ctx context.Context, ids []int64) ([]GetDocumentsRow, error) { + rows, err := q.db.Query(ctx, getDocuments, ids) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetDocumentsRow + for rows.Next() { + var i GetDocumentsRow + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.FileReference, + &i.Date, + &i.MimeType, + &i.Size, + &i.DcID, + &i.AttributesJson, + &i.ThumbsJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFileBlob = `-- name: GetFileBlob :one +SELECT location_key, backend, object_key, size, sha256, mime_type +FROM file_blobs +WHERE location_key = $1::text +` + +type GetFileBlobRow struct { + LocationKey string + Backend string + ObjectKey string + Size int64 + Sha256 []byte + MimeType string +} + +func (q *Queries) GetFileBlob(ctx context.Context, locationKey string) (GetFileBlobRow, error) { + row := q.db.QueryRow(ctx, getFileBlob, locationKey) + var i GetFileBlobRow + err := row.Scan( + &i.LocationKey, + &i.Backend, + &i.ObjectKey, + &i.Size, + &i.Sha256, + &i.MimeType, + ) + return i, err +} + +const getPhoto = `-- name: GetPhoto :one +SELECT id, access_hash, file_reference, date, dc_id, has_stickers, + sizes::text AS sizes_json +FROM photos +WHERE id = $1::bigint +` + +type GetPhotoRow struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + DcID int32 + HasStickers bool + SizesJson string +} + +func (q *Queries) GetPhoto(ctx context.Context, id int64) (GetPhotoRow, error) { + row := q.db.QueryRow(ctx, getPhoto, id) + var i GetPhotoRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.FileReference, + &i.Date, + &i.DcID, + &i.HasStickers, + &i.SizesJson, + ) + return i, err +} + +const getStickerSetByID = `-- name: GetStickerSetByID :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE id = $1::bigint +` + +type GetStickerSetByIDRow struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson string + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson string + PacksJson string + SortOrder int32 + SystemKey string +} + +func (q *Queries) GetStickerSetByID(ctx context.Context, id int64) (GetStickerSetByIDRow, error) { + row := q.db.QueryRow(ctx, getStickerSetByID, id) + var i GetStickerSetByIDRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.ShortName, + &i.Title, + &i.Count, + &i.Hash, + &i.SetKind, + &i.Official, + &i.Animated, + &i.Videos, + &i.Emojis, + &i.Masks, + &i.Installed, + &i.Archived, + &i.InstalledDate, + &i.ThumbDocumentID, + &i.ThumbsJson, + &i.ThumbDcID, + &i.ThumbVersion, + &i.DocumentIdsJson, + &i.PacksJson, + &i.SortOrder, + &i.SystemKey, + ) + return i, err +} + +const getStickerSetByShortName = `-- name: GetStickerSetByShortName :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE short_name = $1::text +` + +type GetStickerSetByShortNameRow struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson string + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson string + PacksJson string + SortOrder int32 + SystemKey string +} + +func (q *Queries) GetStickerSetByShortName(ctx context.Context, shortName string) (GetStickerSetByShortNameRow, error) { + row := q.db.QueryRow(ctx, getStickerSetByShortName, shortName) + var i GetStickerSetByShortNameRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.ShortName, + &i.Title, + &i.Count, + &i.Hash, + &i.SetKind, + &i.Official, + &i.Animated, + &i.Videos, + &i.Emojis, + &i.Masks, + &i.Installed, + &i.Archived, + &i.InstalledDate, + &i.ThumbDocumentID, + &i.ThumbsJson, + &i.ThumbDcID, + &i.ThumbVersion, + &i.DocumentIdsJson, + &i.PacksJson, + &i.SortOrder, + &i.SystemKey, + ) + return i, err +} + +const getStickerSetBySystemKey = `-- name: GetStickerSetBySystemKey :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE system_key = $1::text +` + +type GetStickerSetBySystemKeyRow struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson string + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson string + PacksJson string + SortOrder int32 + SystemKey string +} + +func (q *Queries) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (GetStickerSetBySystemKeyRow, error) { + row := q.db.QueryRow(ctx, getStickerSetBySystemKey, systemKey) + var i GetStickerSetBySystemKeyRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.ShortName, + &i.Title, + &i.Count, + &i.Hash, + &i.SetKind, + &i.Official, + &i.Animated, + &i.Videos, + &i.Emojis, + &i.Masks, + &i.Installed, + &i.Archived, + &i.InstalledDate, + &i.ThumbDocumentID, + &i.ThumbsJson, + &i.ThumbDcID, + &i.ThumbVersion, + &i.DocumentIdsJson, + &i.PacksJson, + &i.SortOrder, + &i.SystemKey, + ) + return i, err +} + +const listAvailableReactions = `-- name: ListAvailableReactions :many +SELECT + reaction, title, inactive, premium, + static_icon_id, appear_animation_id, select_animation_id, + activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order +FROM available_reactions +ORDER BY sort_order ASC, reaction ASC +` + +func (q *Queries) ListAvailableReactions(ctx context.Context) ([]AvailableReaction, error) { + rows, err := q.db.Query(ctx, listAvailableReactions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AvailableReaction + for rows.Next() { + var i AvailableReaction + if err := rows.Scan( + &i.Reaction, + &i.Title, + &i.Inactive, + &i.Premium, + &i.StaticIconID, + &i.AppearAnimationID, + &i.SelectAnimationID, + &i.ActivateAnimationID, + &i.EffectAnimationID, + &i.AroundAnimationID, + &i.CenterIconID, + &i.SortOrder, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProfilePhotos = `-- name: ListProfilePhotos :many +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint + AND active + AND ($3::bigint <= 0 OR photo_id < $3::bigint) +ORDER BY sort_order DESC +OFFSET $4::int +LIMIT $5::int +` + +type ListProfilePhotosParams struct { + OwnerPeerType string + OwnerPeerID int64 + MaxID int64 + OffsetCount int32 + LimitCount int32 +} + +func (q *Queries) ListProfilePhotos(ctx context.Context, arg ListProfilePhotosParams) ([]int64, error) { + rows, err := q.db.Query(ctx, listProfilePhotos, + arg.OwnerPeerType, + arg.OwnerPeerID, + arg.MaxID, + arg.OffsetCount, + arg.LimitCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var photo_id int64 + if err := rows.Scan(&photo_id); err != nil { + return nil, err + } + items = append(items, photo_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listStickerSetsByKind = `-- name: ListStickerSetsByKind :many +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE set_kind = $1::text +ORDER BY sort_order ASC, id ASC +` + +type ListStickerSetsByKindRow struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson string + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson string + PacksJson string + SortOrder int32 + SystemKey string +} + +func (q *Queries) ListStickerSetsByKind(ctx context.Context, setKind string) ([]ListStickerSetsByKindRow, error) { + rows, err := q.db.Query(ctx, listStickerSetsByKind, setKind) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListStickerSetsByKindRow + for rows.Next() { + var i ListStickerSetsByKindRow + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.ShortName, + &i.Title, + &i.Count, + &i.Hash, + &i.SetKind, + &i.Official, + &i.Animated, + &i.Videos, + &i.Emojis, + &i.Masks, + &i.Installed, + &i.Archived, + &i.InstalledDate, + &i.ThumbDocumentID, + &i.ThumbsJson, + &i.ThumbDcID, + &i.ThumbVersion, + &i.DocumentIdsJson, + &i.PacksJson, + &i.SortOrder, + &i.SystemKey, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUploadParts = `-- name: ListUploadParts :many +SELECT part, total_parts, is_big, bytes +FROM upload_parts +WHERE owner_user_id = $1::bigint + AND file_id = $2::bigint +ORDER BY part ASC +` + +type ListUploadPartsParams struct { + OwnerUserID int64 + FileID int64 +} + +type ListUploadPartsRow struct { + Part int32 + TotalParts int32 + IsBig bool + Bytes []byte +} + +func (q *Queries) ListUploadParts(ctx context.Context, arg ListUploadPartsParams) ([]ListUploadPartsRow, error) { + rows, err := q.db.Query(ctx, listUploadParts, arg.OwnerUserID, arg.FileID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUploadPartsRow + for rows.Next() { + var i ListUploadPartsRow + if err := rows.Scan( + &i.Part, + &i.TotalParts, + &i.IsBig, + &i.Bytes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const nextProfilePhotoOrder = `-- name: NextProfilePhotoOrder :one +SELECT COALESCE(MAX(sort_order), 0)::bigint AS max_order +FROM profile_photos +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint +` + +type NextProfilePhotoOrderParams struct { + OwnerPeerType string + OwnerPeerID int64 +} + +func (q *Queries) NextProfilePhotoOrder(ctx context.Context, arg NextProfilePhotoOrderParams) (int64, error) { + row := q.db.QueryRow(ctx, nextProfilePhotoOrder, arg.OwnerPeerType, arg.OwnerPeerID) + var max_order int64 + err := row.Scan(&max_order) + return max_order, err +} + +const putAvailableReaction = `-- name: PutAvailableReaction :exec + +INSERT INTO available_reactions ( + reaction, title, inactive, premium, + static_icon_id, appear_animation_id, select_animation_id, + activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order +) VALUES ( + $1::text, + $2::text, + $3::boolean, + $4::boolean, + $5::bigint, + $6::bigint, + $7::bigint, + $8::bigint, + $9::bigint, + $10::bigint, + $11::bigint, + $12::int +) +ON CONFLICT (reaction) DO UPDATE SET + title = EXCLUDED.title, + inactive = EXCLUDED.inactive, + premium = EXCLUDED.premium, + static_icon_id = EXCLUDED.static_icon_id, + appear_animation_id = EXCLUDED.appear_animation_id, + select_animation_id = EXCLUDED.select_animation_id, + activate_animation_id = EXCLUDED.activate_animation_id, + effect_animation_id = EXCLUDED.effect_animation_id, + around_animation_id = EXCLUDED.around_animation_id, + center_icon_id = EXCLUDED.center_icon_id, + sort_order = EXCLUDED.sort_order +` + +type PutAvailableReactionParams struct { + Reaction string + Title string + Inactive bool + Premium bool + StaticIconID int64 + AppearAnimationID int64 + SelectAnimationID int64 + ActivateAnimationID int64 + EffectAnimationID int64 + AroundAnimationID int64 + CenterIconID int64 + SortOrder int32 +} + +// available_reactions --------------------------------------------------------- +func (q *Queries) PutAvailableReaction(ctx context.Context, arg PutAvailableReactionParams) error { + _, err := q.db.Exec(ctx, putAvailableReaction, + arg.Reaction, + arg.Title, + arg.Inactive, + arg.Premium, + arg.StaticIconID, + arg.AppearAnimationID, + arg.SelectAnimationID, + arg.ActivateAnimationID, + arg.EffectAnimationID, + arg.AroundAnimationID, + arg.CenterIconID, + arg.SortOrder, + ) + return err +} + +const putDocument = `-- name: PutDocument :exec + +INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs) +VALUES ( + $1::bigint, + $2::bigint, + $3::bytea, + $4::int, + $5::text, + $6::bigint, + $7::int, + $8::jsonb, + $9::jsonb +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + file_reference = EXCLUDED.file_reference, + date = EXCLUDED.date, + mime_type = EXCLUDED.mime_type, + size = EXCLUDED.size, + dc_id = EXCLUDED.dc_id, + attributes = EXCLUDED.attributes, + thumbs = EXCLUDED.thumbs +` + +type PutDocumentParams struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + MimeType string + Size int64 + DcID int32 + AttributesJson []byte + ThumbsJson []byte +} + +// documents ------------------------------------------------------------------- +func (q *Queries) PutDocument(ctx context.Context, arg PutDocumentParams) error { + _, err := q.db.Exec(ctx, putDocument, + arg.ID, + arg.AccessHash, + arg.FileReference, + arg.Date, + arg.MimeType, + arg.Size, + arg.DcID, + arg.AttributesJson, + arg.ThumbsJson, + ) + return err +} + +const putFileBlob = `-- name: PutFileBlob :exec + +INSERT INTO file_blobs (location_key, backend, object_key, size, sha256, mime_type) +VALUES ( + $1::text, + $2::text, + $3::text, + $4::bigint, + $5::bytea, + $6::text +) +ON CONFLICT (location_key) DO UPDATE SET + backend = EXCLUDED.backend, + object_key = EXCLUDED.object_key, + size = EXCLUDED.size, + sha256 = EXCLUDED.sha256, + mime_type = EXCLUDED.mime_type +` + +type PutFileBlobParams struct { + LocationKey string + Backend string + ObjectKey string + Size int64 + Sha256 []byte + MimeType string +} + +// file_blobs ------------------------------------------------------------------ +func (q *Queries) PutFileBlob(ctx context.Context, arg PutFileBlobParams) error { + _, err := q.db.Exec(ctx, putFileBlob, + arg.LocationKey, + arg.Backend, + arg.ObjectKey, + arg.Size, + arg.Sha256, + arg.MimeType, + ) + return err +} + +const putPhoto = `-- name: PutPhoto :exec + +INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes) +VALUES ( + $1::bigint, + $2::bigint, + $3::bytea, + $4::int, + $5::int, + $6::boolean, + $7::jsonb +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + file_reference = EXCLUDED.file_reference, + date = EXCLUDED.date, + dc_id = EXCLUDED.dc_id, + has_stickers = EXCLUDED.has_stickers, + sizes = EXCLUDED.sizes +` + +type PutPhotoParams struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + DcID int32 + HasStickers bool + SizesJson []byte +} + +// photos ---------------------------------------------------------------------- +func (q *Queries) PutPhoto(ctx context.Context, arg PutPhotoParams) error { + _, err := q.db.Exec(ctx, putPhoto, + arg.ID, + arg.AccessHash, + arg.FileReference, + arg.Date, + arg.DcID, + arg.HasStickers, + arg.SizesJson, + ) + return err +} + +const putStickerSet = `-- name: PutStickerSet :exec + +INSERT INTO sticker_sets ( + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs, thumb_dc_id, thumb_version, document_ids, packs, sort_order, system_key +) VALUES ( + $1::bigint, + $2::bigint, + $3::text, + $4::text, + $5::int, + $6::int, + $7::text, + $8::boolean, + $9::boolean, + $10::boolean, + $11::boolean, + $12::boolean, + $13::boolean, + $14::boolean, + $15::int, + $16::bigint, + $17::jsonb, + $18::int, + $19::int, + $20::jsonb, + $21::jsonb, + $22::int, + $23::text +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + short_name = EXCLUDED.short_name, + title = EXCLUDED.title, + count = EXCLUDED.count, + hash = EXCLUDED.hash, + set_kind = EXCLUDED.set_kind, + official = EXCLUDED.official, + animated = EXCLUDED.animated, + videos = EXCLUDED.videos, + emojis = EXCLUDED.emojis, + masks = EXCLUDED.masks, + installed = EXCLUDED.installed, + archived = EXCLUDED.archived, + installed_date = EXCLUDED.installed_date, + thumb_document_id = EXCLUDED.thumb_document_id, + thumbs = EXCLUDED.thumbs, + thumb_dc_id = EXCLUDED.thumb_dc_id, + thumb_version = EXCLUDED.thumb_version, + document_ids = EXCLUDED.document_ids, + packs = EXCLUDED.packs, + sort_order = EXCLUDED.sort_order, + system_key = EXCLUDED.system_key +` + +type PutStickerSetParams struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson []byte + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson []byte + PacksJson []byte + SortOrder int32 + SystemKey string +} + +// sticker_sets ---------------------------------------------------------------- +func (q *Queries) PutStickerSet(ctx context.Context, arg PutStickerSetParams) error { + _, err := q.db.Exec(ctx, putStickerSet, + arg.ID, + arg.AccessHash, + arg.ShortName, + arg.Title, + arg.Count, + arg.Hash, + arg.SetKind, + arg.Official, + arg.Animated, + arg.Videos, + arg.Emojis, + arg.Masks, + arg.Installed, + arg.Archived, + arg.InstalledDate, + arg.ThumbDocumentID, + arg.ThumbsJson, + arg.ThumbDcID, + arg.ThumbVersion, + arg.DocumentIdsJson, + arg.PacksJson, + arg.SortOrder, + arg.SystemKey, + ) + return err +} + +const saveUploadPart = `-- name: SaveUploadPart :exec + +INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, bytes) +VALUES ( + $1::bigint, + $2::bigint, + $3::int, + $4::int, + $5::boolean, + $6::bytea +) +ON CONFLICT (owner_user_id, file_id, part) DO UPDATE SET + total_parts = EXCLUDED.total_parts, + is_big = EXCLUDED.is_big, + bytes = EXCLUDED.bytes +` + +type SaveUploadPartParams struct { + OwnerUserID int64 + FileID int64 + Part int32 + TotalParts int32 + IsBig bool + Bytes []byte +} + +// upload_parts ---------------------------------------------------------------- +func (q *Queries) SaveUploadPart(ctx context.Context, arg SaveUploadPartParams) error { + _, err := q.db.Exec(ctx, saveUploadPart, + arg.OwnerUserID, + arg.FileID, + arg.Part, + arg.TotalParts, + arg.IsBig, + arg.Bytes, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/message.sql.go b/internal/store/postgres/sqlcgen/message.sql.go new file mode 100644 index 00000000..2d68720d --- /dev/null +++ b/internal/store/postgres/sqlcgen/message.sql.go @@ -0,0 +1,2331 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: message.sql + +package sqlcgen + +import ( + "context" +) + +const createMessage = `-- name: CreateMessage :one +WITH pm AS ( + INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities + ) VALUES ( + $1, + $2, + 0, + $3, + $4, + $5::jsonb + ) + RETURNING id, sender_user_id +), +box AS ( + INSERT INTO message_boxes ( + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + outgoing, + body, + entities, + pts + ) + SELECT + $2, + $6, + pm.id, + pm.sender_user_id, + $7, + $8, + $1, + $3, + $9, + $4, + $5::jsonb, + $10 + FROM pm + RETURNING + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + pts +) +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities_json, + pts +FROM box +` + +type CreateMessageParams struct { + FromUserID int64 + OwnerUserID int64 + MessageDate int32 + Body string + EntitiesJson []byte + BoxID int32 + PeerType string + PeerID int64 + Outgoing bool + Pts int32 +} + +type CreateMessageRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Pts int32 +} + +func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (CreateMessageRow, error) { + row := q.db.QueryRow(ctx, createMessage, + arg.FromUserID, + arg.OwnerUserID, + arg.MessageDate, + arg.Body, + arg.EntitiesJson, + arg.BoxID, + arg.PeerType, + arg.PeerID, + arg.Outgoing, + arg.Pts, + ) + var i CreateMessageRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Pts, + ) + return i, err +} + +const createMessageBox = `-- name: CreateMessageBox :one +INSERT INTO message_boxes ( + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + outgoing, + body, + entities, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, + $12::boolean, + $13::boolean, + $14::int, + $15::text, + $16::bigint, + $17::int, + $18::text, + $19::jsonb, + $20::int, + $21::text, + $22::bigint, + $23::text, + $24::int, + $25::int, + $26::jsonb +) +RETURNING + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +` + +type CreateMessageBoxParams struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + Outgoing bool + Body string + EntitiesJson []byte + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson []byte +} + +type CreateMessageBoxRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson string +} + +func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxParams) (CreateMessageBoxRow, error) { + row := q.db.QueryRow(ctx, createMessageBox, + arg.OwnerUserID, + arg.BoxID, + arg.PrivateMessageID, + arg.MessageSenderID, + arg.PeerType, + arg.PeerID, + arg.FromUserID, + arg.MessageDate, + arg.Outgoing, + arg.Body, + arg.EntitiesJson, + arg.Silent, + arg.Noforwards, + arg.ReplyToMsgID, + arg.ReplyToPeerType, + arg.ReplyToPeerID, + arg.ReplyToTopID, + arg.QuoteText, + arg.QuoteEntitiesJson, + arg.QuoteOffset, + arg.FwdFromPeerType, + arg.FwdFromPeerID, + arg.FwdFromName, + arg.FwdDate, + arg.Pts, + arg.MediaJson, + ) + var i CreateMessageBoxRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ) + return i, err +} + +const createPrivateMessage = `-- name: CreatePrivateMessage :one +INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + media +) VALUES ( + $1, $2, $3, $4, $5, $6::jsonb, + $7::boolean, + $8::boolean, + $9::int, + $10::text, + $11::bigint, + $12::int, + $13::text, + $14::jsonb, + $15::int, + $16::text, + $17::bigint, + $18::text, + $19::int, + $20::jsonb +) +ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING +RETURNING + id, + sender_user_id, + recipient_user_id, + random_id, + message_date, + edit_date, + body, + entities::text AS entities_json +` + +type CreatePrivateMessageParams struct { + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + MessageDate int32 + Body string + EntitiesJson []byte + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + MediaJson []byte +} + +type CreatePrivateMessageRow struct { + ID int64 + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + MessageDate int32 + EditDate int32 + Body string + EntitiesJson string +} + +func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMessageParams) (CreatePrivateMessageRow, error) { + row := q.db.QueryRow(ctx, createPrivateMessage, + arg.SenderUserID, + arg.RecipientUserID, + arg.RandomID, + arg.MessageDate, + arg.Body, + arg.EntitiesJson, + arg.Silent, + arg.Noforwards, + arg.ReplyToMsgID, + arg.ReplyToPeerType, + arg.ReplyToPeerID, + arg.ReplyToTopID, + arg.QuoteText, + arg.QuoteEntitiesJson, + arg.QuoteOffset, + arg.FwdFromPeerType, + arg.FwdFromPeerID, + arg.FwdFromName, + arg.FwdDate, + arg.MediaJson, + ) + var i CreatePrivateMessageRow + err := row.Scan( + &i.ID, + &i.SenderUserID, + &i.RecipientUserID, + &i.RandomID, + &i.MessageDate, + &i.EditDate, + &i.Body, + &i.EntitiesJson, + ) + return i, err +} + +const deleteMessageBoxesByIDs = `-- name: DeleteMessageBoxesByIDs :many +WITH updated AS ( + UPDATE message_boxes m + SET deleted = true + WHERE m.owner_user_id = $1::bigint + AND m.box_id = ANY($2::int[]) + AND NOT m.deleted + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY box_id ASC +` + +type DeleteMessageBoxesByIDsParams struct { + OwnerUserID int64 + BoxIds []int32 +} + +type DeleteMessageBoxesByIDsRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesByIDs(ctx context.Context, arg DeleteMessageBoxesByIDsParams) ([]DeleteMessageBoxesByIDsRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesByIDs, arg.OwnerUserID, arg.BoxIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesByIDsRow + for rows.Next() { + var i DeleteMessageBoxesByIDsRow + if err := rows.Scan( + &i.OwnerUserID, + &i.BoxID, + &i.PrivateMessageID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteMessageBoxesByPeer = `-- name: DeleteMessageBoxesByPeer :many +WITH updated AS ( + UPDATE message_boxes m + SET deleted = true + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND ($4::int <= 0 OR m.box_id <= $4::int) + AND NOT m.deleted + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY box_id ASC +` + +type DeleteMessageBoxesByPeerParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + MaxID int32 +} + +type DeleteMessageBoxesByPeerRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesByPeer(ctx context.Context, arg DeleteMessageBoxesByPeerParams) ([]DeleteMessageBoxesByPeerRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesByPeer, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.MaxID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesByPeerRow + for rows.Next() { + var i DeleteMessageBoxesByPeerRow + if err := rows.Scan( + &i.OwnerUserID, + &i.BoxID, + &i.PrivateMessageID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteMessageBoxesByPeerBatch = `-- name: DeleteMessageBoxesByPeerBatch :many +WITH target AS ( + SELECT + m.owner_user_id, + m.box_id + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND ($4::int <= 0 OR m.box_id <= $4::int) + AND NOT m.deleted + ORDER BY m.box_id DESC + LIMIT $5::int + FOR UPDATE SKIP LOCKED +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM target t + WHERE m.owner_user_id = t.owner_user_id + AND m.box_id = t.box_id + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY box_id ASC +` + +type DeleteMessageBoxesByPeerBatchParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + MaxID int32 + LimitCount int32 +} + +type DeleteMessageBoxesByPeerBatchRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesByPeerBatch(ctx context.Context, arg DeleteMessageBoxesByPeerBatchParams) ([]DeleteMessageBoxesByPeerBatchRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesByPeerBatch, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.MaxID, + arg.LimitCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesByPeerBatchRow + for rows.Next() { + var i DeleteMessageBoxesByPeerBatchRow + if err := rows.Scan( + &i.OwnerUserID, + &i.BoxID, + &i.PrivateMessageID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteMessageBoxesByPrivateMessages = `-- name: DeleteMessageBoxesByPrivateMessages :many +WITH requested AS ( + SELECT + ($1::bigint[])[i] AS message_sender_id, + ($2::bigint[])[i] AS private_message_id + FROM generate_subscripts($2::bigint[], 1) AS g(i) + WHERE i <= cardinality($1::bigint[]) +), +deduped AS ( + SELECT DISTINCT message_sender_id, private_message_id + FROM requested +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM deduped d + WHERE m.message_sender_id = d.message_sender_id + AND m.private_message_id = d.private_message_id + AND NOT m.deleted + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY owner_user_id ASC, box_id ASC +` + +type DeleteMessageBoxesByPrivateMessagesParams struct { + MessageSenderIds []int64 + PrivateMessageIds []int64 +} + +type DeleteMessageBoxesByPrivateMessagesRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesByPrivateMessages(ctx context.Context, arg DeleteMessageBoxesByPrivateMessagesParams) ([]DeleteMessageBoxesByPrivateMessagesRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesByPrivateMessages, arg.MessageSenderIds, arg.PrivateMessageIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesByPrivateMessagesRow + for rows.Next() { + var i DeleteMessageBoxesByPrivateMessagesRow + if err := rows.Scan( + &i.OwnerUserID, + &i.BoxID, + &i.PrivateMessageID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDialogReadStateForUpdate = `-- name: GetDialogReadStateForUpdate :one +SELECT + user_id, + peer_type, + peer_id, + top_message_id, + read_inbox_max_id, + unread_count +FROM dialogs +WHERE user_id = $1::bigint + AND peer_type = $2::text + AND peer_id = $3::bigint +FOR UPDATE +` + +type GetDialogReadStateForUpdateParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +type GetDialogReadStateForUpdateRow struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + ReadInboxMaxID int32 + UnreadCount int32 +} + +func (q *Queries) GetDialogReadStateForUpdate(ctx context.Context, arg GetDialogReadStateForUpdateParams) (GetDialogReadStateForUpdateRow, error) { + row := q.db.QueryRow(ctx, getDialogReadStateForUpdate, arg.UserID, arg.PeerType, arg.PeerID) + var i GetDialogReadStateForUpdateRow + err := row.Scan( + &i.UserID, + &i.PeerType, + &i.PeerID, + &i.TopMessageID, + &i.ReadInboxMaxID, + &i.UnreadCount, + ) + return i, err +} + +const getMessageBoxByPrivateMessage = `-- name: GetMessageBoxByPrivateMessage :one +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = $1 + AND private_message_id = $2 + AND NOT deleted +` + +type GetMessageBoxByPrivateMessageParams struct { + OwnerUserID int64 + PrivateMessageID int64 +} + +type GetMessageBoxByPrivateMessageRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson string +} + +func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMessageBoxByPrivateMessageParams) (GetMessageBoxByPrivateMessageRow, error) { + row := q.db.QueryRow(ctx, getMessageBoxByPrivateMessage, arg.OwnerUserID, arg.PrivateMessageID) + var i GetMessageBoxByPrivateMessageRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ) + return i, err +} + +const getMessageBoxForEdit = `-- name: GetMessageBoxForEdit :one +SELECT + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = $1::bigint + AND box_id = $2::int + AND peer_type = $3::text + AND peer_id = $4::bigint + AND NOT deleted +LIMIT 1 +FOR UPDATE +` + +type GetMessageBoxForEditParams struct { + OwnerUserID int64 + BoxID int32 + PeerType string + PeerID int64 +} + +type GetMessageBoxForEditRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson string +} + +func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxForEditParams) (GetMessageBoxForEditRow, error) { + row := q.db.QueryRow(ctx, getMessageBoxForEdit, + arg.OwnerUserID, + arg.BoxID, + arg.PeerType, + arg.PeerID, + ) + var i GetMessageBoxForEditRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ) + return i, err +} + +const getMessageBoxForReply = `-- name: GetMessageBoxForReply :one +SELECT + box_id, + private_message_id, + message_sender_id +FROM message_boxes +WHERE owner_user_id = $1::bigint + AND peer_type = $2::text + AND peer_id = $3::bigint + AND box_id = $4::int + AND NOT deleted +LIMIT 1 +` + +type GetMessageBoxForReplyParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + BoxID int32 +} + +type GetMessageBoxForReplyRow struct { + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 +} + +func (q *Queries) GetMessageBoxForReply(ctx context.Context, arg GetMessageBoxForReplyParams) (GetMessageBoxForReplyRow, error) { + row := q.db.QueryRow(ctx, getMessageBoxForReply, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.BoxID, + ) + var i GetMessageBoxForReplyRow + err := row.Scan(&i.BoxID, &i.PrivateMessageID, &i.MessageSenderID) + return i, err +} + +const getMessageBoxesByIDs = `-- name: GetMessageBoxesByIDs :many +SELECT + wanted.box_id AS requested_box_id, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.pts, + m.media::text AS media_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at +FROM unnest($1::int[]) WITH ORDINALITY AS wanted(box_id, ord) +JOIN message_boxes m + ON m.owner_user_id = $2::bigint + AND m.box_id = wanted.box_id + AND NOT m.deleted +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +ORDER BY wanted.ord ASC +` + +type GetMessageBoxesByIDsParams struct { + BoxIds []int32 + OwnerUserID int64 +} + +type GetMessageBoxesByIDsRow struct { + RequestedBoxID interface{} + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerLastSeenAt int64 + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FromUserLastSeenAt int64 +} + +func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesByIDsParams) ([]GetMessageBoxesByIDsRow, error) { + rows, err := q.db.Query(ctx, getMessageBoxesByIDs, arg.BoxIds, arg.OwnerUserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMessageBoxesByIDsRow + for rows.Next() { + var i GetMessageBoxesByIDsRow + if err := rows.Scan( + &i.RequestedBoxID, + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerLastSeenAt, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FromUserLastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMessageBoxesForForward = `-- name: GetMessageBoxesForForward :many +WITH requested AS ( + SELECT + id::int AS box_id, + ord::int AS ord + FROM unnest($4::int[]) WITH ORDINALITY AS r(id, ord) +) +SELECT + r.ord, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.message_sender_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.pts, + m.media::text AS media_json +FROM requested r +JOIN message_boxes m + ON m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND m.box_id = r.box_id + AND NOT m.deleted +ORDER BY r.ord ASC +` + +type GetMessageBoxesForForwardParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + BoxIds []int32 +} + +type GetMessageBoxesForForwardRow struct { + Ord int32 + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson string +} + +func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageBoxesForForwardParams) ([]GetMessageBoxesForForwardRow, error) { + rows, err := q.db.Query(ctx, getMessageBoxesForForward, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.BoxIds, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMessageBoxesForForwardRow + for rows.Next() { + var i GetMessageBoxesForForwardRow + if err := rows.Scan( + &i.Ord, + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getOutboxMessageForReadDate = `-- name: GetOutboxMessageForReadDate :one +SELECT box_id +FROM message_boxes +WHERE owner_user_id = $1::bigint + AND peer_type = $2::text + AND peer_id = $3::bigint + AND box_id = $4::int + AND outgoing + AND NOT deleted +LIMIT 1 +` + +type GetOutboxMessageForReadDateParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + BoxID int32 +} + +func (q *Queries) GetOutboxMessageForReadDate(ctx context.Context, arg GetOutboxMessageForReadDateParams) (int32, error) { + row := q.db.QueryRow(ctx, getOutboxMessageForReadDate, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.BoxID, + ) + var box_id int32 + err := row.Scan(&box_id) + return box_id, err +} + +const getOutboxReadDate = `-- name: GetOutboxReadDate :one +SELECT COALESCE(MIN(date), 0)::int AS read_date +FROM user_update_events +WHERE user_id = $1::bigint + AND event_type = 'read_history_outbox' + AND peer_type = $2::text + AND peer_id = $3::bigint + AND max_id >= $4::int +` + +type GetOutboxReadDateParams struct { + UserID int64 + PeerType string + PeerID int64 + MessageID int32 +} + +func (q *Queries) GetOutboxReadDate(ctx context.Context, arg GetOutboxReadDateParams) (int32, error) { + row := q.db.QueryRow(ctx, getOutboxReadDate, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.MessageID, + ) + var read_date int32 + err := row.Scan(&read_date) + return read_date, err +} + +const getPrivateMessageByRandomID = `-- name: GetPrivateMessageByRandomID :one +SELECT + id, + sender_user_id, + recipient_user_id, + random_id, + message_date, + edit_date, + body, + entities::text AS entities_json +FROM private_messages +WHERE sender_user_id = $1 + AND random_id = $2 + AND random_id <> 0 +` + +type GetPrivateMessageByRandomIDParams struct { + SenderUserID int64 + RandomID int64 +} + +type GetPrivateMessageByRandomIDRow struct { + ID int64 + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + MessageDate int32 + EditDate int32 + Body string + EntitiesJson string +} + +func (q *Queries) GetPrivateMessageByRandomID(ctx context.Context, arg GetPrivateMessageByRandomIDParams) (GetPrivateMessageByRandomIDRow, error) { + row := q.db.QueryRow(ctx, getPrivateMessageByRandomID, arg.SenderUserID, arg.RandomID) + var i GetPrivateMessageByRandomIDRow + err := row.Scan( + &i.ID, + &i.SenderUserID, + &i.RecipientUserID, + &i.RandomID, + &i.MessageDate, + &i.EditDate, + &i.Body, + &i.EntitiesJson, + ) + return i, err +} + +const hasDeletableMessageBoxByPeer = `-- name: HasDeletableMessageBoxByPeer :one +SELECT EXISTS ( + SELECT 1 + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND ($4::int <= 0 OR m.box_id <= $4::int) + AND NOT m.deleted + LIMIT 1 +)::boolean AS more +` + +type HasDeletableMessageBoxByPeerParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + MaxID int32 +} + +func (q *Queries) HasDeletableMessageBoxByPeer(ctx context.Context, arg HasDeletableMessageBoxByPeerParams) (bool, error) { + row := q.db.QueryRow(ctx, hasDeletableMessageBoxByPeer, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.MaxID, + ) + var more bool + err := row.Scan(&more) + return more, err +} + +const latestIncomingReadReceiptCandidate = `-- name: LatestIncomingReadReceiptCandidate :one +SELECT + m.message_sender_id, + m.private_message_id, + sender_box.owner_user_id AS sender_owner_user_id, + sender_box.box_id AS sender_box_id +FROM message_boxes m +JOIN message_boxes sender_box + ON sender_box.message_sender_id = m.message_sender_id + AND sender_box.private_message_id = m.private_message_id + AND sender_box.owner_user_id = m.message_sender_id + AND sender_box.outgoing + AND NOT sender_box.deleted +WHERE m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND NOT m.outgoing + AND NOT m.deleted + AND m.box_id > $4::int + AND m.box_id <= $5::int +ORDER BY m.box_id DESC +LIMIT 1 +` + +type LatestIncomingReadReceiptCandidateParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + OldReadInboxMaxID int32 + NewReadInboxMaxID int32 +} + +type LatestIncomingReadReceiptCandidateRow struct { + MessageSenderID int64 + PrivateMessageID int64 + SenderOwnerUserID int64 + SenderBoxID int32 +} + +func (q *Queries) LatestIncomingReadReceiptCandidate(ctx context.Context, arg LatestIncomingReadReceiptCandidateParams) (LatestIncomingReadReceiptCandidateRow, error) { + row := q.db.QueryRow(ctx, latestIncomingReadReceiptCandidate, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.OldReadInboxMaxID, + arg.NewReadInboxMaxID, + ) + var i LatestIncomingReadReceiptCandidateRow + err := row.Scan( + &i.MessageSenderID, + &i.PrivateMessageID, + &i.SenderOwnerUserID, + &i.SenderBoxID, + ) + return i, err +} + +const listMessagesByUser = `-- name: ListMessagesByUser :many +WITH load_params AS ( + SELECT + $2::int AS offset_id, + $3::int AS offset_date, + $4::int AS add_offset, + $5::int AS limit_count, + CASE + WHEN $4::int >= 0 THEN 'backward' + WHEN $4::int + $5::int > 0 THEN 'around' + ELSE 'forward' + END::text AS load_type +), +base AS NOT MATERIALIZED ( + SELECT + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.pts, + m.media::text AS media_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at + FROM message_boxes m + LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id + LEFT JOIN users from_u ON from_u.id = m.from_user_id + WHERE m.owner_user_id = $1 + AND NOT m.deleted + AND ( + NOT $6::boolean + OR (m.peer_type = $7::text AND m.peer_id = $8::bigint) + ) + AND ( + $9::text = '' + OR m.body ILIKE ('%' || $9::text || '%') + ) + AND ($10::int <= 0 OR m.box_id < $10::int) + AND ($11::int <= 0 OR m.box_id > $11::int) +), +total AS ( + SELECT count(*)::int AS total_count + FROM base + WHERE $12::boolean +), +backward AS ( + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'backward' + AND ( + (p.offset_date > 0 AND b.message_date < p.offset_date) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id)) + ) + ORDER BY b.box_id DESC + OFFSET GREATEST((SELECT add_offset FROM load_params), 0) + LIMIT (SELECT limit_count FROM load_params) +), +around_forward AS ( + SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.pts, f.media_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_last_seen_at + FROM ( + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'around' + AND ( + (p.offset_date > 0 AND b.message_date >= p.offset_date) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + ) + ORDER BY b.box_id ASC + LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params)) + ) f +), +around_backward AS ( + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'around' + AND ( + (p.offset_date > 0 AND b.message_date < p.offset_date) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id)) + ) + ORDER BY b.box_id DESC + LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0) +), +forward AS ( + SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.pts, f.media_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_last_seen_at + FROM ( + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'forward' + AND ( + (p.offset_date > 0 AND b.message_date >= p.offset_date) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + ) + ORDER BY b.box_id ASC + LIMIT (SELECT limit_count FROM load_params) + ) f +), +paged AS ( + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM backward + UNION ALL + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM around_forward + UNION ALL + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM around_backward + UNION ALL + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM forward +) +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media_json, + peer_user_id, + peer_access_hash, + peer_phone, + peer_first_name, + peer_last_name, + peer_username, + peer_country_code, + peer_verified, + peer_support, + peer_last_seen_at, + from_user_user_id, + from_user_access_hash, + from_user_phone, + from_user_first_name, + from_user_last_name, + from_user_username, + from_user_country_code, + from_user_verified, + from_user_support, + from_user_last_seen_at, + COALESCE(total.total_count, 0)::int AS total_count +FROM paged +CROSS JOIN total +ORDER BY box_id DESC +` + +type ListMessagesByUserParams struct { + OwnerUserID int64 + OffsetID int32 + OffsetDate int32 + AddOffset int32 + LimitCount int32 + HasPeer bool + PeerType string + PeerID int64 + Query string + MaxID int32 + MinID int32 + NeedTotalCount bool +} + +type ListMessagesByUserRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerLastSeenAt int64 + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FromUserLastSeenAt int64 + TotalCount int32 +} + +func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUserParams) ([]ListMessagesByUserRow, error) { + rows, err := q.db.Query(ctx, listMessagesByUser, + arg.OwnerUserID, + arg.OffsetID, + arg.OffsetDate, + arg.AddOffset, + arg.LimitCount, + arg.HasPeer, + arg.PeerType, + arg.PeerID, + arg.Query, + arg.MaxID, + arg.MinID, + arg.NeedTotalCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMessagesByUserRow + for rows.Next() { + var i ListMessagesByUserRow + if err := rows.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerLastSeenAt, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FromUserLastSeenAt, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listVisibleMessageBoxesByPrivateMessage = `-- name: ListVisibleMessageBoxesByPrivateMessage :many +SELECT + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE message_sender_id = $1::bigint + AND private_message_id = $2::bigint + AND NOT deleted +ORDER BY owner_user_id ASC, box_id ASC +FOR UPDATE +` + +type ListVisibleMessageBoxesByPrivateMessageParams struct { + MessageSenderID int64 + PrivateMessageID int64 +} + +type ListVisibleMessageBoxesByPrivateMessageRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson string +} + +func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, arg ListVisibleMessageBoxesByPrivateMessageParams) ([]ListVisibleMessageBoxesByPrivateMessageRow, error) { + rows, err := q.db.Query(ctx, listVisibleMessageBoxesByPrivateMessage, arg.MessageSenderID, arg.PrivateMessageID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListVisibleMessageBoxesByPrivateMessageRow + for rows.Next() { + var i ListVisibleMessageBoxesByPrivateMessageRow + if err := rows.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const maxMessageBoxID = `-- name: MaxMessageBoxID :one +SELECT COALESCE(MAX(box_id), 0)::int AS max_box_id +FROM message_boxes +WHERE owner_user_id = $1 +` + +func (q *Queries) MaxMessageBoxID(ctx context.Context, ownerUserID int64) (int32, error) { + row := q.db.QueryRow(ctx, maxMessageBoxID, ownerUserID) + var max_box_id int32 + err := row.Scan(&max_box_id) + return max_box_id, err +} + +const topVisibleMessageBoxByPeer = `-- name: TopVisibleMessageBoxByPeer :one +SELECT + box_id, + message_date +FROM message_boxes +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND NOT deleted +ORDER BY box_id DESC +LIMIT 1 +` + +type TopVisibleMessageBoxByPeerParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 +} + +type TopVisibleMessageBoxByPeerRow struct { + BoxID int32 + MessageDate int32 +} + +func (q *Queries) TopVisibleMessageBoxByPeer(ctx context.Context, arg TopVisibleMessageBoxByPeerParams) (TopVisibleMessageBoxByPeerRow, error) { + row := q.db.QueryRow(ctx, topVisibleMessageBoxByPeer, arg.OwnerUserID, arg.PeerType, arg.PeerID) + var i TopVisibleMessageBoxByPeerRow + err := row.Scan(&i.BoxID, &i.MessageDate) + return i, err +} + +const updateDialogReadInbox = `-- name: UpdateDialogReadInbox :one +UPDATE dialogs d +SET + read_inbox_max_id = GREATEST(d.read_inbox_max_id, $1::int), + unread_count = ( + SELECT count(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND NOT m.outgoing + AND m.box_id > GREATEST(d.read_inbox_max_id, $1::int) + ), + unread_mark = false, + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +WHERE d.user_id = $2::bigint + AND d.peer_type = $3::text + AND d.peer_id = $4::bigint +RETURNING + d.read_inbox_max_id, + d.unread_count +` + +type UpdateDialogReadInboxParams struct { + ReadInboxMaxID int32 + UserID int64 + PeerType string + PeerID int64 +} + +type UpdateDialogReadInboxRow struct { + ReadInboxMaxID int32 + UnreadCount int32 +} + +func (q *Queries) UpdateDialogReadInbox(ctx context.Context, arg UpdateDialogReadInboxParams) (UpdateDialogReadInboxRow, error) { + row := q.db.QueryRow(ctx, updateDialogReadInbox, + arg.ReadInboxMaxID, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + var i UpdateDialogReadInboxRow + err := row.Scan(&i.ReadInboxMaxID, &i.UnreadCount) + return i, err +} + +const updateDialogReadOutbox = `-- name: UpdateDialogReadOutbox :one +UPDATE dialogs +SET + read_outbox_max_id = GREATEST(read_outbox_max_id, $1::int), + updated_at = now() +WHERE user_id = $2::bigint + AND peer_type = $3::text + AND peer_id = $4::bigint + AND read_outbox_max_id < $1::int +RETURNING read_outbox_max_id +` + +type UpdateDialogReadOutboxParams struct { + ReadOutboxMaxID int32 + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) UpdateDialogReadOutbox(ctx context.Context, arg UpdateDialogReadOutboxParams) (int32, error) { + row := q.db.QueryRow(ctx, updateDialogReadOutbox, + arg.ReadOutboxMaxID, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + var read_outbox_max_id int32 + err := row.Scan(&read_outbox_max_id) + return read_outbox_max_id, err +} + +const updateMessageBoxEdit = `-- name: UpdateMessageBoxEdit :one +UPDATE message_boxes +SET body = $1::text, + entities = $2::jsonb, + edit_date = $3::int, + pts = $4::int +WHERE owner_user_id = $5::bigint + AND box_id = $6::int + AND NOT deleted +RETURNING + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +` + +type UpdateMessageBoxEditParams struct { + Body string + EntitiesJson []byte + EditDate int32 + Pts int32 + OwnerUserID int64 + BoxID int32 +} + +type UpdateMessageBoxEditRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Pts int32 + MediaJson string +} + +func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBoxEditParams) (UpdateMessageBoxEditRow, error) { + row := q.db.QueryRow(ctx, updateMessageBoxEdit, + arg.Body, + arg.EntitiesJson, + arg.EditDate, + arg.Pts, + arg.OwnerUserID, + arg.BoxID, + ) + var i UpdateMessageBoxEditRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ) + return i, err +} + +const updatePrivateMessageEdit = `-- name: UpdatePrivateMessageEdit :exec +UPDATE private_messages +SET body = $1::text, + entities = $2::jsonb, + edit_date = $3::int +WHERE sender_user_id = $4::bigint + AND id = $5::bigint +` + +type UpdatePrivateMessageEditParams struct { + Body string + EntitiesJson []byte + EditDate int32 + SenderUserID int64 + PrivateMessageID int64 +} + +func (q *Queries) UpdatePrivateMessageEdit(ctx context.Context, arg UpdatePrivateMessageEditParams) error { + _, err := q.db.Exec(ctx, updatePrivateMessageEdit, + arg.Body, + arg.EntitiesJson, + arg.EditDate, + arg.SenderUserID, + arg.PrivateMessageID, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go new file mode 100644 index 00000000..36655ae2 --- /dev/null +++ b/internal/store/postgres/sqlcgen/models.go @@ -0,0 +1,681 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcgen + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type AccountPassword struct { + UserID int64 + HasRecovery bool + HasSecureValues bool + HasPassword bool + Hint string + EmailUnconfirmedPattern string + LoginEmailPattern string + SecureRandom []byte + UpdatedAt pgtype.Timestamptz +} + +type AppConfig struct { + Client string + Hash int32 + ConfigJson []byte + UpdatedAt pgtype.Timestamptz +} + +type AuthKey struct { + AuthKeyID int64 + Body []byte + ServerSalt int64 + CreatedAt pgtype.Timestamptz +} + +type Authorization struct { + AuthKeyID int64 + UserID int64 + Hash int64 + Layer int32 + DeviceModel string + Platform string + SystemVersion string + ApiID int32 + AppVersion string + Ip string + CreatedAt pgtype.Timestamptz + ActiveAt pgtype.Timestamptz +} + +type AvailableReaction struct { + Reaction string + Title string + Inactive bool + Premium bool + StaticIconID int64 + AppearAnimationID int64 + SelectAnimationID int64 + ActivateAnimationID int64 + EffectAnimationID int64 + AroundAnimationID int64 + CenterIconID int64 + SortOrder int32 +} + +type Channel struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Title string + About string + Username *string + Broadcast bool + Megagroup bool + Forum bool + ForumTabs bool + Noforwards bool + JoinToSend bool + JoinRequest bool + Signatures bool + PreHistoryHidden bool + ParticipantsHidden bool + Antispam bool + LinkedChatID int64 + SlowmodeSeconds int32 + DefaultBannedRights []byte + AvailableReactions []byte + ColorSet bool + Color int32 + ColorBackgroundEmojiID int64 + ProfileColorSet bool + ProfileColor int32 + ProfileColorBackgroundEmojiID int64 + EmojiStatusDocumentID int64 + EmojiStatusUntil int32 + ParticipantsCount int32 + AdminsCount int32 + KickedCount int32 + BannedCount int32 + TopMessageID int32 + Pts int32 + AdminLogSeq int64 + TtlPeriod int32 + Date int32 + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + PinnedMessageID int32 + Autotranslation bool + RestrictedSponsored bool + BroadcastMessagesAllowed bool + SendPaidMessagesStars int64 + PhotoID int64 + PhotoDcID int32 + PhotoStripped []byte +} + +type ChannelAdminLogEvent struct { + ChannelID int64 + ID int64 + ActorUserID int64 + EventDate int32 + EventType string + PrevString string + NewString string + PrevBool bool + NewBool bool + PrevInt int32 + NewInt int32 + PrevParticipant []byte + NewParticipant []byte + Participant []byte + Message []byte + PrevMessage []byte + NewMessage []byte + Query string + CreatedAt pgtype.Timestamptz +} + +type ChannelDialog struct { + UserID int64 + ChannelID int64 + FolderID int32 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + PinnedOrder int32 + UnreadMark bool + ViewForumAsMessages bool + NotifySettings []byte + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + DefaultSendAsPeerType *string + DefaultSendAsPeerID *int64 +} + +type ChannelForumTopic struct { + ChannelID int64 + TopicID int32 + CreatorUserID int64 + Title string + IconColor int32 + IconEmojiID int64 + TitleMissing bool + Closed bool + Hidden bool + Pinned bool + PinnedOrder int32 + Date int32 + TopMessageID int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + UnreadPollVotesCount int32 + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChannelInvite struct { + ChannelID int64 + InviteID int64 + Hash string + AdminUserID int64 + Title string + Permanent bool + Revoked bool + RequestNeeded bool + ExpireDate *int32 + UsageLimit *int32 + UsageCount int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + RequestedCount int32 +} + +type ChannelInviteHash struct { + Hash string + ChannelID int64 + InviteID int64 + UpdatedAt pgtype.Timestamptz +} + +type ChannelInviteImporter struct { + ChannelID int64 + InviteID int64 + UserID int64 + Date int32 + Requested bool + ApprovedBy int64 + ViaChatlist bool + About string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChannelMember struct { + ChannelID int64 + UserID int64 + InviterUserID int64 + Role string + Status string + JoinedAt int32 + LeftAt int32 + AdminRights []byte + BannedRights []byte + Rank string + AvailableMinID int32 + AvailableMinPts int32 + ReadInboxMaxID int32 + ReadInboxDate int32 + ReadOutboxMaxID int32 + UnreadMark bool + SlowmodeLastSendDate int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChannelMessage struct { + ChannelID int64 + ID int32 + RandomID int64 + SenderUserID int64 + FromPeerType string + FromPeerID int64 + SendAsPeerType *string + SendAsPeerID *int64 + MessageDate int32 + EditDate int32 + Post bool + Silent bool + Noforwards bool + Body string + Entities []byte + ReplyTo []byte + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + FwdFrom []byte + DiscussionChannelID int64 + DiscussionMessageID int32 + Action []byte + Pts int32 + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + ViewsCount int32 + Media []byte +} + +type ChannelMessageReaction struct { + ChannelID int64 + MessageID int32 + ReactedUserID int64 + SenderUserID int64 + ReactionType string + ReactionValue string + Big bool + Unread bool + ChosenOrder int32 + ReactionDate int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChannelMessageViewer struct { + ChannelID int64 + MessageID int32 + ViewerUserID int64 + ViewedAt int32 + CreatedAt pgtype.Timestamptz +} + +type ChannelUnreadMention struct { + UserID int64 + ChannelID int64 + MessageID int32 + TopMessageID int32 + CreatedAt pgtype.Timestamptz +} + +type ChannelUpdateEvent struct { + ChannelID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + MessageID int32 + MessageIds []byte + SenderUserID int64 + UserIds []byte + Payload []byte + CreatedAt pgtype.Timestamptz +} + +type ChannelUsername struct { + UsernameLower string + ChannelID int64 + UpdatedAt pgtype.Timestamptz +} + +type Contact struct { + UserID int64 + ContactUserID int64 + Mutual bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntities []byte + CloseFriend bool + StoriesHidden bool +} + +type Country struct { + Iso2 string + DefaultName string + Name string + Hidden bool + OrderIndex int32 + UpdatedAt pgtype.Timestamptz +} + +type CountryCode struct { + ID int64 + Iso2 string + CountryCode string + Prefixes []string + Patterns []string + OrderIndex int32 +} + +type Dialog struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + UpdatedAt pgtype.Timestamptz + PinnedOrder int32 + UnreadMark bool + HiddenPeerSettingsBar bool + FolderID int32 +} + +type DialogDraft struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + Date int32 + Draft []byte + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type DialogFilter struct { + UserID int64 + FilterID int32 + IsChatlist bool + Filter []byte + OrderValue int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type DialogFilterSetting struct { + UserID int64 + TagsEnabled bool + UpdatedAt pgtype.Timestamptz +} + +type DispatchOutbox struct { + ID int64 + TargetUserID int64 + Pts int32 + EventType string + ExcludeSessionID int64 + Status string + Attempts int32 + NextAttemptAt pgtype.Timestamptz + LastError string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + ExcludeAuthKeyID int64 +} + +type Document struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + MimeType string + Size int64 + DcID int32 + Attributes []byte + Thumbs []byte + CreatedAt pgtype.Timestamptz +} + +type FileBlob struct { + LocationKey string + Backend string + ObjectKey string + Size int64 + Sha256 []byte + MimeType string + CreatedAt pgtype.Timestamptz +} + +type LangPack struct { + LangPack string + LangCode string + Version int32 + StringsCount int32 + UpdatedAt pgtype.Timestamptz +} + +type LangPackString struct { + LangPack string + LangCode string + Key string + Version int32 + Pluralized bool + Value string + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool + UpdatedAt pgtype.Timestamptz +} + +type MessageBox struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + Outgoing bool + Body string + Entities []byte + Pts int32 + Deleted bool + CreatedAt pgtype.Timestamptz + EditDate int32 + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntities []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Media []byte +} + +type Photo struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + DcID int32 + HasStickers bool + Sizes []byte + CreatedAt pgtype.Timestamptz +} + +type PrivateMessage struct { + ID int64 + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + MessageDate int32 + Body string + Entities []byte + CreatedAt pgtype.Timestamptz + EditDate int32 + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntities []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Media []byte +} + +type ProfilePhoto struct { + OwnerPeerType string + OwnerPeerID int64 + PhotoID int64 + Date int32 + Active bool + SortOrder int64 + CreatedAt pgtype.Timestamptz +} + +type StickerSet struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + Thumbs []byte + ThumbDcID int32 + ThumbVersion int32 + DocumentIds []byte + Packs []byte + SortOrder int32 + SystemKey string + CreatedAt pgtype.Timestamptz +} + +type TempAuthKeyBinding struct { + TempAuthKeyID int64 + PermAuthKeyID int64 + Nonce int64 + ExpiresAt int32 + EncryptedMessage []byte + CreatedAt pgtype.Timestamptz + TempSessionID int64 +} + +type UpdateState struct { + AuthKeyID int64 + Pts int32 + Qts int32 + Date int32 + Seq int32 + UpdatedAt pgtype.Timestamptz + UserID int64 +} + +type UploadPart struct { + OwnerUserID int64 + FileID int64 + Part int32 + TotalParts int32 + IsBig bool + Bytes []byte + CreatedAt pgtype.Timestamptz +} + +type User struct { + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + Verified bool + Support bool + About string + LastSeenAt int64 +} + +type UserRecentReaction struct { + UserID int64 + ReactionType string + ReactionValue string + ReactionDate int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type UserSavedReactionTag struct { + UserID int64 + ReactionType string + ReactionValue string + Title string + ReactionCount int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type UserTopReaction struct { + UserID int64 + ReactionType string + ReactionValue string + ReactionCount int32 + ReactionDate int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type UserUpdateEvent struct { + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + MessageBoxID *int32 + PeerType *string + PeerID *int64 + MaxID int32 + StillUnreadCount int32 + CreatedAt pgtype.Timestamptz + EventBool bool + EventPeers []byte + PeerSettings []byte + MessageIds []byte + DialogFilter []byte + FilterOrder []byte + FolderPeers []byte + FilterID int32 + TagsEnabled bool +} + +type UserUpdateWatermark struct { + UserID int64 + ContiguousPts int32 + UpdatedAt pgtype.Timestamptz +} diff --git a/internal/store/postgres/sqlcgen/temp_auth_key.sql.go b/internal/store/postgres/sqlcgen/temp_auth_key.sql.go new file mode 100644 index 00000000..4821e763 --- /dev/null +++ b/internal/store/postgres/sqlcgen/temp_auth_key.sql.go @@ -0,0 +1,80 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: temp_auth_key.sql + +package sqlcgen + +import ( + "context" +) + +const getTempAuthKeyBinding = `-- name: GetTempAuthKeyBinding :one +SELECT + temp_auth_key_id, + perm_auth_key_id, + nonce, + temp_session_id, + expires_at, + encrypted_message +FROM temp_auth_key_bindings +WHERE temp_auth_key_id = $1 +` + +type GetTempAuthKeyBindingRow struct { + TempAuthKeyID int64 + PermAuthKeyID int64 + Nonce int64 + TempSessionID int64 + ExpiresAt int32 + EncryptedMessage []byte +} + +func (q *Queries) GetTempAuthKeyBinding(ctx context.Context, tempAuthKeyID int64) (GetTempAuthKeyBindingRow, error) { + row := q.db.QueryRow(ctx, getTempAuthKeyBinding, tempAuthKeyID) + var i GetTempAuthKeyBindingRow + err := row.Scan( + &i.TempAuthKeyID, + &i.PermAuthKeyID, + &i.Nonce, + &i.TempSessionID, + &i.ExpiresAt, + &i.EncryptedMessage, + ) + return i, err +} + +const upsertTempAuthKeyBinding = `-- name: UpsertTempAuthKeyBinding :exec +INSERT INTO temp_auth_key_bindings ( + temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message +) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (temp_auth_key_id) DO UPDATE SET + perm_auth_key_id = EXCLUDED.perm_auth_key_id, + nonce = EXCLUDED.nonce, + temp_session_id = EXCLUDED.temp_session_id, + expires_at = EXCLUDED.expires_at, + encrypted_message = EXCLUDED.encrypted_message, + created_at = now() +` + +type UpsertTempAuthKeyBindingParams struct { + TempAuthKeyID int64 + PermAuthKeyID int64 + Nonce int64 + TempSessionID int64 + ExpiresAt int32 + EncryptedMessage []byte +} + +func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAuthKeyBindingParams) error { + _, err := q.db.Exec(ctx, upsertTempAuthKeyBinding, + arg.TempAuthKeyID, + arg.PermAuthKeyID, + arg.Nonce, + arg.TempSessionID, + arg.ExpiresAt, + arg.EncryptedMessage, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/update_state.sql.go b/internal/store/postgres/sqlcgen/update_state.sql.go new file mode 100644 index 00000000..d2740b7f --- /dev/null +++ b/internal/store/postgres/sqlcgen/update_state.sql.go @@ -0,0 +1,103 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: update_state.sql + +package sqlcgen + +import ( + "context" +) + +const deleteUpdateState = `-- name: DeleteUpdateState :exec +DELETE FROM update_states +WHERE auth_key_id = $1 + AND user_id = $2 +` + +type DeleteUpdateStateParams struct { + AuthKeyID int64 + UserID int64 +} + +func (q *Queries) DeleteUpdateState(ctx context.Context, arg DeleteUpdateStateParams) error { + _, err := q.db.Exec(ctx, deleteUpdateState, arg.AuthKeyID, arg.UserID) + return err +} + +const deleteUpdateStatesByAuthKey = `-- name: DeleteUpdateStatesByAuthKey :exec +DELETE FROM update_states +WHERE auth_key_id = $1 +` + +func (q *Queries) DeleteUpdateStatesByAuthKey(ctx context.Context, authKeyID int64) error { + _, err := q.db.Exec(ctx, deleteUpdateStatesByAuthKey, authKeyID) + return err +} + +const getUpdateState = `-- name: GetUpdateState :one +SELECT auth_key_id, user_id, pts, qts, date, seq +FROM update_states +WHERE auth_key_id = $1 + AND user_id = $2 +` + +type GetUpdateStateParams struct { + AuthKeyID int64 + UserID int64 +} + +type GetUpdateStateRow struct { + AuthKeyID int64 + UserID int64 + Pts int32 + Qts int32 + Date int32 + Seq int32 +} + +func (q *Queries) GetUpdateState(ctx context.Context, arg GetUpdateStateParams) (GetUpdateStateRow, error) { + row := q.db.QueryRow(ctx, getUpdateState, arg.AuthKeyID, arg.UserID) + var i GetUpdateStateRow + err := row.Scan( + &i.AuthKeyID, + &i.UserID, + &i.Pts, + &i.Qts, + &i.Date, + &i.Seq, + ) + return i, err +} + +const upsertUpdateState = `-- name: UpsertUpdateState :exec +INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (auth_key_id, user_id) DO UPDATE SET + pts = EXCLUDED.pts, + qts = EXCLUDED.qts, + date = EXCLUDED.date, + seq = EXCLUDED.seq, + updated_at = now() +` + +type UpsertUpdateStateParams struct { + AuthKeyID int64 + UserID int64 + Pts int32 + Qts int32 + Date int32 + Seq int32 +} + +func (q *Queries) UpsertUpdateState(ctx context.Context, arg UpsertUpdateStateParams) error { + _, err := q.db.Exec(ctx, upsertUpdateState, + arg.AuthKeyID, + arg.UserID, + arg.Pts, + arg.Qts, + arg.Date, + arg.Seq, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go new file mode 100644 index 00000000..2d377141 --- /dev/null +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -0,0 +1,426 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: user.sql + +package sqlcgen + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +` + +type CreateUserParams struct { + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.AccessHash, + arg.Phone, + arg.FirstName, + arg.LastName, + arg.Username, + arg.CountryCode, + ) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + ) + return i, err +} + +const getUserByPhone = `-- name: GetUserByPhone :one +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE phone = $1 +` + +func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { + row := q.db.QueryRow(ctx, getUserByPhone, phone) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + ) + return i, err +} + +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE lower(username) = lower($1) AND username <> '' +` + +func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) { + row := q.db.QueryRow(ctx, getUserByUsername, lower) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + ) + return i, err +} + +const getUsersByIDs = `-- name: GetUsersByIDs :many +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +FROM users +WHERE id = ANY($1::bigint[]) +ORDER BY id +` + +func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error) { + rows, err := q.db.Query(ctx, getUsersByIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUsersByPhones = `-- name: GetUsersByPhones :many +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +FROM users +WHERE phone = ANY($1::text[]) +ORDER BY id +` + +func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User, error) { + rows, err := q.db.Query(ctx, getUsersByPhones, phones) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchUsers = `-- name: SearchUsers :many +WITH matched AS ( + SELECT + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.about, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + (c.contact_user_id IS NOT NULL)::boolean AS contact, + COALESCE(c.mutual, false)::boolean AS mutual, + CASE + WHEN $2::text <> '' AND u.phone = $2::text THEN 0 + WHEN lower(u.username) = $3::text THEN 1 + WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $3::text THEN 2 + WHEN lower(u.first_name) = $3::text THEN 3 + WHEN c.contact_user_id IS NOT NULL THEN 4 + ELSE 5 + END AS rank + FROM users u + LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id + WHERE u.id <> $4::bigint + AND $3::text <> '' + AND ( + ($2::text <> '' AND u.phone LIKE $2::text || '%') + OR lower(u.username) LIKE $5::text || '%' ESCAPE '\' + OR lower(u.first_name) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(u.last_name) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(c.contact_first_name) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(c.contact_last_name) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $5::text || '%' ESCAPE '\' + ) +) +SELECT + id, + access_hash, + phone, + first_name, + last_name, + about, + username, + country_code, + verified, + support, + last_seen_at, + contact, + mutual +FROM matched +ORDER BY contact DESC, rank, id +LIMIT $1 +` + +type SearchUsersParams struct { + LimitCount int32 + PhoneQuery string + QueryLower string + CurrentUserID int64 + QueryLike string +} + +type SearchUsersRow struct { + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + About string + Username string + CountryCode string + Verified bool + Support bool + LastSeenAt int64 + Contact bool + Mutual bool +} + +func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) { + rows, err := q.db.Query(ctx, searchUsers, + arg.LimitCount, + arg.PhoneQuery, + arg.QueryLower, + arg.CurrentUserID, + arg.QueryLike, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchUsersRow + for rows.Next() { + var i SearchUsersRow + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.About, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + &i.Contact, + &i.Mutual, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec +UPDATE users +SET last_seen_at = GREATEST(last_seen_at, $1::bigint), + updated_at = now() +WHERE id = $2::bigint +` + +type UpdateUserLastSeenParams struct { + LastSeenAt int64 + ID int64 +} + +func (q *Queries) UpdateUserLastSeen(ctx context.Context, arg UpdateUserLastSeenParams) error { + _, err := q.db.Exec(ctx, updateUserLastSeen, arg.LastSeenAt, arg.ID) + return err +} + +const updateUserProfile = `-- name: UpdateUserProfile :one +UPDATE users +SET first_name = $2, + last_name = $3, + about = $4, + updated_at = now() +WHERE id = $1 +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +` + +type UpdateUserProfileParams struct { + ID int64 + FirstName string + LastName string + About string +} + +func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserProfile, + arg.ID, + arg.FirstName, + arg.LastName, + arg.About, + ) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + ) + return i, err +} + +const updateUserUsername = `-- name: UpdateUserUsername :one +UPDATE users +SET username = $2, + updated_at = now() +WHERE id = $1 +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +` + +type UpdateUserUsernameParams struct { + ID int64 + Username string +} + +func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsernameParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserUsername, arg.ID, arg.Username) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + ) + return i, err +} diff --git a/internal/store/postgres/sqlcgen/user_update_event.sql.go b/internal/store/postgres/sqlcgen/user_update_event.sql.go new file mode 100644 index 00000000..d2dac885 --- /dev/null +++ b/internal/store/postgres/sqlcgen/user_update_event.sql.go @@ -0,0 +1,1307 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: user_update_event.sql + +package sqlcgen + +import ( + "context" +) + +const appendUserUpdateEvent = `-- name: AppendUserUpdateEvent :exec +INSERT INTO user_update_events ( + user_id, + pts, + pts_count, + date, + event_type, + event_bool, + event_peers, + peer_settings, + message_ids, + dialog_filter, + filter_order, + folder_peers, + message_box_id, + peer_type, + peer_id, + filter_id, + max_id, + still_unread_count, + tags_enabled +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6::boolean, + $7::jsonb, + $8::jsonb, + $9::jsonb, + $10::jsonb, + $11::jsonb, + $12::jsonb, + $13, + $14::text, + $15::bigint, + $16::int, + $17::int, + $18::int, + $19::boolean +) +ON CONFLICT (user_id, pts) DO NOTHING +` + +type AppendUserUpdateEventParams struct { + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + EventBool bool + EventPeers []byte + PeerSettings []byte + MessageIds []byte + DialogFilter []byte + FilterOrder []byte + FolderPeers []byte + MessageBoxID *int32 + PeerType *string + PeerID *int64 + FilterID int32 + MaxID int32 + StillUnreadCount int32 + TagsEnabled bool +} + +func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error { + _, err := q.db.Exec(ctx, appendUserUpdateEvent, + arg.UserID, + arg.Pts, + arg.PtsCount, + arg.Date, + arg.EventType, + arg.EventBool, + arg.EventPeers, + arg.PeerSettings, + arg.MessageIds, + arg.DialogFilter, + arg.FilterOrder, + arg.FolderPeers, + arg.MessageBoxID, + arg.PeerType, + arg.PeerID, + arg.FilterID, + arg.MaxID, + arg.StillUnreadCount, + arg.TagsEnabled, + ) + return err +} + +const batchListDispatchEvents = `-- name: BatchListDispatchEvents :many +SELECT + e.user_id, + e.pts, + e.pts_count, + e.date, + e.event_type, + e.event_bool, + COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, + COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, + COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, + COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json, + COALESCE(e.filter_order::text, '[]')::text AS filter_order_json, + COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, + COALESCE(e.peer_type, '')::text AS event_peer_type, + COALESCE(e.peer_id, 0)::bigint AS event_peer_id, + e.filter_id, + e.max_id, + e.still_unread_count, + e.tags_enabled, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.private_message_id, 0)::bigint AS private_message_id, + COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id, + COALESCE(m.peer_type, '')::text AS peer_type, + COALESCE(m.peer_id, 0)::bigint AS peer_id, + COALESCE(m.from_user_id, 0)::bigint AS from_user_id, + COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.edit_date, 0)::int AS edit_date, + COALESCE(m.outgoing, false)::boolean AS outgoing, + COALESCE(m.body, '')::text AS body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + COALESCE(m.silent, false)::boolean AS silent, + COALESCE(m.noforwards, false)::boolean AS noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_id, + COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, + COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id, + COALESCE(m.quote_text, '')::text AS quote_text, + COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, + COALESCE(m.quote_offset, 0)::int AS quote_offset, + COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, + COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.media::text, '{}')::text AS media_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, + COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash, + COALESCE(fwd_u.phone, '')::text AS fwd_user_phone, + COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name, + COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name, + COALESCE(fwd_u.username, '')::text AS fwd_user_username, + COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code, + COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified, + COALESCE(fwd_u.support, false)::boolean AS fwd_user_support, + COALESCE(reply_u.id, 0)::bigint AS reply_user_id, + COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash, + COALESCE(reply_u.phone, '')::text AS reply_user_phone, + COALESCE(reply_u.first_name, '')::text AS reply_user_first_name, + COALESCE(reply_u.last_name, '')::text AS reply_user_last_name, + COALESCE(reply_u.username, '')::text AS reply_user_username, + COALESCE(reply_u.country_code, '')::text AS reply_user_country_code, + COALESCE(reply_u.verified, false)::boolean AS reply_user_verified, + COALESCE(reply_u.support, false)::boolean AS reply_user_support, + COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id, + COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash, + COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id, + COALESCE(fwd_ch.title, '')::text AS fwd_channel_title, + COALESCE(fwd_ch.about, '')::text AS fwd_channel_about, + COALESCE(fwd_ch.username, '')::text AS fwd_channel_username, + COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast, + COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup, + COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum, + COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards, + COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures, + COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden, + COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds, + COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights, + COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count, + COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count, + COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count, + COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count, + COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id, + COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id, + COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts, + COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period, + COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date, + COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted, + COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id, + COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash, + COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id, + COALESCE(reply_ch.title, '')::text AS reply_channel_title, + COALESCE(reply_ch.about, '')::text AS reply_channel_about, + COALESCE(reply_ch.username, '')::text AS reply_channel_username, + COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast, + COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup, + COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum, + COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards, + COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures, + COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden, + COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds, + COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights, + COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count, + COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count, + COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count, + COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count, + COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id, + COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id, + COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts, + COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period, + COALESCE(reply_ch.date, 0)::int AS reply_channel_date, + COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted +FROM unnest($1::bigint[]) WITH ORDINALITY AS u(user_id, ord) +JOIN unnest($2::int[]) WITH ORDINALITY AS p(pts, ord) USING (ord) +JOIN user_update_events e ON e.user_id = u.user_id AND e.pts = p.pts +LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id +LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id +LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id +LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id +` + +type BatchListDispatchEventsParams struct { + UserIds []int64 + PtsList []int32 +} + +type BatchListDispatchEventsRow struct { + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + EventBool bool + EventPeersJson string + PeerSettingsJson string + MessageIdsJson string + DialogFilterJson string + FilterOrderJson string + FolderPeersJson string + EventPeerType string + EventPeerID int64 + FilterID int32 + MaxID int32 + StillUnreadCount int32 + TagsEnabled bool + MessageID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + MessageEntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + MediaJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FwdUserID int64 + FwdUserAccessHash int64 + FwdUserPhone string + FwdUserFirstName string + FwdUserLastName string + FwdUserUsername string + FwdUserCountryCode string + FwdUserVerified bool + FwdUserSupport bool + ReplyUserID int64 + ReplyUserAccessHash int64 + ReplyUserPhone string + ReplyUserFirstName string + ReplyUserLastName string + ReplyUserUsername string + ReplyUserCountryCode string + ReplyUserVerified bool + ReplyUserSupport bool + FwdChannelID int64 + FwdChannelAccessHash int64 + FwdChannelCreatorUserID int64 + FwdChannelTitle string + FwdChannelAbout string + FwdChannelUsername string + FwdChannelBroadcast bool + FwdChannelMegagroup bool + FwdChannelForum bool + FwdChannelNoforwards bool + FwdChannelSignatures bool + FwdChannelPreHistoryHidden bool + FwdChannelSlowmodeSeconds int32 + FwdChannelDefaultBannedRights string + FwdChannelParticipantsCount int32 + FwdChannelAdminsCount int32 + FwdChannelKickedCount int32 + FwdChannelBannedCount int32 + FwdChannelTopMessageID int32 + FwdChannelPinnedMessageID int32 + FwdChannelPts int32 + FwdChannelTtlPeriod int32 + FwdChannelDate int32 + FwdChannelDeleted bool + ReplyChannelID int64 + ReplyChannelAccessHash int64 + ReplyChannelCreatorUserID int64 + ReplyChannelTitle string + ReplyChannelAbout string + ReplyChannelUsername string + ReplyChannelBroadcast bool + ReplyChannelMegagroup bool + ReplyChannelForum bool + ReplyChannelNoforwards bool + ReplyChannelSignatures bool + ReplyChannelPreHistoryHidden bool + ReplyChannelSlowmodeSeconds int32 + ReplyChannelDefaultBannedRights string + ReplyChannelParticipantsCount int32 + ReplyChannelAdminsCount int32 + ReplyChannelKickedCount int32 + ReplyChannelBannedCount int32 + ReplyChannelTopMessageID int32 + ReplyChannelPinnedMessageID int32 + ReplyChannelPts int32 + ReplyChannelTtlPeriod int32 + ReplyChannelDate int32 + ReplyChannelDeleted bool +} + +// 按 (user_id, pts) 精确批量取账号事件,供 outbox worker 一次性加载一批 claim 的事件详情, +// 取代逐条 ListUserUpdateEventsAfter。列与 ListUserUpdateEventsAfter 完全一致以复用转换逻辑。 +func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDispatchEventsParams) ([]BatchListDispatchEventsRow, error) { + rows, err := q.db.Query(ctx, batchListDispatchEvents, arg.UserIds, arg.PtsList) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BatchListDispatchEventsRow + for rows.Next() { + var i BatchListDispatchEventsRow + if err := rows.Scan( + &i.UserID, + &i.Pts, + &i.PtsCount, + &i.Date, + &i.EventType, + &i.EventBool, + &i.EventPeersJson, + &i.PeerSettingsJson, + &i.MessageIdsJson, + &i.DialogFilterJson, + &i.FilterOrderJson, + &i.FolderPeersJson, + &i.EventPeerType, + &i.EventPeerID, + &i.FilterID, + &i.MaxID, + &i.StillUnreadCount, + &i.TagsEnabled, + &i.MessageID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.MessageEntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.MediaJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FwdUserID, + &i.FwdUserAccessHash, + &i.FwdUserPhone, + &i.FwdUserFirstName, + &i.FwdUserLastName, + &i.FwdUserUsername, + &i.FwdUserCountryCode, + &i.FwdUserVerified, + &i.FwdUserSupport, + &i.ReplyUserID, + &i.ReplyUserAccessHash, + &i.ReplyUserPhone, + &i.ReplyUserFirstName, + &i.ReplyUserLastName, + &i.ReplyUserUsername, + &i.ReplyUserCountryCode, + &i.ReplyUserVerified, + &i.ReplyUserSupport, + &i.FwdChannelID, + &i.FwdChannelAccessHash, + &i.FwdChannelCreatorUserID, + &i.FwdChannelTitle, + &i.FwdChannelAbout, + &i.FwdChannelUsername, + &i.FwdChannelBroadcast, + &i.FwdChannelMegagroup, + &i.FwdChannelForum, + &i.FwdChannelNoforwards, + &i.FwdChannelSignatures, + &i.FwdChannelPreHistoryHidden, + &i.FwdChannelSlowmodeSeconds, + &i.FwdChannelDefaultBannedRights, + &i.FwdChannelParticipantsCount, + &i.FwdChannelAdminsCount, + &i.FwdChannelKickedCount, + &i.FwdChannelBannedCount, + &i.FwdChannelTopMessageID, + &i.FwdChannelPinnedMessageID, + &i.FwdChannelPts, + &i.FwdChannelTtlPeriod, + &i.FwdChannelDate, + &i.FwdChannelDeleted, + &i.ReplyChannelID, + &i.ReplyChannelAccessHash, + &i.ReplyChannelCreatorUserID, + &i.ReplyChannelTitle, + &i.ReplyChannelAbout, + &i.ReplyChannelUsername, + &i.ReplyChannelBroadcast, + &i.ReplyChannelMegagroup, + &i.ReplyChannelForum, + &i.ReplyChannelNoforwards, + &i.ReplyChannelSignatures, + &i.ReplyChannelPreHistoryHidden, + &i.ReplyChannelSlowmodeSeconds, + &i.ReplyChannelDefaultBannedRights, + &i.ReplyChannelParticipantsCount, + &i.ReplyChannelAdminsCount, + &i.ReplyChannelKickedCount, + &i.ReplyChannelBannedCount, + &i.ReplyChannelTopMessageID, + &i.ReplyChannelPinnedMessageID, + &i.ReplyChannelPts, + &i.ReplyChannelTtlPeriod, + &i.ReplyChannelDate, + &i.ReplyChannelDeleted, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const claimDispatchOutbox = `-- name: ClaimDispatchOutbox :many +WITH picked AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE ( + status = 'pending' + AND next_attempt_at <= now() + ) + OR ( + status = 'dispatching' + AND updated_at < now() - make_interval(secs => $1::int) + ) + ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED +) +UPDATE dispatch_outbox d +SET + status = 'dispatching', + attempts = d.attempts + 1, + updated_at = now() +FROM picked p +WHERE d.target_user_id = p.target_user_id + AND d.id = p.id +RETURNING + d.id, + d.target_user_id, + d.pts, + d.event_type, + d.exclude_auth_key_id, + d.exclude_session_id, + d.attempts +` + +type ClaimDispatchOutboxParams struct { + LeaseSeconds int32 + LimitCount int32 +} + +type ClaimDispatchOutboxRow struct { + ID int64 + TargetUserID int64 + Pts int32 + EventType string + ExcludeAuthKeyID int64 + ExcludeSessionID int64 + Attempts int32 +} + +func (q *Queries) ClaimDispatchOutbox(ctx context.Context, arg ClaimDispatchOutboxParams) ([]ClaimDispatchOutboxRow, error) { + rows, err := q.db.Query(ctx, claimDispatchOutbox, arg.LeaseSeconds, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ClaimDispatchOutboxRow + for rows.Next() { + var i ClaimDispatchOutboxRow + if err := rows.Scan( + &i.ID, + &i.TargetUserID, + &i.Pts, + &i.EventType, + &i.ExcludeAuthKeyID, + &i.ExcludeSessionID, + &i.Attempts, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteFailedDispatchOutbox = `-- name: DeleteFailedDispatchOutbox :one +WITH doomed AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE status = 'failed' + AND updated_at < now() - make_interval(secs => $1::int) + ORDER BY updated_at ASC, target_user_id ASC, id ASC + LIMIT $2 +), +deleted AS ( + DELETE FROM dispatch_outbox d + USING doomed x + WHERE d.target_user_id = x.target_user_id + AND d.id = x.id + RETURNING d.id +) +SELECT count(*)::int AS deleted_count +FROM deleted +` + +type DeleteFailedDispatchOutboxParams struct { + OlderThanSeconds int32 + LimitCount int32 +} + +func (q *Queries) DeleteFailedDispatchOutbox(ctx context.Context, arg DeleteFailedDispatchOutboxParams) (int32, error) { + row := q.db.QueryRow(ctx, deleteFailedDispatchOutbox, arg.OlderThanSeconds, arg.LimitCount) + var deleted_count int32 + err := row.Scan(&deleted_count) + return deleted_count, err +} + +const enqueueDispatch = `-- name: EnqueueDispatch :exec +INSERT INTO dispatch_outbox ( + target_user_id, + pts, + event_type, + exclude_auth_key_id, + exclude_session_id +) VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT DO NOTHING +` + +type EnqueueDispatchParams struct { + TargetUserID int64 + Pts int32 + EventType string + ExcludeAuthKeyID int64 + ExcludeSessionID int64 +} + +func (q *Queries) EnqueueDispatch(ctx context.Context, arg EnqueueDispatchParams) error { + _, err := q.db.Exec(ctx, enqueueDispatch, + arg.TargetUserID, + arg.Pts, + arg.EventType, + arg.ExcludeAuthKeyID, + arg.ExcludeSessionID, + ) + return err +} + +const ensureUserUpdateWatermark = `-- name: EnsureUserUpdateWatermark :exec +INSERT INTO user_update_watermarks (user_id, contiguous_pts) +VALUES ($1, 0) +ON CONFLICT (user_id) DO NOTHING +` + +func (q *Queries) EnsureUserUpdateWatermark(ctx context.Context, userID int64) error { + _, err := q.db.Exec(ctx, ensureUserUpdateWatermark, userID) + return err +} + +const getUserUpdateWatermark = `-- name: GetUserUpdateWatermark :one +SELECT contiguous_pts +FROM user_update_watermarks +WHERE user_id = $1 +` + +func (q *Queries) GetUserUpdateWatermark(ctx context.Context, userID int64) (int32, error) { + row := q.db.QueryRow(ctx, getUserUpdateWatermark, userID) + var contiguous_pts int32 + err := row.Scan(&contiguous_pts) + return contiguous_pts, err +} + +const listUserUpdateEventsAfter = `-- name: ListUserUpdateEventsAfter :many +SELECT + e.user_id, + e.pts, + e.pts_count, + e.date, + e.event_type, + e.event_bool, + COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, + COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, + COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, + COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json, + COALESCE(e.filter_order::text, '[]')::text AS filter_order_json, + COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, + COALESCE(e.peer_type, '')::text AS event_peer_type, + COALESCE(e.peer_id, 0)::bigint AS event_peer_id, + e.filter_id, + e.max_id, + e.still_unread_count, + e.tags_enabled, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.private_message_id, 0)::bigint AS private_message_id, + COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id, + COALESCE(m.peer_type, '')::text AS peer_type, + COALESCE(m.peer_id, 0)::bigint AS peer_id, + COALESCE(m.from_user_id, 0)::bigint AS from_user_id, + COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.edit_date, 0)::int AS edit_date, + COALESCE(m.outgoing, false)::boolean AS outgoing, + COALESCE(m.body, '')::text AS body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + COALESCE(m.silent, false)::boolean AS silent, + COALESCE(m.noforwards, false)::boolean AS noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_id, + COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, + COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id, + COALESCE(m.quote_text, '')::text AS quote_text, + COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, + COALESCE(m.quote_offset, 0)::int AS quote_offset, + COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, + COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.media::text, '{}')::text AS media_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, + COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash, + COALESCE(fwd_u.phone, '')::text AS fwd_user_phone, + COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name, + COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name, + COALESCE(fwd_u.username, '')::text AS fwd_user_username, + COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code, + COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified, + COALESCE(fwd_u.support, false)::boolean AS fwd_user_support, + COALESCE(reply_u.id, 0)::bigint AS reply_user_id, + COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash, + COALESCE(reply_u.phone, '')::text AS reply_user_phone, + COALESCE(reply_u.first_name, '')::text AS reply_user_first_name, + COALESCE(reply_u.last_name, '')::text AS reply_user_last_name, + COALESCE(reply_u.username, '')::text AS reply_user_username, + COALESCE(reply_u.country_code, '')::text AS reply_user_country_code, + COALESCE(reply_u.verified, false)::boolean AS reply_user_verified, + COALESCE(reply_u.support, false)::boolean AS reply_user_support, + COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id, + COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash, + COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id, + COALESCE(fwd_ch.title, '')::text AS fwd_channel_title, + COALESCE(fwd_ch.about, '')::text AS fwd_channel_about, + COALESCE(fwd_ch.username, '')::text AS fwd_channel_username, + COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast, + COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup, + COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum, + COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards, + COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures, + COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden, + COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds, + COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights, + COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count, + COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count, + COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count, + COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count, + COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id, + COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id, + COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts, + COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period, + COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date, + COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted, + COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id, + COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash, + COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id, + COALESCE(reply_ch.title, '')::text AS reply_channel_title, + COALESCE(reply_ch.about, '')::text AS reply_channel_about, + COALESCE(reply_ch.username, '')::text AS reply_channel_username, + COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast, + COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup, + COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum, + COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards, + COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures, + COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden, + COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds, + COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights, + COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count, + COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count, + COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count, + COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count, + COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id, + COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id, + COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts, + COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period, + COALESCE(reply_ch.date, 0)::int AS reply_channel_date, + COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted +FROM user_update_events e +LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id +LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id +LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id +LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id +WHERE e.user_id = $1 + AND e.pts > $2 +ORDER BY e.pts ASC +LIMIT $3 +` + +type ListUserUpdateEventsAfterParams struct { + UserID int64 + Pts int32 + LimitCount int32 +} + +type ListUserUpdateEventsAfterRow struct { + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + EventBool bool + EventPeersJson string + PeerSettingsJson string + MessageIdsJson string + DialogFilterJson string + FilterOrderJson string + FolderPeersJson string + EventPeerType string + EventPeerID int64 + FilterID int32 + MaxID int32 + StillUnreadCount int32 + TagsEnabled bool + MessageID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + MessageEntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + MediaJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FwdUserID int64 + FwdUserAccessHash int64 + FwdUserPhone string + FwdUserFirstName string + FwdUserLastName string + FwdUserUsername string + FwdUserCountryCode string + FwdUserVerified bool + FwdUserSupport bool + ReplyUserID int64 + ReplyUserAccessHash int64 + ReplyUserPhone string + ReplyUserFirstName string + ReplyUserLastName string + ReplyUserUsername string + ReplyUserCountryCode string + ReplyUserVerified bool + ReplyUserSupport bool + FwdChannelID int64 + FwdChannelAccessHash int64 + FwdChannelCreatorUserID int64 + FwdChannelTitle string + FwdChannelAbout string + FwdChannelUsername string + FwdChannelBroadcast bool + FwdChannelMegagroup bool + FwdChannelForum bool + FwdChannelNoforwards bool + FwdChannelSignatures bool + FwdChannelPreHistoryHidden bool + FwdChannelSlowmodeSeconds int32 + FwdChannelDefaultBannedRights string + FwdChannelParticipantsCount int32 + FwdChannelAdminsCount int32 + FwdChannelKickedCount int32 + FwdChannelBannedCount int32 + FwdChannelTopMessageID int32 + FwdChannelPinnedMessageID int32 + FwdChannelPts int32 + FwdChannelTtlPeriod int32 + FwdChannelDate int32 + FwdChannelDeleted bool + ReplyChannelID int64 + ReplyChannelAccessHash int64 + ReplyChannelCreatorUserID int64 + ReplyChannelTitle string + ReplyChannelAbout string + ReplyChannelUsername string + ReplyChannelBroadcast bool + ReplyChannelMegagroup bool + ReplyChannelForum bool + ReplyChannelNoforwards bool + ReplyChannelSignatures bool + ReplyChannelPreHistoryHidden bool + ReplyChannelSlowmodeSeconds int32 + ReplyChannelDefaultBannedRights string + ReplyChannelParticipantsCount int32 + ReplyChannelAdminsCount int32 + ReplyChannelKickedCount int32 + ReplyChannelBannedCount int32 + ReplyChannelTopMessageID int32 + ReplyChannelPinnedMessageID int32 + ReplyChannelPts int32 + ReplyChannelTtlPeriod int32 + ReplyChannelDate int32 + ReplyChannelDeleted bool +} + +func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpdateEventsAfterParams) ([]ListUserUpdateEventsAfterRow, error) { + rows, err := q.db.Query(ctx, listUserUpdateEventsAfter, arg.UserID, arg.Pts, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUserUpdateEventsAfterRow + for rows.Next() { + var i ListUserUpdateEventsAfterRow + if err := rows.Scan( + &i.UserID, + &i.Pts, + &i.PtsCount, + &i.Date, + &i.EventType, + &i.EventBool, + &i.EventPeersJson, + &i.PeerSettingsJson, + &i.MessageIdsJson, + &i.DialogFilterJson, + &i.FilterOrderJson, + &i.FolderPeersJson, + &i.EventPeerType, + &i.EventPeerID, + &i.FilterID, + &i.MaxID, + &i.StillUnreadCount, + &i.TagsEnabled, + &i.MessageID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.MessageEntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.MediaJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FwdUserID, + &i.FwdUserAccessHash, + &i.FwdUserPhone, + &i.FwdUserFirstName, + &i.FwdUserLastName, + &i.FwdUserUsername, + &i.FwdUserCountryCode, + &i.FwdUserVerified, + &i.FwdUserSupport, + &i.ReplyUserID, + &i.ReplyUserAccessHash, + &i.ReplyUserPhone, + &i.ReplyUserFirstName, + &i.ReplyUserLastName, + &i.ReplyUserUsername, + &i.ReplyUserCountryCode, + &i.ReplyUserVerified, + &i.ReplyUserSupport, + &i.FwdChannelID, + &i.FwdChannelAccessHash, + &i.FwdChannelCreatorUserID, + &i.FwdChannelTitle, + &i.FwdChannelAbout, + &i.FwdChannelUsername, + &i.FwdChannelBroadcast, + &i.FwdChannelMegagroup, + &i.FwdChannelForum, + &i.FwdChannelNoforwards, + &i.FwdChannelSignatures, + &i.FwdChannelPreHistoryHidden, + &i.FwdChannelSlowmodeSeconds, + &i.FwdChannelDefaultBannedRights, + &i.FwdChannelParticipantsCount, + &i.FwdChannelAdminsCount, + &i.FwdChannelKickedCount, + &i.FwdChannelBannedCount, + &i.FwdChannelTopMessageID, + &i.FwdChannelPinnedMessageID, + &i.FwdChannelPts, + &i.FwdChannelTtlPeriod, + &i.FwdChannelDate, + &i.FwdChannelDeleted, + &i.ReplyChannelID, + &i.ReplyChannelAccessHash, + &i.ReplyChannelCreatorUserID, + &i.ReplyChannelTitle, + &i.ReplyChannelAbout, + &i.ReplyChannelUsername, + &i.ReplyChannelBroadcast, + &i.ReplyChannelMegagroup, + &i.ReplyChannelForum, + &i.ReplyChannelNoforwards, + &i.ReplyChannelSignatures, + &i.ReplyChannelPreHistoryHidden, + &i.ReplyChannelSlowmodeSeconds, + &i.ReplyChannelDefaultBannedRights, + &i.ReplyChannelParticipantsCount, + &i.ReplyChannelAdminsCount, + &i.ReplyChannelKickedCount, + &i.ReplyChannelBannedCount, + &i.ReplyChannelTopMessageID, + &i.ReplyChannelPinnedMessageID, + &i.ReplyChannelPts, + &i.ReplyChannelTtlPeriod, + &i.ReplyChannelDate, + &i.ReplyChannelDeleted, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockUserUpdateWatermark = `-- name: LockUserUpdateWatermark :one +SELECT contiguous_pts +FROM user_update_watermarks +WHERE user_id = $1 +FOR UPDATE +` + +func (q *Queries) LockUserUpdateWatermark(ctx context.Context, userID int64) (int32, error) { + row := q.db.QueryRow(ctx, lockUserUpdateWatermark, userID) + var contiguous_pts int32 + err := row.Scan(&contiguous_pts) + return contiguous_pts, err +} + +const markDispatchDelivered = `-- name: MarkDispatchDelivered :exec +DELETE FROM dispatch_outbox +WHERE target_user_id = $1 + AND id = $2 +` + +type MarkDispatchDeliveredParams struct { + TargetUserID int64 + ID int64 +} + +// 方案 A:投递成功即删除。outbox 是任务队列,delivered 行无保留价值 +// (消息在 message_boxes、离线补偿在 user_update_events),删除让表维持「未完成任务」小稳态。 +func (q *Queries) MarkDispatchDelivered(ctx context.Context, arg MarkDispatchDeliveredParams) error { + _, err := q.db.Exec(ctx, markDispatchDelivered, arg.TargetUserID, arg.ID) + return err +} + +const markDispatchDeliveredBatch = `-- name: MarkDispatchDeliveredBatch :exec +DELETE FROM dispatch_outbox d +USING unnest($1::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord) +JOIN unnest($2::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord) +WHERE d.target_user_id = tu.target_user_id + AND d.id = di.id +` + +type MarkDispatchDeliveredBatchParams struct { + TargetUserIds []int64 + Ids []int64 +} + +// 批量删除一批已投递的 (target_user_id, id);target_user_id 入 WHERE 保证分区裁剪。 +func (q *Queries) MarkDispatchDeliveredBatch(ctx context.Context, arg MarkDispatchDeliveredBatchParams) error { + _, err := q.db.Exec(ctx, markDispatchDeliveredBatch, arg.TargetUserIds, arg.Ids) + return err +} + +const markDispatchFailed = `-- name: MarkDispatchFailed :exec +UPDATE dispatch_outbox +SET + status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END, + next_attempt_at = CASE + WHEN attempts >= 5 THEN next_attempt_at + ELSE now() + make_interval(secs => LEAST(60, attempts * attempts)) + END, + last_error = $3, + updated_at = now() +WHERE target_user_id = $1 + AND id = $2 +` + +type MarkDispatchFailedParams struct { + TargetUserID int64 + ID int64 + LastError string +} + +func (q *Queries) MarkDispatchFailed(ctx context.Context, arg MarkDispatchFailedParams) error { + _, err := q.db.Exec(ctx, markDispatchFailed, arg.TargetUserID, arg.ID, arg.LastError) + return err +} + +const maxUserPts = `-- name: MaxUserPts :one +SELECT COALESCE(MAX(pts), 0)::int AS max_pts +FROM user_update_events +WHERE user_id = $1 +` + +func (q *Queries) MaxUserPts(ctx context.Context, userID int64) (int32, error) { + row := q.db.QueryRow(ctx, maxUserPts, userID) + var max_pts int32 + err := row.Scan(&max_pts) + return max_pts, err +} + +const nextUserPtsAfter = `-- name: NextUserPtsAfter :many +SELECT pts, pts_count +FROM user_update_events +WHERE user_id = $1 + AND pts > $2 +ORDER BY pts ASC +LIMIT $3 +` + +type NextUserPtsAfterParams struct { + UserID int64 + Pts int32 + LimitCount int32 +} + +type NextUserPtsAfterRow struct { + Pts int32 + PtsCount int32 +} + +func (q *Queries) NextUserPtsAfter(ctx context.Context, arg NextUserPtsAfterParams) ([]NextUserPtsAfterRow, error) { + rows, err := q.db.Query(ctx, nextUserPtsAfter, arg.UserID, arg.Pts, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NextUserPtsAfterRow + for rows.Next() { + var i NextUserPtsAfterRow + if err := rows.Scan(&i.Pts, &i.PtsCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const recentUserPts = `-- name: RecentUserPts :many +SELECT pts, pts_count +FROM user_update_events +WHERE user_id = $1 +ORDER BY pts DESC +LIMIT $2 +` + +type RecentUserPtsParams struct { + UserID int64 + WindowSize int32 +} + +type RecentUserPtsRow struct { + Pts int32 + PtsCount int32 +} + +// 取某 user 最近的一段 pts(降序),供计算「最大连续已提交 pts」用。 +// 只看顶部窗口:瞬时空洞只可能出现在最近在途事务区,窗口足够大即可覆盖其下方连续。 +func (q *Queries) RecentUserPts(ctx context.Context, arg RecentUserPtsParams) ([]RecentUserPtsRow, error) { + rows, err := q.db.Query(ctx, recentUserPts, arg.UserID, arg.WindowSize) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecentUserPtsRow + for rows.Next() { + var i RecentUserPtsRow + if err := rows.Scan(&i.Pts, &i.PtsCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const saveUserUpdateWatermark = `-- name: SaveUserUpdateWatermark :exec +INSERT INTO user_update_watermarks (user_id, contiguous_pts, updated_at) +VALUES ($1, $2, now()) +ON CONFLICT (user_id) DO UPDATE SET + contiguous_pts = GREATEST(user_update_watermarks.contiguous_pts, EXCLUDED.contiguous_pts), + updated_at = now() +` + +type SaveUserUpdateWatermarkParams struct { + UserID int64 + ContiguousPts int32 +} + +func (q *Queries) SaveUserUpdateWatermark(ctx context.Context, arg SaveUserUpdateWatermarkParams) error { + _, err := q.db.Exec(ctx, saveUserUpdateWatermark, arg.UserID, arg.ContiguousPts) + return err +} diff --git a/internal/store/postgres/temp_auth_key.go b/internal/store/postgres/temp_auth_key.go new file mode 100644 index 00000000..88a3e5f1 --- /dev/null +++ b/internal/store/postgres/temp_auth_key.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// TempAuthKeyBindingStore 用 PostgreSQL 实现 store.TempAuthKeyBindingStore。 +type TempAuthKeyBindingStore struct { + q *sqlcgen.Queries +} + +// NewTempAuthKeyBindingStore 基于 pgx 连接池(或事务)创建 TempAuthKeyBindingStore。 +func NewTempAuthKeyBindingStore(db sqlcgen.DBTX) *TempAuthKeyBindingStore { + return &TempAuthKeyBindingStore{q: sqlcgen.New(db)} +} + +func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error { + if err := s.q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{ + TempAuthKeyID: authKeyIDToInt64(b.TempAuthKeyID), + PermAuthKeyID: b.PermAuthKeyID, + Nonce: b.Nonce, + TempSessionID: b.TempSessionID, + ExpiresAt: int32(b.ExpiresAt), + EncryptedMessage: b.EncryptedMessage, + }); err != nil { + return fmt.Errorf("upsert temp auth key binding: %w", err) + } + return nil +} + +func (s *TempAuthKeyBindingStore) GetByTemp(ctx context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) { + row, err := s.q.GetTempAuthKeyBinding(ctx, authKeyIDToInt64(tempAuthKeyID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TempAuthKeyBinding{}, false, nil + } + return domain.TempAuthKeyBinding{}, false, fmt.Errorf("get temp auth key binding: %w", err) + } + return domain.TempAuthKeyBinding{ + TempAuthKeyID: authKeyIDFromInt64(row.TempAuthKeyID), + PermAuthKeyID: row.PermAuthKeyID, + Nonce: row.Nonce, + TempSessionID: row.TempSessionID, + ExpiresAt: int(row.ExpiresAt), + EncryptedMessage: append([]byte(nil), row.EncryptedMessage...), + }, true, nil +} diff --git a/internal/store/postgres/update_event.go b/internal/store/postgres/update_event.go new file mode 100644 index 00000000..89fb24cc --- /dev/null +++ b/internal/store/postgres/update_event.go @@ -0,0 +1,925 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// UpdateEventStore 用 PostgreSQL 实现 store.UpdateEventStore。 +type UpdateEventStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewUpdateEventStore 基于 pgx 连接池(或事务)创建 UpdateEventStore。 +func NewUpdateEventStore(db sqlcgen.DBTX) *UpdateEventStore { + return &UpdateEventStore{db: db, q: sqlcgen.New(db)} +} + +func (s *UpdateEventStore) Append(ctx context.Context, userID int64, event domain.UpdateEvent) error { + beginner, ok := s.db.(interface { + Begin(context.Context) (pgx.Tx, error) + }) + if !ok { + if err := appendUserUpdateEvent(ctx, s.q, userID, event); err != nil { + return fmt.Errorf("append update event: %w", err) + } + return nil + } + tx, err := beginner.Begin(ctx) + if err != nil { + return fmt.Errorf("begin append update event: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if err := appendUserUpdateEvent(ctx, sqlcgen.New(tx), userID, event); err != nil { + return fmt.Errorf("append update event: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit append update event: %w", err) + } + committed = true + return nil +} + +// AppendWithDispatch 将账号级 update 事件与在线投递 outbox 放入同一个 PG 事务。 +// 设置类 RPC 不像消息发送那样已有业务大事务;这里至少保证“事件已持久化”与 +// “可靠在线投递任务已入队”同生共死,避免进程在手动 push 前退出造成在线通知漏投。 +func (s *UpdateEventStore) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error { + beginner, ok := s.db.(interface { + Begin(context.Context) (pgx.Tx, error) + }) + if !ok { + if err := s.Append(ctx, userID, event); err != nil { + return err + } + if err := s.q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: userID, + Pts: int32(event.Pts), + EventType: string(event.Type), + ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID), + ExcludeSessionID: excludeSessionID, + }); err != nil { + return fmt.Errorf("enqueue dispatch: %w", err) + } + return nil + } + tx, err := beginner.Begin(ctx) + if err != nil { + return fmt.Errorf("begin append update dispatch: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + qtx := sqlcgen.New(tx) + if err := appendUserUpdateEvent(ctx, qtx, userID, event); err != nil { + return fmt.Errorf("append update event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: userID, + Pts: int32(event.Pts), + EventType: string(event.Type), + ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID), + ExcludeSessionID: excludeSessionID, + }); err != nil { + return fmt.Errorf("enqueue dispatch: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit append update dispatch: %w", err) + } + committed = true + return nil +} + +func appendUserUpdateEvent(ctx context.Context, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent) error { + var messageID *int32 + if event.Message.ID != 0 { + id := int32(event.Message.ID) + messageID = &id + } + var peerType *string + var peerID *int64 + peer := event.Peer + if peer.ID == 0 { + peer = event.Message.Peer + } + if peer.ID != 0 { + t := string(peer.Type) + id := peer.ID + peerType = &t + peerID = &id + } + peers, err := encodeEventPeers(event.Peers) + if err != nil { + return err + } + settings, err := encodePeerSettings(event.Settings) + if err != nil { + return err + } + messageIDs, err := encodeEventMessageIDs(event.MessageIDs) + if err != nil { + return err + } + dialogFilter, err := encodeEventDialogFilter(event.DialogFilter) + if err != nil { + return err + } + filterOrder, err := encodeEventFilterOrder(event.FilterOrder) + if err != nil { + return err + } + folderPeers, err := encodeEventFolderPeers(event.FolderPeers) + if err != nil { + return err + } + if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ + UserID: userID, + Pts: int32(event.Pts), + PtsCount: int32(event.PtsCount), + Date: int32(event.Date), + EventType: string(event.Type), + EventBool: event.Bool, + EventPeers: peers, + PeerSettings: settings, + MessageIds: messageIDs, + DialogFilter: dialogFilter, + FilterOrder: filterOrder, + FolderPeers: folderPeers, + MaxID: pgInt32NonNegative(event.MaxID), + StillUnreadCount: int32(event.StillUnreadCount), + FilterID: pgInt32NonNegative(event.FilterID), + TagsEnabled: event.TagsEnabled, + MessageBoxID: messageID, + PeerType: peerType, + PeerID: peerID, + }); err != nil { + return err + } + if _, err := advanceContiguousPts(ctx, q, userID); err != nil { + return fmt.Errorf("advance update watermark: %w", err) + } + return nil +} + +func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.q.ListUserUpdateEventsAfter(ctx, sqlcgen.ListUserUpdateEventsAfterParams{ + UserID: userID, + Pts: int32(pts), + LimitCount: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("list update events: %w", err) + } + out := make([]domain.UpdateEvent, 0, len(rows)) + for _, row := range rows { + entities, err := decodeMessageEntities(row.MessageEntitiesJson) + if err != nil { + return nil, fmt.Errorf("decode message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + if err != nil { + return nil, fmt.Errorf("decode message metadata: %w", err) + } + peers, err := decodeEventPeers(row.EventPeersJson) + if err != nil { + return nil, fmt.Errorf("decode event peers: %w", err) + } + settings, err := decodePeerSettings(row.PeerSettingsJson) + if err != nil { + return nil, fmt.Errorf("decode peer settings: %w", err) + } + messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson) + if err != nil { + return nil, fmt.Errorf("decode message ids: %w", err) + } + dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson) + if err != nil { + return nil, fmt.Errorf("decode dialog filter: %w", err) + } + filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson) + if err != nil { + return nil, fmt.Errorf("decode filter order: %w", err) + } + folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson) + if err != nil { + return nil, fmt.Errorf("decode folder peers: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return nil, fmt.Errorf("decode message media: %w", err) + } + out = append(out, domain.UpdateEvent{ + UserID: row.UserID, + Type: domain.UpdateEventType(row.EventType), + Pts: int(row.Pts), + PtsCount: int(row.PtsCount), + Date: int(row.Date), + Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID}, + Peers: peers, + Bool: row.EventBool, + Settings: settings, + MessageIDs: messageIDs, + MaxID: int(row.MaxID), + StillUnreadCount: int(row.StillUnreadCount), + FilterID: int(row.FilterID), + DialogFilter: dialogFilter, + FilterOrder: filterOrder, + FolderPeers: folderPeers, + TagsEnabled: row.TagsEnabled, + Message: domain.Message{ + ID: int(row.MessageID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Media: media, + }, + Users: usersFromUpdateEventRow(row), + Channels: channelsFromUpdateEventRow(row), + }) + } + return out, nil +} + +func (s *UpdateEventStore) Current(ctx context.Context, userID int64) (int, error) { + pts, err := s.q.MaxUserPts(ctx, userID) + if err != nil { + return 0, fmt.Errorf("max user pts: %w", err) + } + return int(pts), nil +} + +func (s *UpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) { + beginner, ok := s.db.(interface { + Begin(context.Context) (pgx.Tx, error) + }) + if !ok { + pts, err := advanceContiguousPts(ctx, s.q, userID) + if err != nil { + return 0, fmt.Errorf("advance update watermark: %w", err) + } + return pts, nil + } + tx, err := beginner.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin advance update watermark: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + pts, err := advanceContiguousPts(ctx, sqlcgen.New(tx), userID) + if err != nil { + return 0, fmt.Errorf("advance update watermark: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit advance update watermark: %w", err) + } + committed = true + return pts, nil +} + +// contiguousWindow 是计算最大连续 pts 时回看的顶部 pts 数量。 +// 瞬时空洞只来自最近在途的发送事务(提交即填实、回退即补 noop),单用户在途量远小于此, +// 故窗口内若无空洞即可认定窗口下方连续。生产极端高 fan-in 可调大。 +const contiguousWindow = 4096 + +// MaxContiguousPts 见 store.UpdateEventStore 接口说明。正常路径 O(1) 读账号水位; +// 缺行通常来自迁移前数据,允许一次性从 durable 事件计算并补写。 +func (s *UpdateEventStore) MaxContiguousPts(ctx context.Context, userID int64) (int, error) { + pts, err := s.q.GetUserUpdateWatermark(ctx, userID) + if err == nil { + return int(pts), nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return 0, fmt.Errorf("get update watermark: %w", err) + } + return s.AdvanceContiguousPts(ctx, userID) +} + +func advanceContiguousPts(ctx context.Context, q *sqlcgen.Queries, userID int64) (int, error) { + if err := q.EnsureUserUpdateWatermark(ctx, userID); err != nil { + return 0, err + } + locked, err := q.LockUserUpdateWatermark(ctx, userID) + if err != nil { + return 0, err + } + contiguous := int(locked) + for { + rows, err := q.NextUserPtsAfter(ctx, sqlcgen.NextUserPtsAfterParams{ + UserID: userID, + Pts: int32(contiguous), + LimitCount: contiguousWindow, + }) + if err != nil { + return 0, err + } + if len(rows) == 0 { + break + } + advanced := false + for _, row := range rows { + count := maxInt(int(row.PtsCount), 1) + expected := contiguous + count + if int(row.Pts) != expected { + if contiguous > int(locked) { + if err := saveUserUpdateWatermark(ctx, q, userID, contiguous); err != nil { + return 0, err + } + } + return contiguous, nil + } + contiguous = int(row.Pts) + advanced = true + } + if len(rows) < contiguousWindow || !advanced { + break + } + } + if contiguous > int(locked) { + if err := saveUserUpdateWatermark(ctx, q, userID, contiguous); err != nil { + return 0, err + } + } + return contiguous, nil +} + +func saveUserUpdateWatermark(ctx context.Context, q *sqlcgen.Queries, userID int64, contiguous int) error { + return q.SaveUserUpdateWatermark(ctx, sqlcgen.SaveUserUpdateWatermarkParams{ + UserID: userID, + ContiguousPts: int32(contiguous), + }) +} + +func computeContiguousPtsFromRecent(ctx context.Context, q *sqlcgen.Queries, userID int64) (int, error) { + rows, err := q.RecentUserPts(ctx, sqlcgen.RecentUserPtsParams{ + UserID: userID, + WindowSize: contiguousWindow, + }) + if err != nil { + return 0, fmt.Errorf("recent user pts: %w", err) + } + if len(rows) == 0 { + return 0, nil + } + nextByStart := make(map[int]int, len(rows)) + floor := int(rows[0].Pts) - maxInt(int(rows[0].PtsCount), 1) + for _, p := range rows { + count := maxInt(int(p.PtsCount), 1) + v := int(p.Pts) + start := v - count + nextByStart[start] = v + if start < floor { + floor = start + } + } + contiguous := floor + for { + next, ok := nextByStart[contiguous] + if !ok { + break + } + contiguous = next + } + return contiguous, nil +} + +// BatchByCursor 按 (user_id, pts) 一次性批量取多条账号事件,供 outbox worker 取代逐条 ListAfter。 +// 返回顺序不保证与 cursors 一致,调用方按 (UserID,Pts) 自行索引。 +func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) { + if len(cursors) == 0 { + return nil, nil + } + userIDs := make([]int64, len(cursors)) + ptsList := make([]int32, len(cursors)) + for i, c := range cursors { + userIDs[i] = c.UserID + ptsList[i] = int32(c.Pts) + } + rows, err := s.q.BatchListDispatchEvents(ctx, sqlcgen.BatchListDispatchEventsParams{ + UserIds: userIDs, + PtsList: ptsList, + }) + if err != nil { + return nil, fmt.Errorf("batch list dispatch events: %w", err) + } + out := make([]domain.UpdateEvent, 0, len(rows)) + for _, row := range rows { + entities, err := decodeMessageEntities(row.MessageEntitiesJson) + if err != nil { + return nil, fmt.Errorf("decode message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + ) + if err != nil { + return nil, fmt.Errorf("decode message metadata: %w", err) + } + peers, err := decodeEventPeers(row.EventPeersJson) + if err != nil { + return nil, fmt.Errorf("decode event peers: %w", err) + } + settings, err := decodePeerSettings(row.PeerSettingsJson) + if err != nil { + return nil, fmt.Errorf("decode peer settings: %w", err) + } + messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson) + if err != nil { + return nil, fmt.Errorf("decode message ids: %w", err) + } + dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson) + if err != nil { + return nil, fmt.Errorf("decode dialog filter: %w", err) + } + filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson) + if err != nil { + return nil, fmt.Errorf("decode filter order: %w", err) + } + folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson) + if err != nil { + return nil, fmt.Errorf("decode folder peers: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return nil, fmt.Errorf("decode message media: %w", err) + } + out = append(out, domain.UpdateEvent{ + UserID: row.UserID, + Type: domain.UpdateEventType(row.EventType), + Pts: int(row.Pts), + PtsCount: int(row.PtsCount), + Date: int(row.Date), + Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID}, + Peers: peers, + Bool: row.EventBool, + Settings: settings, + MessageIDs: messageIDs, + MaxID: int(row.MaxID), + StillUnreadCount: int(row.StillUnreadCount), + FilterID: int(row.FilterID), + DialogFilter: dialogFilter, + FilterOrder: filterOrder, + FolderPeers: folderPeers, + TagsEnabled: row.TagsEnabled, + Message: domain.Message{ + ID: int(row.MessageID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Media: media, + }, + Users: usersFromBatchDispatchRow(row), + Channels: channelsFromBatchDispatchRow(row), + }) + } + return out, nil +} + +func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.User { + return mergeEventUsers( + domain.User{ + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + }, + domain.User{ + ID: row.FromUserUserID, + AccessHash: row.FromUserAccessHash, + Phone: row.FromUserPhone, + FirstName: row.FromUserFirstName, + LastName: row.FromUserLastName, + Username: row.FromUserUsername, + CountryCode: row.FromUserCountryCode, + Verified: row.FromUserVerified, + Support: row.FromUserSupport, + }, + domain.User{ + ID: row.FwdUserID, + AccessHash: row.FwdUserAccessHash, + Phone: row.FwdUserPhone, + FirstName: row.FwdUserFirstName, + LastName: row.FwdUserLastName, + Username: row.FwdUserUsername, + CountryCode: row.FwdUserCountryCode, + Verified: row.FwdUserVerified, + Support: row.FwdUserSupport, + }, + domain.User{ + ID: row.ReplyUserID, + AccessHash: row.ReplyUserAccessHash, + Phone: row.ReplyUserPhone, + FirstName: row.ReplyUserFirstName, + LastName: row.ReplyUserLastName, + Username: row.ReplyUserUsername, + CountryCode: row.ReplyUserCountryCode, + Verified: row.ReplyUserVerified, + Support: row.ReplyUserSupport, + }, + ) +} + +// usersFromBatchDispatchRow 与 usersFromUpdateEventRow 等价,只是行类型为 BatchListDispatchEventsRow +// (两条查询列完全一致;改一处列时务必同步另一处)。 +func usersFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.User { + return mergeEventUsers( + domain.User{ + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + }, + domain.User{ + ID: row.FromUserUserID, + AccessHash: row.FromUserAccessHash, + Phone: row.FromUserPhone, + FirstName: row.FromUserFirstName, + LastName: row.FromUserLastName, + Username: row.FromUserUsername, + CountryCode: row.FromUserCountryCode, + Verified: row.FromUserVerified, + Support: row.FromUserSupport, + }, + domain.User{ + ID: row.FwdUserID, + AccessHash: row.FwdUserAccessHash, + Phone: row.FwdUserPhone, + FirstName: row.FwdUserFirstName, + LastName: row.FwdUserLastName, + Username: row.FwdUserUsername, + CountryCode: row.FwdUserCountryCode, + Verified: row.FwdUserVerified, + Support: row.FwdUserSupport, + }, + domain.User{ + ID: row.ReplyUserID, + AccessHash: row.ReplyUserAccessHash, + Phone: row.ReplyUserPhone, + FirstName: row.ReplyUserFirstName, + LastName: row.ReplyUserLastName, + Username: row.ReplyUserUsername, + CountryCode: row.ReplyUserCountryCode, + Verified: row.ReplyUserVerified, + Support: row.ReplyUserSupport, + }, + ) +} + +func channelsFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.Channel { + return mergeEventChannels( + eventChannelFromFields( + row.FwdChannelID, row.FwdChannelAccessHash, row.FwdChannelCreatorUserID, row.FwdChannelTitle, row.FwdChannelAbout, row.FwdChannelUsername, + row.FwdChannelBroadcast, row.FwdChannelMegagroup, row.FwdChannelForum, row.FwdChannelNoforwards, row.FwdChannelSignatures, row.FwdChannelPreHistoryHidden, + int(row.FwdChannelSlowmodeSeconds), row.FwdChannelDefaultBannedRights, int(row.FwdChannelParticipantsCount), int(row.FwdChannelAdminsCount), + int(row.FwdChannelKickedCount), int(row.FwdChannelBannedCount), int(row.FwdChannelTopMessageID), int(row.FwdChannelPinnedMessageID), + int(row.FwdChannelPts), int(row.FwdChannelTtlPeriod), int(row.FwdChannelDate), row.FwdChannelDeleted, + ), + eventChannelFromFields( + row.ReplyChannelID, row.ReplyChannelAccessHash, row.ReplyChannelCreatorUserID, row.ReplyChannelTitle, row.ReplyChannelAbout, row.ReplyChannelUsername, + row.ReplyChannelBroadcast, row.ReplyChannelMegagroup, row.ReplyChannelForum, row.ReplyChannelNoforwards, row.ReplyChannelSignatures, row.ReplyChannelPreHistoryHidden, + int(row.ReplyChannelSlowmodeSeconds), row.ReplyChannelDefaultBannedRights, int(row.ReplyChannelParticipantsCount), int(row.ReplyChannelAdminsCount), + int(row.ReplyChannelKickedCount), int(row.ReplyChannelBannedCount), int(row.ReplyChannelTopMessageID), int(row.ReplyChannelPinnedMessageID), + int(row.ReplyChannelPts), int(row.ReplyChannelTtlPeriod), int(row.ReplyChannelDate), row.ReplyChannelDeleted, + ), + ) +} + +func channelsFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.Channel { + return mergeEventChannels( + eventChannelFromFields( + row.FwdChannelID, row.FwdChannelAccessHash, row.FwdChannelCreatorUserID, row.FwdChannelTitle, row.FwdChannelAbout, row.FwdChannelUsername, + row.FwdChannelBroadcast, row.FwdChannelMegagroup, row.FwdChannelForum, row.FwdChannelNoforwards, row.FwdChannelSignatures, row.FwdChannelPreHistoryHidden, + int(row.FwdChannelSlowmodeSeconds), row.FwdChannelDefaultBannedRights, int(row.FwdChannelParticipantsCount), int(row.FwdChannelAdminsCount), + int(row.FwdChannelKickedCount), int(row.FwdChannelBannedCount), int(row.FwdChannelTopMessageID), int(row.FwdChannelPinnedMessageID), + int(row.FwdChannelPts), int(row.FwdChannelTtlPeriod), int(row.FwdChannelDate), row.FwdChannelDeleted, + ), + eventChannelFromFields( + row.ReplyChannelID, row.ReplyChannelAccessHash, row.ReplyChannelCreatorUserID, row.ReplyChannelTitle, row.ReplyChannelAbout, row.ReplyChannelUsername, + row.ReplyChannelBroadcast, row.ReplyChannelMegagroup, row.ReplyChannelForum, row.ReplyChannelNoforwards, row.ReplyChannelSignatures, row.ReplyChannelPreHistoryHidden, + int(row.ReplyChannelSlowmodeSeconds), row.ReplyChannelDefaultBannedRights, int(row.ReplyChannelParticipantsCount), int(row.ReplyChannelAdminsCount), + int(row.ReplyChannelKickedCount), int(row.ReplyChannelBannedCount), int(row.ReplyChannelTopMessageID), int(row.ReplyChannelPinnedMessageID), + int(row.ReplyChannelPts), int(row.ReplyChannelTtlPeriod), int(row.ReplyChannelDate), row.ReplyChannelDeleted, + ), + ) +} + +// mergeEventUsers 合并事件依赖用户,跳过 ID=0 并按 ID 去重。 +func mergeEventUsers(items ...domain.User) []domain.User { + users := make([]domain.User, 0, len(items)) + add := func(u domain.User) { + if u.ID == 0 { + return + } + for _, existing := range users { + if existing.ID == u.ID { + return + } + } + users = append(users, u) + } + for _, item := range items { + add(item) + } + return users +} + +func eventChannelFromFields(id, accessHash, creatorUserID int64, title, about, username string, broadcast, megagroup, forum, noforwards, signatures, preHistoryHidden bool, slowmodeSeconds int, defaultRights string, participantsCount, adminsCount, kickedCount, bannedCount, topMessageID, pinnedMessageID, pts, ttlPeriod, date int, deleted bool) domain.Channel { + if id == 0 { + return domain.Channel{} + } + ch := domain.Channel{ + ID: id, + AccessHash: accessHash, + CreatorUserID: creatorUserID, + Title: title, + About: about, + Username: username, + Broadcast: broadcast, + Megagroup: megagroup, + Forum: forum, + NoForwards: noforwards, + Signatures: signatures, + PreHistoryHidden: preHistoryHidden, + SlowmodeSeconds: slowmodeSeconds, + ParticipantsCount: participantsCount, + AdminsCount: adminsCount, + KickedCount: kickedCount, + BannedCount: bannedCount, + TopMessageID: topMessageID, + PinnedMessageID: pinnedMessageID, + Pts: pts, + TTLPeriod: ttlPeriod, + Date: date, + Deleted: deleted, + } + _ = json.Unmarshal([]byte(defaultRights), &ch.DefaultBannedRights) + return ch +} + +func mergeEventChannels(items ...domain.Channel) []domain.Channel { + channels := make([]domain.Channel, 0, len(items)) + seen := make(map[int64]struct{}, len(items)) + for _, ch := range items { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + channels = append(channels, ch) + } + return channels +} + +type eventPeerJSON struct { + Type string `json:"type"` + ID int64 `json:"id"` +} + +func encodeEventPeers(peers []domain.Peer) ([]byte, error) { + if len(peers) == 0 { + return []byte("[]"), nil + } + wire := make([]eventPeerJSON, 0, len(peers)) + for _, peer := range peers { + if peer.ID == 0 { + continue + } + wire = append(wire, eventPeerJSON{Type: string(peer.Type), ID: peer.ID}) + } + raw, err := json.Marshal(wire) + if err != nil { + return nil, fmt.Errorf("marshal event peers: %w", err) + } + return raw, nil +} + +func decodeEventPeers(raw string) ([]domain.Peer, error) { + if raw == "" { + return nil, nil + } + var wire []eventPeerJSON + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return nil, err + } + out := make([]domain.Peer, 0, len(wire)) + for _, peer := range wire { + if peer.ID == 0 { + continue + } + out = append(out, domain.Peer{Type: domain.PeerType(peer.Type), ID: peer.ID}) + } + return out, nil +} + +func encodeEventMessageIDs(ids []int) ([]byte, error) { + if len(ids) == 0 { + return []byte("[]"), nil + } + raw, err := json.Marshal(ids) + if err != nil { + return nil, fmt.Errorf("marshal event message ids: %w", err) + } + return raw, nil +} + +func decodeEventMessageIDs(raw string) ([]int, error) { + if raw == "" { + return nil, nil + } + var ids []int + if err := json.Unmarshal([]byte(raw), &ids); err != nil { + return nil, err + } + return ids, nil +} + +func encodeEventDialogFilter(folder *domain.DialogFolder) ([]byte, error) { + if folder == nil { + return []byte("{}"), nil + } + raw, err := json.Marshal(folder) + if err != nil { + return nil, fmt.Errorf("marshal event dialog filter: %w", err) + } + return raw, nil +} + +func decodeEventDialogFilter(raw string) (*domain.DialogFolder, error) { + if raw == "" || raw == "{}" { + return nil, nil + } + var folder domain.DialogFolder + if err := json.Unmarshal([]byte(raw), &folder); err != nil { + return nil, err + } + return &folder, nil +} + +func encodeEventFilterOrder(order []int) ([]byte, error) { + if len(order) == 0 { + return []byte("[]"), nil + } + raw, err := json.Marshal(order) + if err != nil { + return nil, fmt.Errorf("marshal event filter order: %w", err) + } + return raw, nil +} + +func decodeEventFilterOrder(raw string) ([]int, error) { + if raw == "" { + return nil, nil + } + var order []int + if err := json.Unmarshal([]byte(raw), &order); err != nil { + return nil, err + } + return order, nil +} + +func encodeEventFolderPeers(peers []domain.FolderPeerUpdate) ([]byte, error) { + if len(peers) == 0 { + return []byte("[]"), nil + } + raw, err := json.Marshal(peers) + if err != nil { + return nil, fmt.Errorf("marshal event folder peers: %w", err) + } + return raw, nil +} + +func decodeEventFolderPeers(raw string) ([]domain.FolderPeerUpdate, error) { + if raw == "" { + return nil, nil + } + var peers []domain.FolderPeerUpdate + if err := json.Unmarshal([]byte(raw), &peers); err != nil { + return nil, err + } + return peers, nil +} + +type peerSettingsJSON struct { + AddContact bool `json:"add_contact,omitempty"` + BlockContact bool `json:"block_contact,omitempty"` + ShareContact bool `json:"share_contact,omitempty"` + NeedContactsException bool `json:"need_contacts_exception,omitempty"` + HiddenPeerSettingsBar bool `json:"hidden_peer_settings_bar,omitempty"` +} + +func encodePeerSettings(settings domain.PeerSettings) ([]byte, error) { + raw, err := json.Marshal(peerSettingsJSON{ + AddContact: settings.AddContact, + BlockContact: settings.BlockContact, + ShareContact: settings.ShareContact, + NeedContactsException: settings.NeedContactsException, + HiddenPeerSettingsBar: settings.HiddenPeerSettingsBar, + }) + if err != nil { + return nil, fmt.Errorf("marshal peer settings: %w", err) + } + return raw, nil +} + +func decodePeerSettings(raw string) (domain.PeerSettings, error) { + if raw == "" { + return domain.PeerSettings{}, nil + } + var wire peerSettingsJSON + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return domain.PeerSettings{}, err + } + return domain.PeerSettings{ + AddContact: wire.AddContact, + BlockContact: wire.BlockContact, + ShareContact: wire.ShareContact, + NeedContactsException: wire.NeedContactsException, + HiddenPeerSettingsBar: wire.HiddenPeerSettingsBar, + }, nil +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/store/postgres/updatestate.go b/internal/store/postgres/updatestate.go new file mode 100644 index 00000000..e8213fb3 --- /dev/null +++ b/internal/store/postgres/updatestate.go @@ -0,0 +1,72 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// UpdateStateStore 用 PostgreSQL 实现 store.UpdateStateStore。 +type UpdateStateStore struct { + q *sqlcgen.Queries +} + +// NewUpdateStateStore 基于 pgx 连接池(或事务)创建 UpdateStateStore。 +func NewUpdateStateStore(db sqlcgen.DBTX) *UpdateStateStore { + return &UpdateStateStore{q: sqlcgen.New(db)} +} + +func (s *UpdateStateStore) Get(ctx context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) { + row, err := s.q.GetUpdateState(ctx, sqlcgen.GetUpdateStateParams{ + AuthKeyID: authKeyIDToInt64(id), + UserID: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.UpdateState{}, false, nil + } + return domain.UpdateState{}, false, fmt.Errorf("get update state: %w", err) + } + return domain.UpdateState{ + Pts: int(row.Pts), + Qts: int(row.Qts), + Date: int(row.Date), + Seq: int(row.Seq), + }, true, nil +} + +func (s *UpdateStateStore) Save(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState) error { + if err := s.q.UpsertUpdateState(ctx, sqlcgen.UpsertUpdateStateParams{ + AuthKeyID: authKeyIDToInt64(id), + UserID: userID, + Pts: int32(st.Pts), + Qts: int32(st.Qts), + Date: int32(st.Date), + Seq: int32(st.Seq), + }); err != nil { + return fmt.Errorf("upsert update state: %w", err) + } + return nil +} + +func (s *UpdateStateStore) Delete(ctx context.Context, id [8]byte, userID int64) error { + if err := s.q.DeleteUpdateState(ctx, sqlcgen.DeleteUpdateStateParams{ + AuthKeyID: authKeyIDToInt64(id), + UserID: userID, + }); err != nil { + return fmt.Errorf("delete update state: %w", err) + } + return nil +} + +func (s *UpdateStateStore) DeleteAuthKey(ctx context.Context, id [8]byte) error { + if err := s.q.DeleteUpdateStatesByAuthKey(ctx, authKeyIDToInt64(id)); err != nil { + return fmt.Errorf("delete update states by auth key: %w", err) + } + return nil +} diff --git a/internal/store/postgres/user.go b/internal/store/postgres/user.go new file mode 100644 index 00000000..e445bc47 --- /dev/null +++ b/internal/store/postgres/user.go @@ -0,0 +1,234 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// UserStore 用 PostgreSQL 实现 store.UserStore。 +type UserStore struct { + q *sqlcgen.Queries +} + +// NewUserStore 基于 pgx 连接池(或事务)创建 UserStore。 +func NewUserStore(db sqlcgen.DBTX) *UserStore { + return &UserStore{q: sqlcgen.New(db)} +} + +func (s *UserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) { + row, err := s.q.GetUserByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, false, nil + } + return domain.User{}, false, fmt.Errorf("get user by id: %w", err) + } + return userFromModel(row), true, nil +} + +func (s *UserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := s.q.GetUsersByIDs(ctx, ids) + if err != nil { + return nil, fmt.Errorf("get users by ids: %w", err) + } + out := make([]domain.User, 0, len(rows)) + for _, row := range rows { + out = append(out, userFromModel(row)) + } + return out, nil +} + +func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) { + row, err := s.q.GetUserByPhone(ctx, phone) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, false, nil + } + return domain.User{}, false, fmt.Errorf("get user by phone: %w", err) + } + return userFromModel(row), true, nil +} + +func (s *UserStore) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) { + if len(phones) == 0 { + return nil, nil + } + rows, err := s.q.GetUsersByPhones(ctx, phones) + if err != nil { + return nil, fmt.Errorf("get users by phones: %w", err) + } + out := make([]domain.User, 0, len(rows)) + for _, row := range rows { + out = append(out, userFromModel(row)) + } + return out, nil +} + +func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) { + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + if username == "" { + return domain.User{}, false, nil + } + row, err := s.q.GetUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, false, nil + } + return domain.User{}, false, fmt.Errorf("get user by username: %w", err) + } + return userFromModel(row), true, nil +} + +func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) { + query = strings.ToLower(strings.TrimSpace(query)) + if currentUserID == 0 || query == "" { + return domain.UserSearchResult{}, nil + } + if limit <= 0 || limit > 50 { + limit = 50 + } + rows, err := s.q.SearchUsers(ctx, sqlcgen.SearchUsersParams{ + CurrentUserID: currentUserID, + QueryLower: query, + QueryLike: escapeLike(query), + PhoneQuery: phoneQuery, + LimitCount: int32(limit), + }) + if err != nil { + return domain.UserSearchResult{}, fmt.Errorf("search users: %w", err) + } + out := domain.UserSearchResult{ + MyResults: make([]domain.User, 0, len(rows)), + Results: make([]domain.User, 0, len(rows)), + } + for _, row := range rows { + u := domain.User{ + ID: row.ID, + AccessHash: row.AccessHash, + Phone: row.Phone, + FirstName: row.FirstName, + LastName: row.LastName, + About: row.About, + Username: row.Username, + CountryCode: row.CountryCode, + Verified: row.Verified, + Support: row.Support, + LastSeenAt: int(row.LastSeenAt), + Contact: row.Contact, + Mutual: row.Mutual, + } + if row.Contact { + out.MyResults = append(out.MyResults, u) + } else { + out.Results = append(out.Results, u) + } + } + return out, nil +} + +func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error) { + row, err := s.q.UpdateUserProfile(ctx, sqlcgen.UpdateUserProfileParams{ + ID: userID, + FirstName: firstName, + LastName: lastName, + About: about, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrFirstNameInvalid + } + return domain.User{}, fmt.Errorf("update user profile: %w", err) + } + return userFromModel(row), nil +} + +func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) { + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + row, err := s.q.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{ + ID: userID, + Username: username, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" { + return domain.User{}, domain.ErrUsernameOccupied + } + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUsernameNotOccupied + } + return domain.User{}, fmt.Errorf("update user username: %w", err) + } + return userFromModel(row), nil +} + +func (s *UserStore) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error { + if lastSeenAt <= 0 { + return nil + } + if err := s.q.UpdateUserLastSeen(ctx, sqlcgen.UpdateUserLastSeenParams{ + ID: userID, + LastSeenAt: int64(lastSeenAt), + }); err != nil { + return fmt.Errorf("update user last seen: %w", err) + } + return nil +} + +func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, error) { + row, err := s.q.CreateUser(ctx, sqlcgen.CreateUserParams{ + AccessHash: u.AccessHash, + Phone: u.Phone, + FirstName: u.FirstName, + LastName: u.LastName, + Username: u.Username, + CountryCode: u.CountryCode, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" { + return domain.User{}, domain.ErrUsernameOccupied + } + return domain.User{}, fmt.Errorf("create user: %w", err) + } + return userFromModel(row), nil +} + +func escapeLike(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r == '%' || r == '_' || r == '\\' { + b.WriteRune('\\') + } + b.WriteRune(r) + } + return b.String() +} + +func userFromModel(r sqlcgen.User) domain.User { + return domain.User{ + ID: r.ID, + AccessHash: r.AccessHash, + Phone: r.Phone, + FirstName: r.FirstName, + LastName: r.LastName, + About: r.About, + Username: r.Username, + CountryCode: r.CountryCode, + Verified: r.Verified, + Support: r.Support, + LastSeenAt: int(r.LastSeenAt), + } +} diff --git a/internal/store/ratelimit.go b/internal/store/ratelimit.go new file mode 100644 index 00000000..069facfb --- /dev/null +++ b/internal/store/ratelimit.go @@ -0,0 +1,11 @@ +package store + +import ( + "context" + "time" +) + +// RateLimiter 提供轻量窗口限流。返回 retryAfterSeconds 供 RPC 映射 FLOOD_WAIT_X。 +type RateLimiter interface { + Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error) +} diff --git a/internal/store/redisstore/allocator.go b/internal/store/redisstore/allocator.go new file mode 100644 index 00000000..ab051b01 --- /dev/null +++ b/internal/store/redisstore/allocator.go @@ -0,0 +1,303 @@ +package redisstore + +import ( + "context" + "errors" + "fmt" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/store" +) + +// PtsAllocator 用 Redis INCR 分配账号级 pts,Redis 丢失时从 PG durable log 恢复当前最大 pts。 +type PtsAllocator struct { + counter counterAllocator +} + +// BoxIDAllocator 用 Redis INCR 分配 owner 视角的 message box id。 +type BoxIDAllocator struct { + counter counterAllocator +} + +// ChannelPtsAllocator 用 Redis INCR 分配 channel 维度 pts。 +type ChannelPtsAllocator struct { + counter counterAllocator +} + +// ChannelIDAllocator 用 Redis INCR 分配全局 channel/supergroup id。 +type ChannelIDAllocator struct { + counter counterAllocator +} + +// ChannelMessageIDAllocator 用 Redis INCR 分配 channel 维度 message id。 +type ChannelMessageIDAllocator struct { + counter counterAllocator +} + +type counterAllocator struct { + c *redis.Client + source store.CounterSource + key func(int64) string + name string +} + +const missingCounterSentinel int64 = -1 + +var ( + counterNextScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if current then + return redis.call("INCR", KEYS[1]) +end +return -1 +`) + + counterNextByScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if current then + return redis.call("INCRBY", KEYS[1], ARGV[1]) +end +return -1 +`) + + counterRecoverCurrentScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if current then + return tonumber(current) +end +redis.call("SET", KEYS[1], ARGV[1]) +return tonumber(ARGV[1]) +`) + + counterRecoverNextScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if not current then + redis.call("SET", KEYS[1], ARGV[1]) +end +return redis.call("INCR", KEYS[1]) +`) + + counterRecoverNextByScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if not current then + redis.call("SET", KEYS[1], ARGV[1]) +end +return redis.call("INCRBY", KEYS[1], ARGV[2]) +`) +) + +// NewPtsAllocator 创建 Redis-backed pts allocator。 +func NewPtsAllocator(c *redis.Client, source store.CounterSource) *PtsAllocator { + return &PtsAllocator{counter: counterAllocator{ + c: c, + source: source, + key: ptsKey, + name: "pts", + }} +} + +// NewBoxIDAllocator 创建 Redis-backed message box id allocator。 +func NewBoxIDAllocator(c *redis.Client, source store.CounterSource) *BoxIDAllocator { + return &BoxIDAllocator{counter: counterAllocator{ + c: c, + source: source, + key: boxIDKey, + name: "box_id", + }} +} + +// NewChannelPtsAllocator 创建 Redis-backed channel pts allocator。 +func NewChannelPtsAllocator(c *redis.Client, source store.CounterSource) *ChannelPtsAllocator { + return &ChannelPtsAllocator{counter: counterAllocator{ + c: c, + source: source, + key: channelPtsKey, + name: "channel_pts", + }} +} + +// NewChannelIDAllocator 创建 Redis-backed channel id allocator。 +func NewChannelIDAllocator(c *redis.Client, source store.CounterSource) *ChannelIDAllocator { + return &ChannelIDAllocator{counter: counterAllocator{ + c: c, + source: source, + key: channelIDKey, + name: "channel_id", + }} +} + +// NewChannelMessageIDAllocator 创建 Redis-backed channel message id allocator。 +func NewChannelMessageIDAllocator(c *redis.Client, source store.CounterSource) *ChannelMessageIDAllocator { + return &ChannelMessageIDAllocator{counter: counterAllocator{ + c: c, + source: source, + key: channelMessageIDKey, + name: "channel_msg_id", + }} +} + +func ptsKey(userID int64) string { + return fmt.Sprintf("counter:pts:{%d}", userID) +} + +func boxIDKey(userID int64) string { + return fmt.Sprintf("counter:box_id:{%d}", userID) +} + +func channelPtsKey(channelID int64) string { + return fmt.Sprintf("counter:channel_pts:{%d}", channelID) +} + +func channelIDKey(_ int64) string { + return "counter:channel_id" +} + +func channelMessageIDKey(channelID int64) string { + return fmt.Sprintf("counter:channel_msg_id:{%d}", channelID) +} + +func (a *PtsAllocator) NextPts(ctx context.Context, userID int64) (int, error) { + v, err := a.counter.next(ctx, userID) + return int(v), err +} + +func (a *PtsAllocator) NextPtsN(ctx context.Context, userID int64, count int) (int, error) { + v, err := a.counter.nextBy(ctx, userID, count) + return int(v), err +} + +func (a *PtsAllocator) CurrentPts(ctx context.Context, userID int64) (int, error) { + v, err := a.counter.current(ctx, userID) + return int(v), err +} + +func (a *BoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) { + v, err := a.counter.next(ctx, userID) + return int(v), err +} + +func (a *BoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) { + v, err := a.counter.current(ctx, userID) + return int(v), err +} + +func (a *ChannelPtsAllocator) NextChannelPts(ctx context.Context, channelID int64) (int, error) { + v, err := a.counter.next(ctx, channelID) + return int(v), err +} + +func (a *ChannelPtsAllocator) NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) { + v, err := a.counter.nextBy(ctx, channelID, count) + return int(v), err +} + +func (a *ChannelPtsAllocator) CurrentChannelPts(ctx context.Context, channelID int64) (int, error) { + v, err := a.counter.current(ctx, channelID) + return int(v), err +} + +func (a *ChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) { + return a.counter.next(ctx, 1) +} + +func (a *ChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) { + return a.counter.current(ctx, 1) +} + +func (a *ChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) { + v, err := a.counter.next(ctx, channelID) + return int(v), err +} + +func (a *ChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) { + v, err := a.counter.current(ctx, channelID) + return int(v), err +} + +func (a counterAllocator) next(ctx context.Context, userID int64) (int64, error) { + return a.nextBy(ctx, userID, 1) +} + +func (a counterAllocator) nextBy(ctx context.Context, userID int64, count int) (int64, error) { + if count <= 0 { + return 0, fmt.Errorf("redis next %s counter: invalid count %d", a.name, count) + } + key, err := a.validatedKey(userID) + if err != nil { + return 0, err + } + script := counterNextScript + args := []any{} + if count > 1 { + script = counterNextByScript + args = append(args, count) + } + v, err := script.Run(ctx, a.c, []string{key}, args...).Int64() + if err != nil { + return 0, fmt.Errorf("redis next %s counter: %w", a.name, err) + } + if v != missingCounterSentinel { + return v, nil + } + recovered, err := a.recovered(ctx, userID) + if err != nil { + return 0, err + } + recoverScript := counterRecoverNextScript + recoverArgs := []any{recovered} + if count > 1 { + recoverScript = counterRecoverNextByScript + recoverArgs = append(recoverArgs, count) + } + v, err = recoverScript.Run(ctx, a.c, []string{key}, recoverArgs...).Int64() + if err != nil { + return 0, fmt.Errorf("redis recover-next %s counter: %w", a.name, err) + } + return v, nil +} + +func (a counterAllocator) current(ctx context.Context, userID int64) (int64, error) { + key, err := a.validatedKey(userID) + if err != nil { + return 0, err + } + v, err := a.c.Get(ctx, key).Int64() + if err == nil { + return v, nil + } + if !errors.Is(err, redis.Nil) { + return 0, fmt.Errorf("redis get %s counter: %w", a.name, err) + } + recovered, err := a.recovered(ctx, userID) + if err != nil { + return 0, err + } + v, err = counterRecoverCurrentScript.Run(ctx, a.c, []string{key}, recovered).Int64() + if err != nil { + return 0, fmt.Errorf("redis recover-current %s counter: %w", a.name, err) + } + return v, nil +} + +func (a counterAllocator) validatedKey(userID int64) (string, error) { + if userID == 0 { + return "", fmt.Errorf("redis %s counter: missing user id", a.name) + } + if a.c == nil { + return "", fmt.Errorf("redis %s counter: nil client", a.name) + } + return a.key(userID), nil +} + +func (a counterAllocator) recovered(ctx context.Context, userID int64) (int, error) { + recovered := 0 + var err error + if a.source != nil { + recovered, err = a.source.Current(ctx, userID) + if err != nil { + return 0, fmt.Errorf("recover %s counter: %w", a.name, err) + } + } + return recovered, nil +} diff --git a/internal/store/redisstore/allocator_integration_test.go b/internal/store/redisstore/allocator_integration_test.go new file mode 100644 index 00000000..bdcb5956 --- /dev/null +++ b/internal/store/redisstore/allocator_integration_test.go @@ -0,0 +1,160 @@ +package redisstore + +import ( + "context" + "os" + "sync" + "testing" + "time" +) + +type staticCounterSource struct { + value int +} + +func (s staticCounterSource) Current(context.Context, int64) (int, error) { + return s.value, nil +} + +func TestRedisAllocatorsRecoverFromCounterSource(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + ctx := context.Background() + c, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + + userID := time.Now().UnixNano() + t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID), boxIDKey(userID)).Err() }) + + pts := NewPtsAllocator(c, staticCounterSource{value: 41}) + currentPts, err := pts.CurrentPts(ctx, userID) + if err != nil { + t.Fatalf("CurrentPts: %v", err) + } + if currentPts != 41 { + t.Fatalf("current pts = %d, want recovered 41", currentPts) + } + nextPts, err := pts.NextPts(ctx, userID) + if err != nil { + t.Fatalf("NextPts: %v", err) + } + if nextPts != 42 { + t.Fatalf("next pts = %d, want 42", nextPts) + } + + boxes := NewBoxIDAllocator(c, staticCounterSource{value: 100}) + currentBox, err := boxes.CurrentBoxID(ctx, userID) + if err != nil { + t.Fatalf("CurrentBoxID: %v", err) + } + if currentBox != 100 { + t.Fatalf("current box = %d, want recovered 100", currentBox) + } + nextBox, err := boxes.NextBoxID(ctx, userID) + if err != nil { + t.Fatalf("NextBoxID: %v", err) + } + if nextBox != 101 { + t.Fatalf("next box = %d, want 101", nextBox) + } +} + +func TestRedisAllocatorConcurrentFirstUse(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + ctx := context.Background() + c, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + + userID := time.Now().UnixNano() + t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID)).Err() }) + + const workers = 32 + pts := NewPtsAllocator(c, staticCounterSource{value: 1000}) + values := make(chan int, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + v, err := pts.NextPts(ctx, userID) + if err != nil { + errs <- err + return + } + values <- v + }() + } + wg.Wait() + close(values) + close(errs) + + for err := range errs { + t.Fatalf("NextPts: %v", err) + } + seen := make(map[int]bool, workers) + for v := range values { + if v < 1001 || v > 1000+workers { + t.Fatalf("pts = %d, want recovered contiguous range", v) + } + if seen[v] { + t.Fatalf("duplicate pts %d", v) + } + seen[v] = true + } + for want := 1001; want <= 1000+workers; want++ { + if !seen[want] { + t.Fatalf("missing pts %d", want) + } + } + current, err := pts.CurrentPts(ctx, userID) + if err != nil { + t.Fatalf("CurrentPts: %v", err) + } + if current != 1000+workers { + t.Fatalf("current pts = %d, want %d", current, 1000+workers) + } +} + +func TestRedisRateLimiterWindow(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + ctx := context.Background() + c, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + + key := "test:" + time.Now().Format("150405.000000000") + t.Cleanup(func() { _ = c.Del(ctx, rateLimitKey(key)).Err() }) + + limiter := NewRateLimiter(c) + allowed, retry, err := limiter.Allow(ctx, key, 1, time.Minute) + if err != nil { + t.Fatalf("Allow first: %v", err) + } + if !allowed || retry != 0 { + t.Fatalf("first allowed=%v retry=%d, want allowed", allowed, retry) + } + allowed, retry, err = limiter.Allow(ctx, key, 1, time.Minute) + if err != nil { + t.Fatalf("Allow second: %v", err) + } + if allowed || retry <= 0 { + t.Fatalf("second allowed=%v retry=%d, want limited with retry", allowed, retry) + } +} diff --git a/internal/store/redisstore/code.go b/internal/store/redisstore/code.go new file mode 100644 index 00000000..c2d83808 --- /dev/null +++ b/internal/store/redisstore/code.go @@ -0,0 +1,55 @@ +package redisstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/store" +) + +// CodeStore 用 Redis 实现 store.CodeStore(验证码带 TTL 自动过期)。 +type CodeStore struct { + c *redis.Client +} + +// NewCodeStore 创建 Redis CodeStore。 +func NewCodeStore(c *redis.Client) *CodeStore { + return &CodeStore{c: c} +} + +func codeKey(hash string) string { return "phonecode:" + hash } + +func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error { + v, err := json.Marshal(code) + if err != nil { + return fmt.Errorf("marshal phone code: %w", err) + } + if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil { + return fmt.Errorf("redis set phone code: %w", err) + } + return nil +} + +func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool, error) { + raw, err := s.c.Get(ctx, codeKey(hash)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return store.PhoneCode{}, false, nil + } + return store.PhoneCode{}, false, fmt.Errorf("redis get phone code: %w", err) + } + var code store.PhoneCode + if err := json.Unmarshal(raw, &code); err != nil { + return store.PhoneCode{}, false, fmt.Errorf("unmarshal phone code: %w", err) + } + return code, true, nil +} + +func (s *CodeStore) Del(ctx context.Context, hash string) error { + return s.c.Del(ctx, codeKey(hash)).Err() +} diff --git a/internal/store/redisstore/ratelimit.go b/internal/store/redisstore/ratelimit.go new file mode 100644 index 00000000..1b5a948d --- /dev/null +++ b/internal/store/redisstore/ratelimit.go @@ -0,0 +1,57 @@ +package redisstore + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/redis/go-redis/v9" +) + +// RateLimiter 用 Redis INCR + TTL 实现固定窗口限流。 +type RateLimiter struct { + c *redis.Client +} + +// NewRateLimiter 创建 Redis-backed RateLimiter。 +func NewRateLimiter(c *redis.Client) *RateLimiter { + return &RateLimiter{c: c} +} + +func rateLimitKey(key string) string { + return "ratelimit:" + key +} + +func (l *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) { + if limit <= 0 { + return true, 0, nil + } + if window <= 0 { + window = time.Second + } + if l == nil || l.c == nil { + return false, 0, fmt.Errorf("redis rate limiter: nil client") + } + redisKey := rateLimitKey(key) + count, err := l.c.Incr(ctx, redisKey).Result() + if err != nil { + return false, 0, fmt.Errorf("redis incr rate limit: %w", err) + } + if count == 1 { + if err := l.c.Expire(ctx, redisKey, window).Err(); err != nil { + return false, 0, fmt.Errorf("redis expire rate limit: %w", err) + } + } + if count <= int64(limit) { + return true, 0, nil + } + ttl, err := l.c.TTL(ctx, redisKey).Result() + if err != nil { + return false, 0, fmt.Errorf("redis ttl rate limit: %w", err) + } + if ttl <= 0 { + ttl = window + } + return false, int(math.Ceil(ttl.Seconds())), nil +} diff --git a/internal/store/redisstore/redis.go b/internal/store/redisstore/redis.go new file mode 100644 index 00000000..f7491907 --- /dev/null +++ b/internal/store/redisstore/redis.go @@ -0,0 +1,25 @@ +// Package redisstore 用 Redis 实现高频易失态的存储接口(第一阶段:SessionStore)。 +// +// 职责边界见 docs/persistence-layer.md §1:Redis 存「态与计数」,丢失可由 PG/协议恢复。 +package redisstore + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +// Open 按地址建立 Redis 连接并 ping 验证。 +func Open(ctx context.Context, addr, password string, db int) (*redis.Client, error) { + c := redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + DB: db, + }) + if err := c.Ping(ctx).Err(); err != nil { + _ = c.Close() + return nil, fmt.Errorf("redis ping %s: %w", addr, err) + } + return c, nil +} diff --git a/internal/store/redisstore/session.go b/internal/store/redisstore/session.go new file mode 100644 index 00000000..626c9533 --- /dev/null +++ b/internal/store/redisstore/session.go @@ -0,0 +1,75 @@ +package redisstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/store" +) + +// DefaultSessionTTL 是 session 记录的默认过期时间。 +// session 是连接态:过期或丢失后,客户端重连会触发 new_session_created / bad_server_salt 重建, +// 因此 TTL 不必很长。 +const DefaultSessionTTL = 30 * 24 * time.Hour + +// SessionStore 用 Redis 实现 store.SessionStore。 +type SessionStore struct { + c *redis.Client + ttl time.Duration +} + +// NewSessionStore 创建 Redis SessionStore。ttl<=0 表示永不过期。 +func NewSessionStore(c *redis.Client, ttl time.Duration) *SessionStore { + return &SessionStore{c: c, ttl: ttl} +} + +func sessionKey(id int64) string { + return fmt.Sprintf("session:%d", id) +} + +// sessionValue 是 SessionData 在 Redis 中的序列化形态(不含 ID,ID 即 key)。 +type sessionValue struct { + AuthKeyID [8]byte `json:"auth_key_id"` + Salt int64 `json:"salt"` + LastSeen int64 `json:"last_seen"` +} + +// Save 实现 store.SessionStore。 +func (s *SessionStore) Save(ctx context.Context, d store.SessionData) error { + v, err := json.Marshal(sessionValue{AuthKeyID: d.AuthKeyID, Salt: d.Salt, LastSeen: d.LastSeen}) + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + if err := s.c.Set(ctx, sessionKey(d.ID), v, s.ttl).Err(); err != nil { + return fmt.Errorf("redis set session: %w", err) + } + return nil +} + +// Get 实现 store.SessionStore。不存在时 found=false。 +func (s *SessionStore) Get(ctx context.Context, id int64) (store.SessionData, bool, error) { + raw, err := s.c.Get(ctx, sessionKey(id)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return store.SessionData{}, false, nil + } + return store.SessionData{}, false, fmt.Errorf("redis get session: %w", err) + } + var v sessionValue + if err := json.Unmarshal(raw, &v); err != nil { + return store.SessionData{}, false, fmt.Errorf("unmarshal session: %w", err) + } + return store.SessionData{ID: id, AuthKeyID: v.AuthKeyID, Salt: v.Salt, LastSeen: v.LastSeen}, true, nil +} + +func (s *SessionStore) Delete(ctx context.Context, id int64) error { + if err := s.c.Del(ctx, sessionKey(id)).Err(); err != nil { + return fmt.Errorf("redis delete session: %w", err) + } + return nil +} diff --git a/internal/store/redisstore/session_integration_test.go b/internal/store/redisstore/session_integration_test.go new file mode 100644 index 00000000..aff049e5 --- /dev/null +++ b/internal/store/redisstore/session_integration_test.go @@ -0,0 +1,52 @@ +package redisstore + +import ( + "context" + "os" + "testing" + "time" + + "telesrv/internal/store" +) + +// TestSessionStoreRoundTrip 验证 session 落 Redis 后能用全新 store 实例原样读回。 +// 未设 TELESRV_TEST_REDIS_ADDR 则跳过。 +func TestSessionStoreRoundTrip(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + ctx := context.Background() + c, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + + want := store.SessionData{ + ID: 0x1234beef, + AuthKeyID: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}, + Salt: 42, + LastSeen: 1000, + } + t.Cleanup(func() { _ = c.Del(ctx, sessionKey(want.ID)).Err() }) + + if err := NewSessionStore(c, time.Minute).Save(ctx, want); err != nil { + t.Fatalf("save: %v", err) + } + + got, found, err := NewSessionStore(c, time.Minute).Get(ctx, want.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if !found { + t.Fatal("session not found after save") + } + if got != want { + t.Fatalf("round trip mismatch: got %+v want %+v", got, want) + } + + if _, found, _ := NewSessionStore(c, time.Minute).Get(ctx, 999999); found { + t.Fatal("unexpected found for missing session") + } +} diff --git a/internal/store/redisstore/user_counter_allocator.go b/internal/store/redisstore/user_counter_allocator.go new file mode 100644 index 00000000..b5f77c18 --- /dev/null +++ b/internal/store/redisstore/user_counter_allocator.go @@ -0,0 +1,112 @@ +package redisstore + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/store" +) + +// UserCounterAllocator 在一次 Lua 往返内同时分配某 user 的下一个 pts 与 box_id。 +// +// counter:pts:{userID} 与 counter:box_id:{userID} 共享 hash-tag {userID},处于同一 +// Redis Cluster slot,可被单个 Lua 脚本原子操作——把发送热路径上「pts + box 两次往返」 +// 合并成一次。语义与 PtsAllocator/BoxIDAllocator 各自调用等价:pts 账号级无洞、 +// Redis 冷 miss 时从 PG durable log 恢复基线。 +type UserCounterAllocator struct { + c *redis.Client + ptsSource store.CounterSource + boxSource store.CounterSource +} + +// NewUserCounterAllocator 创建合并 allocator。ptsSource 恢复 pts(MAX(user_update_events.pts)), +// boxSource 恢复 box_id(MAX(message_boxes.box_id))。 +func NewUserCounterAllocator(c *redis.Client, ptsSource, boxSource store.CounterSource) *UserCounterAllocator { + return &UserCounterAllocator{c: c, ptsSource: ptsSource, boxSource: boxSource} +} + +// userCountersNextScript 热路径:两 key 都存在时各自 INCR 并返回;任一缺失返回 {-1,-1} 触发恢复。 +var userCountersNextScript = redis.NewScript(` +local pts = redis.call("GET", KEYS[1]) +local box = redis.call("GET", KEYS[2]) +if pts and box then + return {redis.call("INCR", KEYS[1]), redis.call("INCR", KEYS[2])} +end +return {-1, -1} +`) + +// userCountersRecoverScript 冷路径:每个 key「缺失才用 PG 基线 SET,再 INCR」。 +// 对已存在的 key 跳过 SET 只 INCR,故对「一存一缺」也安全;Redis 单线程串行化保证无重复无洞。 +var userCountersRecoverScript = redis.NewScript(` +local function recnext(key, base) + if not redis.call("GET", key) then + redis.call("SET", key, base) + end + return redis.call("INCR", key) +end +return {recnext(KEYS[1], ARGV[1]), recnext(KEYS[2], ARGV[2])} +`) + +// NextUserCounters 返回该 user 的下一个 (pts, boxID)。 +func (a *UserCounterAllocator) NextUserCounters(ctx context.Context, userID int64) (int, int, error) { + if userID == 0 { + return 0, 0, fmt.Errorf("redis user counters: missing user id") + } + if a.c == nil { + return 0, 0, fmt.Errorf("redis user counters: nil client") + } + keys := []string{ptsKey(userID), boxIDKey(userID)} + + pts, box, err := runTwoCounters(ctx, a.c, userCountersNextScript, keys) + if err != nil { + return 0, 0, fmt.Errorf("redis next user counters: %w", err) + } + if pts != missingCounterSentinel && box != missingCounterSentinel { + return int(pts), int(box), nil + } + + // 冷路径:至少一个 key 缺失,从 PG durable log 恢复两个基线,再 recover-next。 + ptsBase, err := recoverBase(ctx, a.ptsSource, userID, "pts") + if err != nil { + return 0, 0, err + } + boxBase, err := recoverBase(ctx, a.boxSource, userID, "box_id") + if err != nil { + return 0, 0, err + } + pts, box, err = runTwoCounters(ctx, a.c, userCountersRecoverScript, keys, ptsBase, boxBase) + if err != nil { + return 0, 0, fmt.Errorf("redis recover user counters: %w", err) + } + return int(pts), int(box), nil +} + +// runTwoCounters 执行返回二元整数表的 Lua 脚本,用 .Slice() 手动解析(不依赖 Int64Slice)。 +func runTwoCounters(ctx context.Context, c *redis.Client, script *redis.Script, keys []string, args ...any) (int64, int64, error) { + raw, err := script.Run(ctx, c, keys, args...).Slice() + if err != nil { + return 0, 0, err + } + if len(raw) != 2 { + return 0, 0, fmt.Errorf("unexpected reply len %d, want 2", len(raw)) + } + a, okA := raw[0].(int64) + b, okB := raw[1].(int64) + if !okA || !okB { + return 0, 0, fmt.Errorf("unexpected reply element types %T,%T, want int64", raw[0], raw[1]) + } + return a, b, nil +} + +func recoverBase(ctx context.Context, source store.CounterSource, userID int64, name string) (int, error) { + if source == nil { + return 0, nil + } + v, err := source.Current(ctx, userID) + if err != nil { + return 0, fmt.Errorf("recover %s counter: %w", name, err) + } + return v, nil +} diff --git a/internal/store/session.go b/internal/store/session.go new file mode 100644 index 00000000..e42401b9 --- /dev/null +++ b/internal/store/session.go @@ -0,0 +1,23 @@ +package store + +import "context" + +// SessionData 是一条 MTProto session 记录(client 生成的 session_id)。 +// +// 后续里程碑会扩展 device / layer 等字段。 +type SessionData struct { + ID int64 // session_id(客户端生成) + AuthKeyID [8]byte // 绑定的 auth key + Salt int64 // 当前 server salt + LastSeen int64 // unix 秒 +} + +// SessionStore 记录在线 MTProto session。实现见 store/memory(测试替身)、store/redisstore。 +type SessionStore interface { + // Save 保存或更新一条 session 记录。 + Save(ctx context.Context, s SessionData) error + // Get 按 session_id 查询;不存在时 found=false。 + Get(ctx context.Context, id int64) (data SessionData, found bool, err error) + // Delete 删除一条 session 记录;不存在时不报错。 + Delete(ctx context.Context, id int64) error +} diff --git a/internal/store/temp_auth_key.go b/internal/store/temp_auth_key.go new file mode 100644 index 00000000..ae195866 --- /dev/null +++ b/internal/store/temp_auth_key.go @@ -0,0 +1,13 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// TempAuthKeyBindingStore 持久化 auth.bindTempAuthKey 的 temp→perm 绑定。 +type TempAuthKeyBindingStore interface { + Save(ctx context.Context, binding domain.TempAuthKeyBinding) error + GetByTemp(ctx context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) +} diff --git a/internal/store/update_event.go b/internal/store/update_event.go new file mode 100644 index 00000000..502584ad --- /dev/null +++ b/internal/store/update_event.go @@ -0,0 +1,26 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// UpdateEventStore 持久化 user 维度的增量事件。 +type UpdateEventStore interface { + Append(ctx context.Context, userID int64, event domain.UpdateEvent) error + ListAfter(ctx context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) + Current(ctx context.Context, userID int64) (int, error) + // MaxContiguousPts 返回从 1 起无空洞的最大已提交 pts。 + // 并发发送在途时(pts 已分配未提交)会暂时小于 Current(最大已提交),空洞提交/补洞后回升。 + // getState/getDifference 据此只暴露连续 pts,避免客户端跳过在途空洞而丢消息。 + MaxContiguousPts(ctx context.Context, userID int64) (int, error) + // AdvanceContiguousPts 推进并返回账号级连续 pts 水位。实现应在写事件的同一事务内调用。 + AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) +} + +// EventCursor 精确定位单条账号事件,用于 outbox worker 批量加载已 claim 事件。 +type EventCursor struct { + UserID int64 + Pts int +} diff --git a/internal/store/updatestate.go b/internal/store/updatestate.go new file mode 100644 index 00000000..7ed28d3e --- /dev/null +++ b/internal/store/updatestate.go @@ -0,0 +1,15 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// UpdateStateStore 持久化 auth_key + user 维度的 pts/qts/seq 状态,避免同一设备换号串状态。 +type UpdateStateStore interface { + Get(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error) + Save(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState) error + Delete(ctx context.Context, authKeyID [8]byte, userID int64) error + DeleteAuthKey(ctx context.Context, authKeyID [8]byte) error +} diff --git a/internal/store/user.go b/internal/store/user.go new file mode 100644 index 00000000..fff88664 --- /dev/null +++ b/internal/store/user.go @@ -0,0 +1,22 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// UserStore 持久化用户。实现见 store/memory(测试替身)、store/postgres。 +type UserStore interface { + ByID(ctx context.Context, id int64) (domain.User, bool, error) + ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) + ByPhone(ctx context.Context, phone string) (domain.User, bool, error) + ByPhones(ctx context.Context, phones []string) ([]domain.User, error) + ByUsername(ctx context.Context, username string) (domain.User, bool, error) + Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) + UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error) + UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) + UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error + // Create 创建用户并返回分配了 ID 的副本。 + Create(ctx context.Context, u domain.User) (domain.User, error) +} diff --git a/internal/testkit/doc.go b/internal/testkit/doc.go new file mode 100644 index 00000000..abe31e86 --- /dev/null +++ b/internal/testkit/doc.go @@ -0,0 +1,3 @@ +// Package testkit 提供协议与业务测试工具:协议 spike 的集成测试辅助、 +// 驱动客户端调用首批 RPC 的脚手架等。 +package testkit diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 00000000..afbc215d --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "postgresql" + # schema 只取 *.up.sql,避免 golang-migrate 的 down 脚本(DROP)被 sqlc 当成 schema 的一部分。 + # 与 golang-migrate 共用同一份迁移文件,单一事实源。 + schema: "deploy/migrations/*.up.sql" + queries: "internal/store/postgres/queries" + gen: + go: + package: "sqlcgen" + out: "internal/store/postgres/sqlcgen" + sql_package: "pgx/v5" + emit_pointers_for_null_types: true

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%0DwkKWy=BE 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⪙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 yi;}Miu%_jEh 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