From 0c99ae0a9d757188648b21afb29b5f6a25157f0f Mon Sep 17 00:00:00 2001 From: A Date: Sun, 19 Jul 2026 20:37:10 +0800 Subject: [PATCH] feat: sync monoforum and collectible emoji status Sync telesrv bd15657 (feat(account): implement collectible emoji status). Skipped telesrv docs changes per public sync rules. --- ...noforum_direct_message_invariants.down.sql | 1 + ...monoforum_direct_message_invariants.up.sql | 98 ++++++++ ...109_monoforum_paid_message_ledger.down.sql | 3 + .../0109_monoforum_paid_message_ledger.up.sql | 3 + .../0114_collectible_emoji_status.down.sql | 61 +++++ .../0114_collectible_emoji_status.up.sql | 166 +++++++++++++ ...ectible_pattern_document_identity.down.sql | 98 ++++++++ ...llectible_pattern_document_identity.up.sql | 172 +++++++++++++ ...lectible_pattern_preview_identity.down.sql | 98 ++++++++ ...ollectible_pattern_preview_identity.up.sql | 168 +++++++++++++ .../collectible_emoji_status_test.go | 34 +++ internal/app/stargifts/service.go | 59 ++++- internal/app/updates/service.go | 14 ++ internal/app/updates/service_test.go | 29 +++ internal/app/users/premium_test.go | 6 +- internal/app/users/service.go | 72 +++++- internal/botapi/server.go | 4 +- internal/domain/channel.go | 38 ++- internal/domain/channel_errors.go | 63 ++--- internal/domain/dialog.go | 46 +++- internal/domain/star_gift.go | 23 ++ internal/domain/stars.go | 17 +- internal/domain/update_event.go | 9 +- internal/domain/user.go | 86 ++++++- internal/rpc/account.go | 153 ++++++++++-- .../account_collectible_emoji_status_test.go | 134 ++++++++++ internal/rpc/bots_longtail.go | 2 +- internal/rpc/channel_fanout_dispatcher.go | 16 ++ internal/rpc/channels_stubs.go | 2 + internal/rpc/convert_channels_core.go | 8 +- internal/rpc/convert_dialogs.go | 3 + internal/rpc/convert_updates.go | 5 + internal/rpc/convert_users.go | 32 ++- internal/rpc/deps.go | 17 +- internal/rpc/errors.go | 4 + internal/rpc/messages_dialogs.go | 41 ++-- internal/rpc/messages_monoforum.go | 107 +++++--- internal/rpc/messages_monoforum_rpc_test.go | 227 +++++++++++++++-- internal/rpc/messages_send.go | 76 +++++- internal/rpc/messages_suggested_post.go | 73 ++++++ internal/rpc/payments.go | 2 + internal/rpc/payments_stars_rpc_test.go | 16 ++ internal/rpc/send_media.go | 83 +++++++ internal/store/memory/channel_helpers.go | 56 +++++ internal/store/memory/channel_invites.go | 3 + internal/store/memory/channel_members.go | 12 + .../store/memory/channel_message_helpers.go | 8 + .../store/memory/channel_message_history.go | 10 +- internal/store/memory/channel_message_send.go | 12 +- internal/store/memory/channel_monoforum.go | 100 +++++++- .../memory/channel_monoforum_send_test.go | 135 ++++++++++- internal/store/memory/channel_store.go | 106 ++++---- internal/store/memory/channel_updates.go | 40 +++ internal/store/memory/dialogs.go | 8 + internal/store/memory/star_gift.go | 19 ++ internal/store/memory/users.go | 13 +- internal/store/postgres/account_lifecycle.go | 1 + internal/store/postgres/channel_core.go | 25 ++ internal/store/postgres/channel_helpers.go | 68 ++++-- .../store/postgres/channel_invite_members.go | 3 + .../store/postgres/channel_member_helpers.go | 9 + .../store/postgres/channel_member_join.go | 3 + .../store/postgres/channel_message_helpers.go | 8 +- .../store/postgres/channel_message_history.go | 7 +- .../store/postgres/channel_message_send.go | 21 +- internal/store/postgres/channel_monoforum.go | 156 ++++++++++-- ...channel_monoforum_send_integration_test.go | 204 +++++++++++++++- internal/store/postgres/channel_store.go | 2 +- internal/store/postgres/channel_updates.go | 89 ++++++- internal/store/postgres/contact.go | 155 ++++++------ .../store/postgres/emoji_status_codec_test.go | 54 +++++ .../emoji_status_event_integration_test.go | 62 +++++ internal/store/postgres/message_delete.go | 27 ++- internal/store/postgres/message_send.go | 33 +-- internal/store/postgres/queries/contact.sql | 8 + internal/store/postgres/queries/user.sql | 6 + .../postgres/queries/user_update_event.sql | 4 + internal/store/postgres/sqlcgen/bot.sql.go | 4 +- .../store/postgres/sqlcgen/contact.sql.go | 210 +++++++++------- internal/store/postgres/sqlcgen/models.go | 57 ++--- internal/store/postgres/sqlcgen/user.sql.go | 96 ++++++-- .../postgres/sqlcgen/user_update_event.sql.go | 70 +++--- .../store/postgres/star_gift_collectibles.go | 31 +++ .../star_gift_lifecycle_integration_test.go | 38 +++ ...ft_lifecycle_migration_integration_test.go | 4 +- internal/store/postgres/update_event.go | 88 +++++-- internal/store/postgres/user.go | 229 ++++++++++++++---- internal/store/private_send_idempotency.go | 32 ++- internal/store/redisstore/user_cache.go | 43 ++-- internal/store/star_gift.go | 3 + internal/store/user.go | 13 +- 91 files changed, 4061 insertions(+), 693 deletions(-) create mode 100644 deploy/migrations/0108_monoforum_direct_message_invariants.down.sql create mode 100644 deploy/migrations/0108_monoforum_direct_message_invariants.up.sql create mode 100644 deploy/migrations/0109_monoforum_paid_message_ledger.down.sql create mode 100644 deploy/migrations/0109_monoforum_paid_message_ledger.up.sql create mode 100644 deploy/migrations/0114_collectible_emoji_status.down.sql create mode 100644 deploy/migrations/0114_collectible_emoji_status.up.sql create mode 100644 deploy/migrations/0117_collectible_pattern_document_identity.down.sql create mode 100644 deploy/migrations/0117_collectible_pattern_document_identity.up.sql create mode 100644 deploy/migrations/0118_collectible_pattern_preview_identity.down.sql create mode 100644 deploy/migrations/0118_collectible_pattern_preview_identity.up.sql create mode 100644 internal/app/stargifts/collectible_emoji_status_test.go create mode 100644 internal/rpc/account_collectible_emoji_status_test.go create mode 100644 internal/rpc/messages_suggested_post.go create mode 100644 internal/store/postgres/emoji_status_codec_test.go create mode 100644 internal/store/postgres/emoji_status_event_integration_test.go diff --git a/deploy/migrations/0108_monoforum_direct_message_invariants.down.sql b/deploy/migrations/0108_monoforum_direct_message_invariants.down.sql new file mode 100644 index 00000000..3b2c0e1a --- /dev/null +++ b/deploy/migrations/0108_monoforum_direct_message_invariants.down.sql @@ -0,0 +1 @@ +ALTER TABLE channel_messages DROP COLUMN IF EXISTS suggested_post; diff --git a/deploy/migrations/0108_monoforum_direct_message_invariants.up.sql b/deploy/migrations/0108_monoforum_direct_message_invariants.up.sql new file mode 100644 index 00000000..2ce090ed --- /dev/null +++ b/deploy/migrations/0108_monoforum_direct_message_invariants.up.sql @@ -0,0 +1,98 @@ +ALTER TABLE channel_messages + ADD COLUMN suggested_post jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- A monoforum is a virtual per-saved-peer container, never an ordinary joined megagroup. +DELETE FROM channel_dialogs d +USING channels c +WHERE d.channel_id = c.id AND c.monoforum; + +DELETE FROM user_channel_member_index i +USING channels c +WHERE i.channel_id = c.id AND c.monoforum; + +DELETE FROM channel_members m +USING channels c +WHERE m.channel_id = c.id AND c.monoforum; + +UPDATE channels c +SET participants_count = 0, + admins_count = 0, + updated_at = now() +WHERE c.monoforum AND (c.participants_count <> 0 OR c.admins_count <> 0); + +-- Older generic sends from non-admin users are deterministically their own saved-peer dialog. +UPDATE channel_messages m +SET saved_peer_type = 'user', + saved_peer_id = m.sender_user_id +FROM channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND NOT m.deleted + AND m.saved_peer_id = 0 + AND m.action = '{}'::jsonb + AND m.sender_user_id <> 0 + AND NOT EXISTS ( + SELECT 1 + FROM channel_members parent_member + WHERE parent_member.channel_id = mono.linked_monoforum_id + AND parent_member.user_id = m.sender_user_id + AND parent_member.status = 'active' + AND parent_member.role IN ('creator', 'admin') + ); + +UPDATE channel_update_events e +SET payload = jsonb_set( + e.payload, + '{message,SavedPeer}', + jsonb_build_object('Type', 'user', 'ID', m.sender_user_id), + true + ) +FROM channel_messages m, channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND e.channel_id = m.channel_id + AND e.message_id = m.id + AND m.saved_peer_type = 'user' + AND m.saved_peer_id = m.sender_user_id + AND COALESCE((e.payload #>> '{message,SavedPeer,ID}')::bigint, 0) = 0; + +UPDATE channel_messages m +SET send_snapshot = jsonb_set( + m.send_snapshot, + '{message,SavedPeer}', + jsonb_build_object('Type', 'user', 'ID', m.sender_user_id), + true + ) +FROM channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND m.random_id <> 0 + AND m.saved_peer_type = 'user' + AND m.saved_peer_id = m.sender_user_id + AND COALESCE((m.send_snapshot #>> '{message,SavedPeer,ID}')::bigint, 0) = 0; + +-- Remove the impossible join service message without creating a pts gap: retain the event row as noop. +UPDATE channel_update_events e +SET event_type = 'noop', message_id = 0, sender_user_id = 0, user_ids = '[]'::jsonb, payload = '{}'::jsonb +FROM channel_messages m, channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND e.channel_id = m.channel_id + AND e.message_id = m.id + AND m.action->>'Type' = 'chat_joined'; + +UPDATE channel_messages m +SET deleted = true +FROM channels mono +WHERE mono.id = m.channel_id + AND mono.monoforum + AND m.action->>'Type' = 'chat_joined'; + +UPDATE channels mono +SET top_message_id = COALESCE(( + SELECT max(m.id) + FROM channel_messages m + WHERE m.channel_id = mono.id AND NOT m.deleted + ), 0), + updated_at = now() +WHERE mono.monoforum; diff --git a/deploy/migrations/0109_monoforum_paid_message_ledger.down.sql b/deploy/migrations/0109_monoforum_paid_message_ledger.down.sql new file mode 100644 index 00000000..3efaddd3 --- /dev/null +++ b/deploy/migrations/0109_monoforum_paid_message_ledger.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.channel_messages + DROP CONSTRAINT IF EXISTS channel_messages_paid_message_stars_check, + DROP COLUMN IF EXISTS paid_message_stars; diff --git a/deploy/migrations/0109_monoforum_paid_message_ledger.up.sql b/deploy/migrations/0109_monoforum_paid_message_ledger.up.sql new file mode 100644 index 00000000..f8dc4a06 --- /dev/null +++ b/deploy/migrations/0109_monoforum_paid_message_ledger.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.channel_messages + ADD COLUMN paid_message_stars bigint NOT NULL DEFAULT 0, + ADD CONSTRAINT channel_messages_paid_message_stars_check CHECK (paid_message_stars >= 0); diff --git a/deploy/migrations/0114_collectible_emoji_status.down.sql b/deploy/migrations/0114_collectible_emoji_status.down.sql new file mode 100644 index 00000000..ea3bb7ec --- /dev/null +++ b/deploy/migrations/0114_collectible_emoji_status.down.sql @@ -0,0 +1,61 @@ +DROP TRIGGER IF EXISTS unique_star_gifts_clear_invalid_emoji_status ON public.unique_star_gifts; +DROP FUNCTION IF EXISTS public.telesrv_clear_invalid_collectible_emoji_status(); + +UPDATE public.documents d +SET attributes = COALESCE(( + SELECT jsonb_agg( + CASE + WHEN item->>'kind' = 'custom_emoji' AND COALESCE((item->>'text_color')::boolean, false) THEN + (item - 'text_color') || jsonb_build_object('kind', 'sticker') + ELSE item + END + ORDER BY ord + ) + FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord) +), '[]'::jsonb) +WHERE d.id IN (SELECT document_id FROM public.star_gift_collectible_patterns); + +ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check; +ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents', + 'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies', + 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', + 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', + 'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter', + 'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages', + 'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned', + 'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction', + 'new_story_reaction', 'noop', 'read_channel_discussion_inbox', + 'read_channel_discussion_outbox' + ]::text[]) +); +ALTER TABLE public.user_update_events DROP COLUMN IF EXISTS emoji_status_payload; + +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check; +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_emoji_status_shape_check; +ALTER TABLE public.users + DROP COLUMN IF EXISTS emoji_status_collectible, + DROP COLUMN IF EXISTS emoji_status_collectible_id; + +ALTER TABLE public.users + ADD CONSTRAINT users_deletion_state_check CHECK ( + (deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '') + OR + (deleted_at IS NOT NULL + AND deletion_source IN ( + 'manual', 'forgot_password', 'tos_decline', 'password_reset_expiry', + 'account_ttl', 'freeze_expiry' + ) + AND account_delete_at IS NULL + AND phone = '' AND first_name = '' AND last_name = '' AND username = '' + AND country_code = '' AND about = '' AND verified = false AND support = false + AND premium_expires_at IS NULL + AND emoji_status_document_id = 0 AND emoji_status_until = 0 + AND color_set = false AND color = 0 AND color_background_emoji_id = 0 + AND profile_color_set = false AND profile_color = 0 + AND profile_color_background_emoji_id = 0 + AND birthday_day = 0 AND birthday_month = 0 AND birthday_year = 0 + AND personal_channel_id = 0 AND last_seen_at = 0 + AND octet_length(deletion_reason) <= 1024) + ); diff --git a/deploy/migrations/0114_collectible_emoji_status.up.sql b/deploy/migrations/0114_collectible_emoji_status.up.sql new file mode 100644 index 00000000..58a7927e --- /dev/null +++ b/deploy/migrations/0114_collectible_emoji_status.up.sql @@ -0,0 +1,166 @@ +-- Complete collectible emoji-status state: users persist the selected unique +-- gift plus an immutable render snapshot, and the account update log persists +-- the exact status payload for online dispatch/offline difference replay. +ALTER TABLE public.users + ADD COLUMN emoji_status_collectible_id bigint REFERENCES public.unique_star_gifts(id), + ADD COLUMN emoji_status_collectible jsonb DEFAULT '{}'::jsonb NOT NULL; + +ALTER TABLE public.users + ADD CONSTRAINT users_emoji_status_shape_check CHECK ( + emoji_status_document_id >= 0 + AND emoji_status_until >= 0 + AND (emoji_status_document_id > 0 OR emoji_status_until = 0) + AND ( + ( + emoji_status_collectible_id IS NULL + AND emoji_status_collectible = '{}'::jsonb + ) OR ( + emoji_status_collectible_id IS NOT NULL + AND emoji_status_collectible_id > 0 + AND emoji_status_document_id > 0 + AND jsonb_typeof(emoji_status_collectible) = 'object' + AND emoji_status_collectible <> '{}'::jsonb + AND emoji_status_collectible ? 'collectible_id' + AND emoji_status_collectible ? 'document_id' + AND emoji_status_collectible ? 'title' + AND emoji_status_collectible ? 'slug' + AND emoji_status_collectible ? 'pattern_document_id' + AND (emoji_status_collectible->>'collectible_id')::bigint = emoji_status_collectible_id + AND (emoji_status_collectible->>'document_id')::bigint = emoji_status_document_id + AND (emoji_status_collectible->>'pattern_document_id')::bigint > 0 + AND length(emoji_status_collectible->>'title') > 0 + AND length(emoji_status_collectible->>'slug') > 0 + AND (emoji_status_collectible->>'center_color')::integer BETWEEN 0 AND 16777215 + AND (emoji_status_collectible->>'edge_color')::integer BETWEEN 0 AND 16777215 + AND (emoji_status_collectible->>'pattern_color')::integer BETWEEN 0 AND 16777215 + AND (emoji_status_collectible->>'text_color')::integer BETWEEN 0 AND 16777215 + ) + ) + ); + +ALTER TABLE public.user_update_events + ADD COLUMN emoji_status_payload jsonb DEFAULT '{}'::jsonb NOT NULL; + +ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check; +ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents', + 'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies', + 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', + 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', + 'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages', + 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers', + 'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state', + 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', + 'sent_story_reaction', 'new_story_reaction', 'noop', + 'read_channel_discussion_inbox', 'read_channel_discussion_outbox' + ]::text[]) +); + +-- Android applies the collectible backdrop's pattern_color only to a +-- documentAttributeCustomEmoji with text_color=true. Existing imports stored +-- these pattern documents as ordinary stickers, so repair them in place. +UPDATE public.documents d +SET attributes = COALESCE(( + SELECT jsonb_agg( + CASE + WHEN item->>'kind' = 'sticker' THEN + jsonb_set( + jsonb_set(item, '{kind}', '"custom_emoji"'::jsonb, false), + '{text_color}', 'true'::jsonb, true + ) + ELSE item + END + ORDER BY ord + ) + FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord) +), '[]'::jsonb) +WHERE d.id IN (SELECT document_id FROM public.star_gift_collectible_patterns); + +-- A transferred/exported/burned gift can no longer remain as the previous +-- owner's status. Keep the durable user state valid even if the lifecycle +-- mutation did not originate from an account RPC. +CREATE OR REPLACE FUNCTION public.telesrv_clear_invalid_collectible_emoji_status() +RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + cleared_user_id bigint; + cleared_pts integer; + event_date integer; +BEGIN + event_date := EXTRACT(EPOCH FROM clock_timestamp())::integer; + FOR cleared_user_id IN + UPDATE public.users u + SET emoji_status_document_id = 0, + emoji_status_until = 0, + emoji_status_collectible_id = NULL, + emoji_status_collectible = '{}'::jsonb, + updated_at = now() + WHERE u.emoji_status_collectible_id = NEW.id + AND ( + NEW.burned + OR NEW.owner_address <> '' + OR NEW.owner_peer_type IS DISTINCT FROM 'user' + OR NEW.owner_peer_id IS DISTINCT FROM u.id + ) + RETURNING u.id + LOOP + INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) + VALUES (cleared_user_id, 0) + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.user_update_watermarks + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + WHERE user_id = cleared_user_id + RETURNING contiguous_pts INTO cleared_pts; + + INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload + ) VALUES ( + cleared_user_id, cleared_pts, 1, event_date, 'user_emoji_status', + 'user', cleared_user_id, '{}'::jsonb + ); + + -- No session is the origin of a lifecycle invalidation: every online + -- device receives it, and offline devices replay the same event. + INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id + ) VALUES ( + cleared_user_id, cleared_pts, 'user_emoji_status', 0, 0 + ); + END LOOP; + RETURN NEW; +END +$$; + +CREATE TRIGGER unique_star_gifts_clear_invalid_emoji_status +AFTER UPDATE OF owner_peer_type, owner_peer_id, owner_address, burned +ON public.unique_star_gifts +FOR EACH ROW EXECUTE FUNCTION public.telesrv_clear_invalid_collectible_emoji_status(); + +-- Deleted-user tombstones must not retain the new collectible facts. +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check; +ALTER TABLE public.users + ADD CONSTRAINT users_deletion_state_check CHECK ( + (deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '') + OR + (deleted_at IS NOT NULL + AND deletion_source IN ( + 'manual', 'forgot_password', 'tos_decline', 'password_reset_expiry', + 'account_ttl', 'freeze_expiry' + ) + AND account_delete_at IS NULL + AND phone = '' AND first_name = '' AND last_name = '' AND username = '' + AND country_code = '' AND about = '' AND verified = false AND support = false + AND premium_expires_at IS NULL + AND emoji_status_document_id = 0 AND emoji_status_until = 0 + AND emoji_status_collectible_id IS NULL + AND emoji_status_collectible = '{}'::jsonb + AND color_set = false AND color = 0 AND color_background_emoji_id = 0 + AND profile_color_set = false AND profile_color = 0 + AND profile_color_background_emoji_id = 0 + AND birthday_day = 0 AND birthday_month = 0 AND birthday_year = 0 + AND personal_channel_id = 0 AND last_seen_at = 0 + AND octet_length(deletion_reason) <= 1024) + ); diff --git a/deploy/migrations/0117_collectible_pattern_document_identity.down.sql b/deploy/migrations/0117_collectible_pattern_document_identity.down.sql new file mode 100644 index 00000000..b2fb149e --- /dev/null +++ b/deploy/migrations/0117_collectible_pattern_document_identity.down.sql @@ -0,0 +1,98 @@ +CREATE TEMP TABLE telesrv_pattern_document_repair_rollback ON COMMIT DROP AS +SELECT old_document_id, new_document_id +FROM public.star_gift_pattern_document_repairs; + +CREATE TEMP TABLE telesrv_rollback_collectible_wearers ON COMMIT DROP AS +SELECT u.id AS user_id, r.old_document_id, r.new_document_id +FROM public.users u +JOIN telesrv_pattern_document_repair_rollback r + ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.new_document_id +WHERE u.emoji_status_collectible_id IS NOT NULL; + +UPDATE public.users u +SET emoji_status_collectible = jsonb_set( + u.emoji_status_collectible, + '{pattern_document_id}', + to_jsonb(w.old_document_id), + false + ), + updated_at = now() +FROM telesrv_rollback_collectible_wearers w +WHERE u.id = w.user_id; + +UPDATE public.user_update_events e +SET emoji_status_payload = jsonb_set( + e.emoji_status_payload, + '{collectible,pattern_document_id}', + to_jsonb(r.old_document_id), + false + ) +FROM telesrv_pattern_document_repair_rollback r +WHERE e.event_type = 'user_emoji_status' + AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.new_document_id; + +ALTER TABLE public.star_gift_collectible_patterns + DISABLE TRIGGER star_gift_collectible_pattern_guard; +UPDATE public.star_gift_collectible_patterns p +SET document_id = r.old_document_id +FROM telesrv_pattern_document_repair_rollback r +WHERE p.document_id = r.new_document_id; +ALTER TABLE public.star_gift_collectible_patterns + ENABLE TRIGGER star_gift_collectible_pattern_guard; + +INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) +SELECT user_id, 0 FROM telesrv_rollback_collectible_wearers +ON CONFLICT (user_id) DO NOTHING; + +CREATE TEMP TABLE telesrv_collectible_pattern_rollback_events ( + user_id bigint PRIMARY KEY, + pts integer NOT NULL +) ON COMMIT DROP; + +WITH bumped AS ( + UPDATE public.user_update_watermarks w + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + FROM telesrv_rollback_collectible_wearers wearer + WHERE w.user_id = wearer.user_id + RETURNING w.user_id, w.contiguous_pts +) +INSERT INTO telesrv_collectible_pattern_rollback_events (user_id, pts) +SELECT user_id, contiguous_pts FROM bumped; + +INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload +) +SELECT + c.user_id, + c.pts, + 1, + EXTRACT(EPOCH FROM clock_timestamp())::integer, + 'user_emoji_status', + 'user', + c.user_id, + jsonb_strip_nulls(jsonb_build_object( + 'document_id', u.emoji_status_document_id, + 'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END, + 'collectible', u.emoji_status_collectible + )) +FROM telesrv_collectible_pattern_rollback_events c +JOIN public.users u ON u.id = c.user_id; + +INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id +) +SELECT user_id, pts, 'user_emoji_status', 0, 0 +FROM telesrv_collectible_pattern_rollback_events; + +DELETE FROM public.file_blobs f +USING telesrv_pattern_document_repair_rollback r +WHERE f.location_key = 'doc:' || r.new_document_id::text + OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%'; + +DROP TABLE public.star_gift_pattern_document_repairs; + +DELETE FROM public.documents d +USING telesrv_pattern_document_repair_rollback r +WHERE d.id = r.new_document_id; diff --git a/deploy/migrations/0117_collectible_pattern_document_identity.up.sql b/deploy/migrations/0117_collectible_pattern_document_identity.up.sql new file mode 100644 index 00000000..94d614c7 --- /dev/null +++ b/deploy/migrations/0117_collectible_pattern_document_identity.up.sql @@ -0,0 +1,172 @@ +-- Telegram clients cache Document metadata by id indefinitely. Migration 0114 +-- corrected collectible pattern attributes in place, but an Android client that +-- had already cached the old sticker shape would never observe that correction. +-- Allocate a new immutable document identity for every pre-0117 pattern, keep a +-- durable old/new map for audit and rollback, and point the published attribute +-- at the clone. The blob bytes do not change, so the new location keys alias the +-- same immutable backend object. +CREATE TABLE public.star_gift_pattern_document_repairs ( + old_document_id bigint PRIMARY KEY, + new_document_id bigint UNIQUE NOT NULL, + repaired_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_pattern_document_repairs_ids_check CHECK ( + old_document_id > 0 AND new_document_id > 0 AND old_document_id <> new_document_id + ) +); + +INSERT INTO public.star_gift_pattern_document_repairs (old_document_id, new_document_id) +SELECT DISTINCT + p.document_id, + ((('x' || substr(md5(p.document_id::text || ':collectible-pattern-custom-emoji:v1'), 1, 16)) + ::bit(64)::bigint) & 9223372036854775807) +FROM public.star_gift_collectible_patterns p; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.star_gift_pattern_document_repairs r + JOIN public.documents d ON d.id = r.new_document_id + ) THEN + RAISE EXCEPTION 'collectible pattern document repair id collision'; + END IF; +END +$$; + +INSERT INTO public.documents ( + id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes, thumbs, created_at +) +SELECT + r.new_document_id, + d.access_hash, + d.file_reference, + d.date, + d.mime_type, + d.size, + d.dc_id, + COALESCE(( + SELECT jsonb_agg( + CASE + WHEN item->>'kind' IN ('sticker', 'custom_emoji') THEN + jsonb_set( + jsonb_set(item, '{kind}', '"custom_emoji"'::jsonb, false), + '{text_color}', 'true'::jsonb, true + ) + ELSE item + END + ORDER BY ord + ) + FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord) + ), '[]'::jsonb), + d.thumbs, + now() +FROM public.star_gift_pattern_document_repairs r +JOIN public.documents d ON d.id = r.old_document_id; + +INSERT INTO public.file_blobs ( + location_key, backend, object_key, size, sha256, mime_type, created_at +) +SELECT + 'doc:' || r.new_document_id::text || + substr(f.location_key, length('doc:' || r.old_document_id::text) + 1), + f.backend, + f.object_key, + f.size, + f.sha256, + f.mime_type, + now() +FROM public.star_gift_pattern_document_repairs r +JOIN public.file_blobs f + ON f.location_key = 'doc:' || r.old_document_id::text + OR f.location_key LIKE 'doc:' || r.old_document_id::text || ':%'; + +-- This is a repair of a previously invalid document identity, not a mutation of +-- the published appearance. Attribute id/name/animation/rarity remain intact. +ALTER TABLE public.star_gift_collectible_patterns + DISABLE TRIGGER star_gift_collectible_pattern_guard; +UPDATE public.star_gift_collectible_patterns p +SET document_id = r.new_document_id +FROM public.star_gift_pattern_document_repairs r +WHERE p.document_id = r.old_document_id; +ALTER TABLE public.star_gift_collectible_patterns + ENABLE TRIGGER star_gift_collectible_pattern_guard; + +-- Capture active wearers before rewriting their immutable render snapshots. +CREATE TEMP TABLE telesrv_repaired_collectible_wearers ON COMMIT DROP AS +SELECT u.id AS user_id, r.old_document_id, r.new_document_id +FROM public.users u +JOIN public.star_gift_pattern_document_repairs r + ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.old_document_id +WHERE u.emoji_status_collectible_id IS NOT NULL; + +UPDATE public.users u +SET emoji_status_collectible = jsonb_set( + u.emoji_status_collectible, + '{pattern_document_id}', + to_jsonb(w.new_document_id), + false + ), + updated_at = now() +FROM telesrv_repaired_collectible_wearers w +WHERE u.id = w.user_id; + +-- An offline client may still replay an older status event, so repair every +-- durable snapshot. A fresh event is appended below for clients that already +-- consumed the old pts and therefore need a new convergence edge. +UPDATE public.user_update_events e +SET emoji_status_payload = jsonb_set( + e.emoji_status_payload, + '{collectible,pattern_document_id}', + to_jsonb(r.new_document_id), + false + ) +FROM public.star_gift_pattern_document_repairs r +WHERE e.event_type = 'user_emoji_status' + AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.old_document_id; + +INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) +SELECT user_id, 0 FROM telesrv_repaired_collectible_wearers +ON CONFLICT (user_id) DO NOTHING; + +CREATE TEMP TABLE telesrv_collectible_pattern_correction_events ( + user_id bigint PRIMARY KEY, + pts integer NOT NULL +) ON COMMIT DROP; + +WITH bumped AS ( + UPDATE public.user_update_watermarks w + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + FROM telesrv_repaired_collectible_wearers wearer + WHERE w.user_id = wearer.user_id + RETURNING w.user_id, w.contiguous_pts +) +INSERT INTO telesrv_collectible_pattern_correction_events (user_id, pts) +SELECT user_id, contiguous_pts FROM bumped; + +INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload +) +SELECT + c.user_id, + c.pts, + 1, + EXTRACT(EPOCH FROM clock_timestamp())::integer, + 'user_emoji_status', + 'user', + c.user_id, + jsonb_strip_nulls(jsonb_build_object( + 'document_id', u.emoji_status_document_id, + 'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END, + 'collectible', u.emoji_status_collectible + )) +FROM telesrv_collectible_pattern_correction_events c +JOIN public.users u ON u.id = c.user_id; + +INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id +) +SELECT user_id, pts, 'user_emoji_status', 0, 0 +FROM telesrv_collectible_pattern_correction_events; diff --git a/deploy/migrations/0118_collectible_pattern_preview_identity.down.sql b/deploy/migrations/0118_collectible_pattern_preview_identity.down.sql new file mode 100644 index 00000000..04ef69d8 --- /dev/null +++ b/deploy/migrations/0118_collectible_pattern_preview_identity.down.sql @@ -0,0 +1,98 @@ +CREATE TEMP TABLE telesrv_pattern_preview_repair_rollback ON COMMIT DROP AS +SELECT old_document_id, new_document_id +FROM public.star_gift_pattern_preview_document_repairs; + +CREATE TEMP TABLE telesrv_pattern_preview_rollback_wearers ON COMMIT DROP AS +SELECT u.id AS user_id, r.old_document_id, r.new_document_id +FROM public.users u +JOIN telesrv_pattern_preview_repair_rollback r + ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.new_document_id +WHERE u.emoji_status_collectible_id IS NOT NULL; + +UPDATE public.users u +SET emoji_status_collectible = jsonb_set( + u.emoji_status_collectible, + '{pattern_document_id}', + to_jsonb(w.old_document_id), + false + ), + updated_at = now() +FROM telesrv_pattern_preview_rollback_wearers w +WHERE u.id = w.user_id; + +UPDATE public.user_update_events e +SET emoji_status_payload = jsonb_set( + e.emoji_status_payload, + '{collectible,pattern_document_id}', + to_jsonb(r.old_document_id), + false + ) +FROM telesrv_pattern_preview_repair_rollback r +WHERE e.event_type = 'user_emoji_status' + AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.new_document_id; + +ALTER TABLE public.star_gift_collectible_patterns + DISABLE TRIGGER star_gift_collectible_pattern_guard; +UPDATE public.star_gift_collectible_patterns p +SET document_id = r.old_document_id +FROM telesrv_pattern_preview_repair_rollback r +WHERE p.document_id = r.new_document_id; +ALTER TABLE public.star_gift_collectible_patterns + ENABLE TRIGGER star_gift_collectible_pattern_guard; + +INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) +SELECT user_id, 0 FROM telesrv_pattern_preview_rollback_wearers +ON CONFLICT (user_id) DO NOTHING; + +CREATE TEMP TABLE telesrv_pattern_preview_rollback_events ( + user_id bigint PRIMARY KEY, + pts integer NOT NULL +) ON COMMIT DROP; + +WITH bumped AS ( + UPDATE public.user_update_watermarks w + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + FROM telesrv_pattern_preview_rollback_wearers wearer + WHERE w.user_id = wearer.user_id + RETURNING w.user_id, w.contiguous_pts +) +INSERT INTO telesrv_pattern_preview_rollback_events (user_id, pts) +SELECT user_id, contiguous_pts FROM bumped; + +INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload +) +SELECT + c.user_id, + c.pts, + 1, + EXTRACT(EPOCH FROM clock_timestamp())::integer, + 'user_emoji_status', + 'user', + c.user_id, + jsonb_strip_nulls(jsonb_build_object( + 'document_id', u.emoji_status_document_id, + 'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END, + 'collectible', u.emoji_status_collectible + )) +FROM telesrv_pattern_preview_rollback_events c +JOIN public.users u ON u.id = c.user_id; + +INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id +) +SELECT user_id, pts, 'user_emoji_status', 0, 0 +FROM telesrv_pattern_preview_rollback_events; + +DELETE FROM public.file_blobs f +USING telesrv_pattern_preview_repair_rollback r +WHERE f.location_key = 'doc:' || r.new_document_id::text + OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%'; + +DROP TABLE public.star_gift_pattern_preview_document_repairs; + +DELETE FROM public.documents d +USING telesrv_pattern_preview_repair_rollback r +WHERE d.id = r.new_document_id; diff --git a/deploy/migrations/0118_collectible_pattern_preview_identity.up.sql b/deploy/migrations/0118_collectible_pattern_preview_identity.up.sql new file mode 100644 index 00000000..ab190715 --- /dev/null +++ b/deploy/migrations/0118_collectible_pattern_preview_identity.up.sql @@ -0,0 +1,168 @@ +-- DrKLO's profile-header path creates collectible patterns with +-- CACHE_TYPE_ALERT_PREVIEW_STATIC. For application/x-tgsticker that client +-- only loads the main TGS when Document.thumbs is non-empty; an empty list is +-- routed to a null thumbnail location and the pattern remains a flat backdrop. +-- +-- Document metadata is cached by id, so adding a thumb to an already published +-- id would not repair existing Android installations. Clone every affected +-- published pattern under a new immutable identity, add an inline PhotoPathSize +-- placeholder, alias the unchanged main blob, and converge active/durable emoji +-- status snapshots to the new id. +CREATE TABLE public.star_gift_pattern_preview_document_repairs ( + old_document_id bigint PRIMARY KEY, + new_document_id bigint UNIQUE NOT NULL, + repaired_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT star_gift_pattern_preview_document_repairs_ids_check CHECK ( + old_document_id > 0 AND new_document_id > 0 AND old_document_id <> new_document_id + ) +); + +INSERT INTO public.star_gift_pattern_preview_document_repairs (old_document_id, new_document_id) +SELECT DISTINCT + p.document_id, + ((('x' || substr(md5(p.document_id::text || ':collectible-pattern-android-preview:v2'), 1, 16)) + ::bit(64)::bigint) & 9223372036854775807) +FROM public.star_gift_collectible_patterns p +JOIN public.documents d ON d.id = p.document_id +WHERE jsonb_array_length(d.thumbs) = 0; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.star_gift_pattern_preview_document_repairs r + JOIN public.documents d ON d.id = r.new_document_id + ) OR EXISTS ( + SELECT 1 + FROM public.star_gift_pattern_preview_document_repairs r + JOIN public.file_blobs f + ON f.location_key = 'doc:' || r.new_document_id::text + OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%' + ) THEN + RAISE EXCEPTION 'collectible pattern preview document repair id collision'; + END IF; +END +$$; + +INSERT INTO public.documents ( + id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes, thumbs, created_at +) +SELECT + r.new_document_id, + d.access_hash, + d.file_reference, + d.date, + d.mime_type, + d.size, + d.dc_id, + d.attributes, + jsonb_build_array(jsonb_build_object( + 'kind', 'path', + 'type', 'j', + 'bytes', 'GQalBdxhTX54SARIBGNsfE4Imk4HooCjlLqhhYOHSIxMjEybVa1VkICfhqqRqquGigRYjgFNkXmHA0cGhwM=' + )), + now() +FROM public.star_gift_pattern_preview_document_repairs r +JOIN public.documents d ON d.id = r.old_document_id; + +INSERT INTO public.file_blobs ( + location_key, backend, object_key, size, sha256, mime_type, created_at +) +SELECT + 'doc:' || r.new_document_id::text || + substr(f.location_key, length('doc:' || r.old_document_id::text) + 1), + f.backend, + f.object_key, + f.size, + f.sha256, + f.mime_type, + now() +FROM public.star_gift_pattern_preview_document_repairs r +JOIN public.file_blobs f + ON f.location_key = 'doc:' || r.old_document_id::text + OR f.location_key LIKE 'doc:' || r.old_document_id::text || ':%'; + +ALTER TABLE public.star_gift_collectible_patterns + DISABLE TRIGGER star_gift_collectible_pattern_guard; +UPDATE public.star_gift_collectible_patterns p +SET document_id = r.new_document_id +FROM public.star_gift_pattern_preview_document_repairs r +WHERE p.document_id = r.old_document_id; +ALTER TABLE public.star_gift_collectible_patterns + ENABLE TRIGGER star_gift_collectible_pattern_guard; + +CREATE TEMP TABLE telesrv_pattern_preview_repaired_wearers ON COMMIT DROP AS +SELECT u.id AS user_id, r.old_document_id, r.new_document_id +FROM public.users u +JOIN public.star_gift_pattern_preview_document_repairs r + ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.old_document_id +WHERE u.emoji_status_collectible_id IS NOT NULL; + +UPDATE public.users u +SET emoji_status_collectible = jsonb_set( + u.emoji_status_collectible, + '{pattern_document_id}', + to_jsonb(w.new_document_id), + false + ), + updated_at = now() +FROM telesrv_pattern_preview_repaired_wearers w +WHERE u.id = w.user_id; + +UPDATE public.user_update_events e +SET emoji_status_payload = jsonb_set( + e.emoji_status_payload, + '{collectible,pattern_document_id}', + to_jsonb(r.new_document_id), + false + ) +FROM public.star_gift_pattern_preview_document_repairs r +WHERE e.event_type = 'user_emoji_status' + AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.old_document_id; + +INSERT INTO public.user_update_watermarks (user_id, contiguous_pts) +SELECT user_id, 0 FROM telesrv_pattern_preview_repaired_wearers +ON CONFLICT (user_id) DO NOTHING; + +CREATE TEMP TABLE telesrv_pattern_preview_correction_events ( + user_id bigint PRIMARY KEY, + pts integer NOT NULL +) ON COMMIT DROP; + +WITH bumped AS ( + UPDATE public.user_update_watermarks w + SET contiguous_pts = contiguous_pts + 1, + updated_at = now() + FROM telesrv_pattern_preview_repaired_wearers wearer + WHERE w.user_id = wearer.user_id + RETURNING w.user_id, w.contiguous_pts +) +INSERT INTO telesrv_pattern_preview_correction_events (user_id, pts) +SELECT user_id, contiguous_pts FROM bumped; + +INSERT INTO public.user_update_events ( + user_id, pts, pts_count, date, event_type, + peer_type, peer_id, emoji_status_payload +) +SELECT + c.user_id, + c.pts, + 1, + EXTRACT(EPOCH FROM clock_timestamp())::integer, + 'user_emoji_status', + 'user', + c.user_id, + jsonb_strip_nulls(jsonb_build_object( + 'document_id', u.emoji_status_document_id, + 'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END, + 'collectible', u.emoji_status_collectible + )) +FROM telesrv_pattern_preview_correction_events c +JOIN public.users u ON u.id = c.user_id; + +INSERT INTO public.dispatch_outbox ( + target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id +) +SELECT user_id, pts, 'user_emoji_status', 0, 0 +FROM telesrv_pattern_preview_correction_events; diff --git a/internal/app/stargifts/collectible_emoji_status_test.go b/internal/app/stargifts/collectible_emoji_status_test.go new file mode 100644 index 00000000..06f64731 --- /dev/null +++ b/internal/app/stargifts/collectible_emoji_status_test.go @@ -0,0 +1,34 @@ +package stargifts + +import ( + "bytes" + "testing" + + "telesrv/internal/domain" +) + +func TestCollectiblePatternUsesTextColorCustomEmojiAttribute(t *testing.T) { + pattern := collectibleDocumentAttributes(domain.StarGiftCollectiblePattern) + if len(pattern) != 3 || pattern[1].Kind != domain.DocAttrCustomEmoji || !pattern[1].TextColor { + t.Fatalf("pattern attributes = %+v, want text-color custom emoji", pattern) + } + model := collectibleDocumentAttributes(domain.StarGiftCollectibleModel) + if len(model) != 3 || model[1].Kind != domain.DocAttrSticker || model[1].TextColor { + t.Fatalf("model attributes = %+v, want ordinary sticker", model) + } +} + +func TestCollectiblePatternHasInlinePathThumbForAndroidStaticPreview(t *testing.T) { + pattern := collectibleDocumentThumbs(domain.StarGiftCollectiblePattern) + if len(pattern) != 1 || pattern[0].Kind != domain.PhotoSizeKindPath || + pattern[0].Type != "j" || !bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) { + t.Fatalf("pattern thumbs = %+v, want inline path placeholder", pattern) + } + pattern[0].Bytes[0] ^= 0xff + if bytes.Equal(pattern[0].Bytes, collectiblePatternPathThumb) { + t.Fatal("collectibleDocumentThumbs returned shared mutable bytes") + } + if model := collectibleDocumentThumbs(domain.StarGiftCollectibleModel); len(model) != 0 { + t.Fatalf("model thumbs = %+v, want no synthetic pattern placeholder", model) + } +} diff --git a/internal/app/stargifts/service.go b/internal/app/stargifts/service.go index 0b4e2e75..627996ff 100644 --- a/internal/app/stargifts/service.go +++ b/internal/app/stargifts/service.go @@ -368,11 +368,8 @@ func (s *Service) materializeCollectibleAttributes(ctx context.Context, attribut ID: documentID, AccessHash: accessHash, FileReference: fileReference, Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker", Size: int64(len(animation.TGS)), DCID: s.dc, - Attributes: []domain.DocumentAttribute{ - {Kind: domain.DocAttrImageSize, W: 512, H: 512}, - {Kind: domain.DocAttrSticker, Alt: "🎁"}, - {Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"}, - }, + Attributes: collectibleDocumentAttributes(attributes[i].Kind), + Thumbs: collectibleDocumentThumbs(attributes[i].Kind), } attributes[i].Blob = &domain.FileBlob{ LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()), @@ -383,6 +380,50 @@ func (s *Service) materializeCollectibleAttributes(ctx context.Context, attribut return nil } +// collectiblePatternPathThumb is a valid, inline PhotoPathSize placeholder. +// DrKLO's CACHE_TYPE_ALERT_PREVIEW_STATIC classifies a TGS document as an +// animated sticker only when document.thumbs is non-empty. The placeholder is +// not used as the rendered collectible pattern: after classification Android +// downloads and decodes the document's full TGS first frame. Keeping the +// placeholder inline avoids introducing a second downloadable blob and matches +// the shape used by official animated-sticker documents. +var collectiblePatternPathThumb = []byte{ + 0x19, 0x06, 0xa5, 0x05, 0xdc, 0x61, 0x4d, 0x7e, + 0x78, 0x48, 0x04, 0x48, 0x04, 0x63, 0x6c, 0x7c, + 0x4e, 0x08, 0x9a, 0x4e, 0x07, 0xa2, 0x80, 0xa3, + 0x94, 0xba, 0xa1, 0x85, 0x83, 0x87, 0x48, 0x8c, + 0x4c, 0x8c, 0x4c, 0x9b, 0x55, 0xad, 0x55, 0x90, + 0x80, 0x9f, 0x86, 0xaa, 0x91, 0xaa, 0xab, 0x86, + 0x8a, 0x04, 0x58, 0x8e, 0x01, 0x4d, 0x91, 0x79, + 0x87, 0x03, 0x47, 0x06, 0x87, 0x03, +} + +func collectibleDocumentThumbs(kind domain.StarGiftCollectibleAttributeKind) []domain.PhotoSize { + if kind != domain.StarGiftCollectiblePattern { + return nil + } + return []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindPath, + Type: "j", + Bytes: append([]byte(nil), collectiblePatternPathThumb...), + }} +} + +func collectibleDocumentAttributes(kind domain.StarGiftCollectibleAttributeKind) []domain.DocumentAttribute { + renderAttribute := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: "🎁"} + if kind == domain.StarGiftCollectiblePattern { + // DrKLO only applies StarGiftAttributeBackdrop.pattern_color when the + // pattern is a text-color custom emoji. Without this the gradient is + // visible but the collectible pattern is rendered with its raw fill. + renderAttribute = domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: "🎁", TextColor: true} + } + return []domain.DocumentAttribute{ + {Kind: domain.DocAttrImageSize, W: 512, H: 512}, + renderAttribute, + {Kind: domain.DocAttrFilename, FileName: string(kind) + ".tgs"}, + } +} + func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) { if s == nil || s.store == nil || giftID <= 0 { return domain.StarGiftUpgradePreview{}, false, nil @@ -433,6 +474,14 @@ func (s *Service) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[i return s.store.UniqueByIDs(ctx, uniqueGiftIDs) } +func (s *Service) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) { + if s == nil || s.store == nil || owner.ID <= 0 || + (owner.Type != domain.PeerTypeUser && owner.Type != domain.PeerTypeChannel) || limit <= 0 { + return []domain.UniqueStarGift{}, nil + } + return s.store.ListUniqueByOwner(ctx, owner, min(limit, domain.MaxSavedStarGiftsLimit)) +} + func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) { if s == nil || s.upgrades == nil { return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured") diff --git a/internal/app/updates/service.go b/internal/app/updates/service.go index 1cd2036a..bbae8f17 100644 --- a/internal/app/updates/service.go +++ b/internal/app/updates/service.go @@ -593,6 +593,20 @@ func (s *Service) RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byt }, true, excludeSessionID) } +// RecordUserEmojiStatus durably synchronizes an absolute emoji-status snapshot +// to the account's other sessions and offline difference stream. +func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + if !status.Valid() { + return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStarGiftCollectibleInvalid + } + return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventUserEmojiStatus, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, + EmojiStatus: status, + PtsCount: 1, + }, true, excludeSessionID) +} + // RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对 // 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts // aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。 diff --git a/internal/app/updates/service_test.go b/internal/app/updates/service_test.go index 55a8b8dd..9d124e43 100644 --- a/internal/app/updates/service_test.go +++ b/internal/app/updates/service_test.go @@ -219,6 +219,35 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) { } } +func TestRecordCollectibleEmojiStatusFeedsDifference(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{3, 1} + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + ownerUserID := int64(1000000001) + status := domain.UserEmojiStatus{ + DocumentID: 71, + Collectible: domain.EmojiStatusCollectible{ + CollectibleID: 91, DocumentID: 71, Title: "Gift", Slug: "Gift-1", + PatternDocumentID: 72, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, + }, + } + event, state, err := svc.RecordUserEmojiStatus(ctx, authKeyID, ownerUserID, status, authKeyID, 42) + if err != nil { + t.Fatalf("RecordUserEmojiStatus: %v", err) + } + if event.Type != domain.UpdateEventUserEmojiStatus || event.Pts != 1 || state.Pts != 1 || !event.LacksWirePts() { + t.Fatalf("event/state = %+v / %+v", event, state) + } + diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != 1 || diff.Events[0].EmojiStatus != status || diff.Events[0].Peer.ID != ownerUserID { + t.Fatalf("difference = %+v, want exact collectible snapshot", diff) + } +} + func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) { ctx := context.Background() authKeyID := [8]byte{4} diff --git a/internal/app/users/premium_test.go b/internal/app/users/premium_test.go index 4818b6ac..0dc88f91 100644 --- a/internal/app/users/premium_test.go +++ b/internal/app/users/premium_test.go @@ -107,7 +107,7 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) { svc := NewService(store) // 非会员设置被拒(PREMIUM_ACCOUNT_REQUIRED)。 - if _, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0); !errors.Is(err, domain.ErrPremiumRequired) { + if _, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{DocumentID: 42}); !errors.Is(err, domain.ErrPremiumRequired) { t.Fatalf("non-premium set err = %v, want ErrPremiumRequired", err) } @@ -115,14 +115,14 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) { if _, err := store.SetPremiumUntil(ctx, u.ID, int(time.Now().Add(time.Hour).Unix())); err != nil { t.Fatalf("grant: %v", err) } - set, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0) + set, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{DocumentID: 42}) if err != nil || set.EmojiStatusDocumentID != 42 { t.Fatalf("premium set = %+v err %v, want document 42", set, err) } if _, err := store.SetPremiumUntil(ctx, u.ID, 0); err != nil { t.Fatalf("downgrade: %v", err) } - cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, 0, 0) + cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, domain.UserEmojiStatus{}) if err != nil || cleared.EmojiStatusDocumentID != 0 { t.Fatalf("clear after downgrade = %+v err %v, want cleared", cleared, err) } diff --git a/internal/app/users/service.go b/internal/app/users/service.go index 96b9e27f..993727f2 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -375,17 +375,14 @@ func (s *Service) SweepExpiredPremium(ctx context.Context, now int64, limit int) return users, nil } -// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;documentID=0 清除)。 +// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;零值清除)。 // 清除不要求会员(到期降级后客户端仍可显式清掉残留状态)。 -func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) { - self, err := s.loadSelf(ctx, userID) +func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { + self, err := s.validateEmojiStatusUpdate(ctx, userID, status) if err != nil { return domain.User{}, err } - if documentID != 0 && !self.PremiumActiveAt(time.Now().Unix()) { - return domain.User{}, domain.ErrPremiumRequired - } - u, err := s.users.UpdateEmojiStatus(ctx, self.ID, documentID, until) + u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status) if err != nil { return domain.User{}, err } @@ -393,6 +390,52 @@ func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentI return u, nil } +// UpdateEmojiStatusWithEvent uses the store's aggregate transaction when it +// is available. The bool reports whether the returned event was durably +// appended with dispatch; lightweight memory/test wiring falls back to the +// ordinary state write and lets the RPC's Updates service append the event. +func (s *Service) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, date int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, bool, error) { + self, err := s.validateEmojiStatusUpdate(ctx, userID, status) + if err != nil { + return domain.User{}, domain.UpdateEvent{}, false, err + } + writer, ok := s.users.(store.UserEmojiStatusEventStore) + if !ok { + u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status) + if err == nil { + s.refreshCachedUsers(ctx, u) + } + return u, domain.UpdateEvent{}, false, err + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventUserEmojiStatus, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: self.ID}, + EmojiStatus: status, + Date: date, + PtsCount: 1, + } + u, event, err := writer.UpdateEmojiStatusWithEvent(ctx, self.ID, status, event, excludeAuthKeyID, excludeSessionID) + if err != nil { + return domain.User{}, domain.UpdateEvent{}, false, err + } + s.refreshCachedUsers(ctx, u) + return u, event, true, nil +} + +func (s *Service) validateEmojiStatusUpdate(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + if !status.Valid() { + return domain.User{}, domain.ErrStarGiftCollectibleInvalid + } + if !status.Empty() && !self.PremiumActiveAt(time.Now().Unix()) { + return domain.User{}, domain.ErrPremiumRequired + } + return self, nil +} + // UpdateBirthday 设置/清除用户生日(account.updateBirthday)。零值 Birthday 表示清除。 func (s *Service) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { self, err := s.loadSelf(ctx, userID) @@ -513,7 +556,7 @@ func (s *Service) loadBaseUsersByIDs(ctx context.Context, userIDs []int64) ([]do if s.cache != nil { if cached, err := s.cache.GetByIDs(ctx, ids); err == nil && len(cached) > 0 { for id, u := range cached { - if u.ID != 0 { + if u.ID != 0 && u.EmojiStatusCollectible.Empty() { loaded[id] = u } } @@ -561,7 +604,18 @@ func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) { if s.cache == nil || len(users) == 0 { return } - _ = s.cache.PutMany(ctx, users) + cacheable := make([]domain.User, 0, len(users)) + for _, user := range users { + // Collectible ownership may change inside the star-gift aggregate. Keep + // these uncommon users on the authoritative store path so the database + // lifecycle trigger can never be masked by a stale base-user cache entry. + if user.ID != 0 && user.EmojiStatusCollectible.Empty() { + cacheable = append(cacheable, user) + } + } + if len(cacheable) > 0 { + _ = s.cache.PutMany(ctx, cacheable) + } } func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) { diff --git a/internal/botapi/server.go b/internal/botapi/server.go index 85607a1f..8ffae5b2 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -27,7 +27,7 @@ type BotsService interface { } type UsersService interface { - UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) + UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) } type WebAppService interface { @@ -661,7 +661,7 @@ func (h *handler) setUserEmojiStatus(w http.ResponseWriter, r *http.Request, bot } until = n } - if _, err := h.users.UpdateEmojiStatus(r.Context(), userID, documentID, until); err != nil { + if _, err := h.users.UpdateEmojiStatus(r.Context(), userID, domain.UserEmojiStatus{DocumentID: documentID, Until: until}); err != nil { if errors.Is(err, domain.ErrPremiumRequired) { writeAPIError(w, http.StatusBadRequest, "PREMIUM_ACCOUNT_REQUIRED") return diff --git a/internal/domain/channel.go b/internal/domain/channel.go index c6f81598..ff3affe9 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -603,17 +603,21 @@ type ChannelMessage struct { From Peer SendAs *Peer // SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。 - SavedPeer Peer - Date int - EditDate int - Post bool - Silent bool - NoForwards bool - Body string - Entities []MessageEntity - ReplyTo *MessageReply - Forward *MessageForward - ViaBotID int64 + SavedPeer Peer + // SuggestedPost 是频道私信建议投稿的不可变发送快照;普通频道消息为 nil。 + SuggestedPost *SuggestedPost + // PaidMessageStars 是本条频道私信实际扣除的 Stars;管理员免费回复及普通频道消息为 0。 + PaidMessageStars int64 + Date int + EditDate int + Post bool + Silent bool + NoForwards bool + Body string + Entities []MessageEntity + ReplyTo *MessageReply + Forward *MessageForward + ViaBotID int64 // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 GroupedID int64 ReplyMarkup *MessageReplyMarkup @@ -1459,7 +1463,15 @@ type SendMonoforumMessageRequest struct { IdempotencyPreflighted bool Message string Entities []MessageEntity - Date int + Media *MessageMedia + ReplyTo *MessageReply + Silent bool + NoForwards bool + SuggestedPost *SuggestedPost + // AllowPaidStars 是客户端授权的最高可扣金额;实际扣款取频道当前价格,绝不按授权上限扣款。 + AllowPaidStars int64 + ClearDraft bool + Date int } // ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one @@ -1579,6 +1591,8 @@ type SendChannelMessageResult struct { Event ChannelUpdateEvent Recipients []int64 Duplicate bool + // SenderStarsBalance 仅在实际发生 paid-message 借记时返回;RPC 只向发件人投影余额更新。 + SenderStarsBalance *StarsBalance // ReplayDeleteEvent is the existing durable channel delete event paired // with a deleted exact-random_id replay. It must be returned only to the // caller echo and must never be fanned out as a fresh event. diff --git a/internal/domain/channel_errors.go b/internal/domain/channel_errors.go index 9ece8f49..92c2f2d0 100644 --- a/internal/domain/channel_errors.go +++ b/internal/domain/channel_errors.go @@ -6,37 +6,38 @@ import ( ) 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") - ErrUserNotParticipant = errors.New("user not participant") - ErrBotGroupsBlocked = errors.New("bot groups blocked") - ErrReactionInvalid = errors.New("reaction invalid") - ErrReactionsTooMany = errors.New("reactions too many") + 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") + ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported") + 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") + ErrUserNotParticipant = errors.New("user not participant") + ErrBotGroupsBlocked = errors.New("bot groups blocked") + ErrReactionInvalid = errors.New("reaction invalid") + ErrReactionsTooMany = errors.New("reactions too many") ) // SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation. diff --git a/internal/domain/dialog.go b/internal/domain/dialog.go index 2347fa4d..ab89e027 100644 --- a/internal/domain/dialog.go +++ b/internal/domain/dialog.go @@ -101,19 +101,42 @@ type DialogDraftWebPage struct { Optional bool } +type SuggestedPostPriceKind string + +const ( + SuggestedPostPriceStars SuggestedPostPriceKind = "stars" + SuggestedPostPriceTON SuggestedPostPriceKind = "ton" +) + +// SuggestedPostPrice is either a decimal Stars amount or a nanotons amount. +type SuggestedPostPrice struct { + Kind SuggestedPostPriceKind + Amount int64 + Nanos int +} + +// SuggestedPost is the domain-only snapshot shared by a monoforum message and its cloud draft. +type SuggestedPost struct { + Accepted bool + Rejected bool + Price *SuggestedPostPrice + ScheduleDate int +} + // 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 - RichMessage *MessageRichMessage + Peer Peer + TopMessageID int + Date int + NoWebpage bool + InvertMedia bool + Message string + Entities []MessageEntity + ReplyTo *MessageReply + WebPage *DialogDraftWebPage + Effect int64 + SuggestedPost *SuggestedPost + RichMessage *MessageRichMessage } // Empty reports whether this draft should clear the cloud draft slot. @@ -126,6 +149,7 @@ func (d DialogDraft) Empty() bool { (d.ReplyTo == nil || replyOnlyTopic) && d.WebPage == nil && d.Effect == 0 && + d.SuggestedPost == nil && d.RichMessage.IsZero() } diff --git a/internal/domain/star_gift.go b/internal/domain/star_gift.go index 52a5b0c1..9d7e44b0 100644 --- a/internal/domain/star_gift.go +++ b/internal/domain/star_gift.go @@ -238,6 +238,29 @@ type UniqueStarGift struct { CreatedAt time.Time } +// CollectibleEmojiStatus projects an immutable unique gift into the complete +// status shape consumed by Telegram clients. Ownership/lifecycle validation +// is intentionally performed by the caller because it depends on the actor; +// this helper validates only the immutable renderable facts. +func CollectibleEmojiStatus(g UniqueStarGift) (EmojiStatusCollectible, bool) { + status := EmojiStatusCollectible{ + CollectibleID: g.ID, + Title: g.Title, + Slug: g.Slug, + CenterColor: g.Backdrop.CenterColor, + EdgeColor: g.Backdrop.EdgeColor, + PatternColor: g.Backdrop.PatternColor, + TextColor: g.Backdrop.TextColor, + } + if g.Model.Document != nil { + status.DocumentID = g.Model.Document.ID + } + if g.Pattern.Document != nil { + status.PatternDocumentID = g.Pattern.Document.ID + } + return status, status.Valid() +} + type StarGiftCurrency string const ( diff --git a/internal/domain/stars.go b/internal/domain/stars.go index 84362af4..dc3f1b60 100644 --- a/internal/domain/stars.go +++ b/internal/domain/stars.go @@ -3,6 +3,7 @@ package domain import ( "encoding/base64" "errors" + "fmt" "strconv" ) @@ -31,8 +32,9 @@ const ( StarsReasonGiftAuction StarsTransactionReason = "gift_auction" StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade" StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details" - StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 - StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 + StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 + StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费 + StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 ) // StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。 @@ -98,6 +100,17 @@ var ( ErrStarsInvalidAmount = errors.New("stars: invalid amount") ) +// StarsPaymentRequiredError reports the minimum paid-message authorization the +// sender must include in allow_paid_stars. The authorization is a ceiling; the +// ledger debits only the channel's current configured price. +type StarsPaymentRequiredError struct { + Stars int64 +} + +func (e *StarsPaymentRequiredError) Error() string { + return fmt.Sprintf("stars: allow payment required: %d", e.Stars) +} + // EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。 func EncodeStarsCursor(id int64) string { return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go index 3d37da73..c7d687e6 100644 --- a/internal/domain/update_event.go +++ b/internal/domain/update_event.go @@ -29,8 +29,11 @@ const ( UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked" // UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL // 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。 - UpdateEventUserPhone UpdateEventType = "user_phone" - UpdateEventDeleteMessages UpdateEventType = "delete_messages" + UpdateEventUserPhone UpdateEventType = "user_phone" + // UpdateEventUserEmojiStatus carries the exact immutable status snapshot. + // It consumes account pts even though updateUserEmojiStatus has no pts. + UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status" + UpdateEventDeleteMessages UpdateEventType = "delete_messages" // UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消 // 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。 // TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。 @@ -81,6 +84,7 @@ type UpdateEvent struct { Peers []Peer Bool bool Phone string + EmojiStatus UserEmojiStatus Settings PeerSettings MessageIDs []int MaxID int @@ -127,6 +131,7 @@ func (e UpdateEvent) LacksWirePts() bool { UpdateEventPeerSettings, UpdateEventPeerStoryBlocked, UpdateEventUserPhone, + UpdateEventUserEmojiStatus, UpdateEventDialogFilter, UpdateEventDialogFilterOrder, UpdateEventDialogFilters, diff --git a/internal/domain/user.go b/internal/domain/user.go index 9b78f6b9..61035f0a 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -16,6 +16,73 @@ type PeerColor struct { BackgroundEmojiID int64 } +// EmojiStatusCollectible is the immutable projection needed to render a +// collectible gift as an emoji status. The source of truth remains the owned +// UniqueStarGift; users store an immutable snapshot so every user projection, +// online update and offline difference observes the same shape without an +// RPC-layer lookup. +type EmojiStatusCollectible struct { + CollectibleID int64 `json:"collectible_id"` + DocumentID int64 `json:"document_id"` + Title string `json:"title"` + Slug string `json:"slug"` + PatternDocumentID int64 `json:"pattern_document_id"` + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + PatternColor int `json:"pattern_color"` + TextColor int `json:"text_color"` +} + +// Empty reports whether no collectible status is present. +func (s EmojiStatusCollectible) Empty() bool { + return s == (EmojiStatusCollectible{}) +} + +// Valid enforces the complete collectible status shape. Partial snapshots +// are forbidden because clients would otherwise render a gradient without its +// model/pattern or be unable to resolve the collectible link. +func (s EmojiStatusCollectible) Valid() bool { + if s.CollectibleID <= 0 || s.DocumentID <= 0 || s.PatternDocumentID <= 0 || + s.Title == "" || s.Slug == "" { + return false + } + for _, color := range []int{s.CenterColor, s.EdgeColor, s.PatternColor, s.TextColor} { + if color < 0 || color > 0xffffff { + return false + } + } + return true +} + +// UserEmojiStatus is the protocol-neutral mutation value accepted by the user +// service/store boundary. Exactly one of a normal document or a complete +// collectible snapshot may be active; the zero value clears the status. +type UserEmojiStatus struct { + DocumentID int64 `json:"document_id"` + Until int `json:"until,omitempty"` + Collectible EmojiStatusCollectible `json:"collectible,omitempty"` +} + +func (s UserEmojiStatus) Empty() bool { + return s.DocumentID == 0 && s.Collectible.Empty() +} + +func (s UserEmojiStatus) Valid() bool { + if s.Until < 0 { + return false + } + if s.Empty() { + return s.Until == 0 + } + if s.DocumentID <= 0 { + return false + } + if s.Collectible.Empty() { + return true + } + return s.Collectible.Valid() && s.DocumentID == s.Collectible.DocumentID +} + // Empty reports whether no explicit color/profile color state is set. func (c PeerColor) Empty() bool { return !c.HasColor && c.BackgroundEmojiID == 0 @@ -47,9 +114,11 @@ type User struct { PremiumUntil int // EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status //(premium 专属,account.updateEmojiStatus)。DocumentID==0 表示未设置; - // Until==0 表示永久。 - EmojiStatusDocumentID int64 - EmojiStatusUntil int + // Until==0 表示永久。EmojiStatusCollectible 非零时 DocumentID 必须等于 + // collectible 的 model document id。 + EmojiStatusDocumentID int64 + EmojiStatusUntil int + EmojiStatusCollectible EmojiStatusCollectible // Birthday 是用户公开生日(account.updateBirthday)。零值表示未设置。 Birthday Birthday // PersonalChannelID 是资料页展示的「个人频道」(account.updatePersonalChannel); @@ -86,12 +155,21 @@ func (u User) PremiumActiveAt(now int64) bool { // (已设置且未过期;Until==0 表示永久)。emoji status 是 premium 专属,到期 // 降级后即便列仍有残值也不再下发。 func (u User) EmojiStatusActiveAt(now int64) bool { - if !u.PremiumActiveAt(now) || u.EmojiStatusDocumentID == 0 { + if !u.PremiumActiveAt(now) || !u.EmojiStatus().Valid() || u.EmojiStatusDocumentID == 0 { return false } return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now } +// EmojiStatus returns the complete status snapshot carried by this user. +func (u User) EmojiStatus() UserEmojiStatus { + return UserEmojiStatus{ + DocumentID: u.EmojiStatusDocumentID, + Until: u.EmojiStatusUntil, + Collectible: u.EmojiStatusCollectible, + } +} + // DeletedTombstone strips every viewer-dependent or personally identifying // field while preserving the immutable id and lifecycle audit facts. func (u User) DeletedTombstone() User { diff --git a/internal/rpc/account.go b/internal/rpc/account.go index 59024407..cfc40498 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -119,11 +119,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) { Hash) }) registerRPC[*tg.AccountGetCollectibleEmojiStatusesRequest](d, tlprofile.SemanticMethodAccountGetCollectibleEmojiStatuses, func(ctx context.Context, layerRequest *tg.AccountGetCollectibleEmojiStatusesRequest) (any, error) { - hash := layerRequest. - Hash - _ = hash - - return tdesktop.CollectibleEmojiStatuses(), nil + return r.onAccountGetCollectibleEmojiStatuses(ctx, layerRequest.Hash) }) registerRPC[*tg.AccountGetDefaultGroupPhotoEmojisRequest](d, tlprofile.SemanticMethodAccountGetDefaultGroupPhotoEmojis, func(ctx context.Context, layerRequest *tg.AccountGetDefaultGroupPhotoEmojisRequest) (any, error) { hash := layerRequest. @@ -1611,10 +1607,10 @@ func (r *Router) onAccountUpdatePersonalChannel(ctx context.Context, channel tg. return true, nil } -// onAccountUpdateEmojiStatus 持久化用户自定义 emoji status(premium 专属)。 -// emojiStatusEmpty 与未支持的 collectible 类型按清除处理(collectible 依赖 -// Stars 礼物模型,范围外,记兼容矩阵);变更经 updateUserEmojiStatus 推给 -// 本人全部在线 session(self user 对象同时携带最新 emoji_status 字段)。 +// onAccountUpdateEmojiStatus persists either a normal custom emoji or a +// complete collectible snapshot. Collectibles must still be locally owned by +// the actor; unsupported constructors are rejected instead of being mistaken +// for a clear operation. func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.EmojiStatusClass) (bool, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -1624,33 +1620,105 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji if !ok { return true, nil // 服务未接通(精简测试装配)时保持旧 stub 语义 } - var documentID int64 - var until int - if s, ok := status.(*tg.EmojiStatus); ok { - documentID = s.DocumentID - if v, ok := s.GetUntil(); ok { - until = v - } + value, err := r.domainUserEmojiStatus(ctx, userID, status) + if err != nil { + return false, err + } + var ( + u domain.User + event domain.UpdateEvent + durableWrite bool + ) + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + if durable, ok := r.deps.Users.(UserEmojiStatusDurableService); ok { + u, event, durableWrite, err = durable.UpdateEmojiStatusWithEvent( + ctx, userID, value, int(r.clock.Now().Unix()), rawAuthKeyIDForOrigin(ctx), sessionID, + ) + } else { + u, err = svc.UpdateEmojiStatus(ctx, userID, value) } - u, err := svc.UpdateEmojiStatus(ctx, userID, documentID, until) if err != nil { if errors.Is(err, domain.ErrPremiumRequired) { return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED") } + if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + return false, tgerr400("COLLECTIBLE_INVALID") + } return false, internalErr() } r.invalidateRPCProjectionForUser(u.ID) - r.pushUserUpdates(ctx, u.ID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{ - UserID: u.ID, - EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()), - }}, - Users: []tg.UserClass{r.tgSelfUser(u)}, - Date: int(r.clock.Now().Unix()), - }) + update := &tg.UpdateUserEmojiStatus{UserID: u.ID, EmojiStatus: tgUserEmojiStatusValue(value)} + if durableWrite { + if sessionID != 0 { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date, + }) + } else if updates, ok := r.deps.Updates.(UserEmojiStatusUpdatesService); ok { + event, _, recordErr := updates.RecordUserEmojiStatus(ctx, authKeyID, userID, value, rawAuthKeyIDForOrigin(ctx), sessionID) + if recordErr != nil { + return false, internalErr() + } + if sessionID != 0 { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: event.Date, + }) + } else { + // Lightweight test deployments without the durable extension retain the + // previous online-only behavior; production wiring implements it. + r.pushUserUpdates(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, Users: []tg.UserClass{r.tgSelfUser(u)}, Date: int(r.clock.Now().Unix()), + }) + } return true, nil } +func (r *Router) domainUserEmojiStatus(ctx context.Context, userID int64, input tg.EmojiStatusClass) (domain.UserEmojiStatus, error) { + switch status := input.(type) { + case *tg.EmojiStatusEmpty: + return domain.UserEmojiStatus{}, nil + case *tg.EmojiStatus: + value := domain.UserEmojiStatus{DocumentID: status.DocumentID} + if until, ok := status.GetUntil(); ok { + value.Until = until + } + if !value.Valid() { + return domain.UserEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + return value, nil + case *tg.InputEmojiStatusCollectible: + if r.deps.Gifts == nil || status.CollectibleID <= 0 { + return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID") + } + gift, found, err := r.deps.Gifts.UniqueByID(ctx, status.CollectibleID) + if err != nil { + return domain.UserEmojiStatus{}, internalErr() + } + owner := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + if !found || gift.Owner != owner || gift.Burned || gift.OwnerAddress != "" { + return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID") + } + collectible, valid := domain.CollectibleEmojiStatus(gift) + if !valid { + return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID") + } + value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible} + if until, ok := status.GetUntil(); ok { + value.Until = until + } + if !value.Valid() { + return domain.UserEmojiStatus{}, tgerr400("COLLECTIBLE_INVALID") + } + return value, nil + default: + return domain.UserEmojiStatus{}, inputConstructorInvalidErr() + } +} + // onAccountUpdateColor 持久化当前用户的消息 accent 或资料页背景色。 // 普通 peerColor 可清除(color flag absent)、可显式设置 color=0;collectible // 颜色依赖礼物资产模型,当前阶段按范围外能力拒绝并记录在兼容矩阵。 @@ -1746,6 +1814,41 @@ func (r *Router) onAccountGetDefaultEmojiStatuses(ctx context.Context, hash int6 return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil } +// onAccountGetCollectibleEmojiStatuses returns the actor's active locally +// owned unique gifts as complete emojiStatusCollectible values. The bounded +// list order and hash are stable, so Android can safely reuse its cache. +func (r *Router) onAccountGetCollectibleEmojiStatuses(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Gifts == nil { + return tdesktop.CollectibleEmojiStatuses(), nil + } + gifts, err := r.deps.Gifts.ListUniqueByOwner(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, domain.MaxSavedStarGiftsLimit) + if err != nil { + return nil, internalErr() + } + ids := make([]int64, 0, len(gifts)) + statuses := make([]tg.EmojiStatusClass, 0, len(gifts)) + for _, gift := range gifts { + collectible, ok := domain.CollectibleEmojiStatus(gift) + if !ok { + continue + } + ids = append(ids, collectible.CollectibleID) + statuses = append(statuses, tgUserEmojiStatusValue(domain.UserEmojiStatus{ + DocumentID: collectible.DocumentID, + Collectible: collectible, + })) + } + catalogHash := mediaCatalogHash(ids) + if hash != 0 && hash == catalogHash { + return &tg.AccountEmojiStatusesNotModified{}, nil + } + return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil +} + func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) { if u.ID == 0 { return diff --git a/internal/rpc/account_collectible_emoji_status_test.go b/internal/rpc/account_collectible_emoji_status_test.go new file mode 100644 index 00000000..b221c2f0 --- /dev/null +++ b/internal/rpc/account_collectible_emoji_status_test.go @@ -0,0 +1,134 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "go.uber.org/zap/zaptest" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type collectibleEmojiGiftService struct { + GiftsService + gifts map[int64]domain.UniqueStarGift +} + +func (s *collectibleEmojiGiftService) UniqueByID(_ context.Context, id int64) (domain.UniqueStarGift, bool, error) { + gift, ok := s.gifts[id] + return gift, ok, nil +} + +func (s *collectibleEmojiGiftService) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) { + out := make([]domain.UniqueStarGift, 0, len(s.gifts)) + for _, gift := range s.gifts { + if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" { + out = append(out, gift) + } + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func collectibleEmojiTestGift(ownerID int64) domain.UniqueStarGift { + return domain.UniqueStarGift{ + ID: 9001, Title: "Plush Pepe", Slug: "PlushPepe-1", + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, + Model: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7101}}, + Pattern: domain.StarGiftCollectibleAttribute{Document: &domain.Document{ID: 7201}}, + Backdrop: domain.StarGiftCollectibleAttribute{ + CenterColor: 0x102030, EdgeColor: 0x405060, + PatternColor: 0x708090, TextColor: 0xa0b0c0, + }, + } +} + +func TestAccountCollectibleEmojiStatusListSetAndRejectNonOwner(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550009101", FirstName: "Owner"}) + if err != nil { + t.Fatal(err) + } + other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550009102", FirstName: "Other"}) + if err != nil { + t.Fatal(err) + } + users := appusers.NewService(userStore) + if _, err := users.GrantPremium(ctx, owner.ID, 1); err != nil { + t.Fatalf("grant premium: %v", err) + } + gift := collectibleEmojiTestGift(owner.ID) + gifts := &collectibleEmojiGiftService{gifts: map[int64]domain.UniqueStarGift{gift.ID: gift}} + r := New(Config{}, Deps{Users: users, Gifts: gifts}, zaptest.NewLogger(t), clock.System) + ownerCtx := WithUserID(ctx, owner.ID) + + listed, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, 0) + if err != nil { + t.Fatalf("get collectible statuses: %v", err) + } + statuses, ok := listed.(*tg.AccountEmojiStatuses) + if !ok || len(statuses.Statuses) != 1 || statuses.Hash == 0 { + t.Fatalf("collectible list = %T %#v", listed, listed) + } + collectible, ok := statuses.Statuses[0].(*tg.EmojiStatusCollectible) + if !ok || collectible.CollectibleID != gift.ID || collectible.DocumentID != gift.Model.Document.ID || + collectible.PatternDocumentID != gift.Pattern.Document.ID || collectible.PatternColor != gift.Backdrop.PatternColor { + t.Fatalf("collectible status = %T %#v", statuses.Statuses[0], statuses.Statuses[0]) + } + if cached, err := r.onAccountGetCollectibleEmojiStatuses(ownerCtx, statuses.Hash); err != nil { + t.Fatalf("get cached collectible statuses: %v", err) + } else if _, ok := cached.(*tg.AccountEmojiStatusesNotModified); !ok { + t.Fatalf("cached collectible statuses = %T, want notModified", cached) + } + + input := &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID} + input.SetUntil(2_000_000_000) + if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, input); err != nil || !ok { + t.Fatalf("set collectible status: ok=%v err=%v", ok, err) + } + self, err := users.Self(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + if !self.EmojiStatusCollectible.Valid() || self.EmojiStatusCollectible.CollectibleID != gift.ID || + self.EmojiStatusUntil != 2_000_000_000 { + t.Fatalf("persisted collectible status = %+v", self.EmojiStatus()) + } + wire, ok := tgUserEmojiStatus(self, time.Now().Unix()).(*tg.EmojiStatusCollectible) + if !ok || wire.Slug != gift.Slug || wire.TextColor != gift.Backdrop.TextColor { + t.Fatalf("wire collectible = %T %#v", tgUserEmojiStatus(self, time.Now().Unix()), wire) + } + + stolen := gift + stolen.Owner = domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} + gifts.gifts[gift.ID] = stolen + if ok, err := r.onAccountUpdateEmojiStatus(ownerCtx, &tg.InputEmojiStatusCollectible{CollectibleID: gift.ID}); ok || !tgerr.Is(err, "COLLECTIBLE_INVALID") { + t.Fatalf("set non-owned collectible: ok=%v err=%v", ok, err) + } +} + +func TestCollectibleEmojiStatusDurableUpdateProjection(t *testing.T) { + collectible, ok := domain.CollectibleEmojiStatus(collectibleEmojiTestGift(1)) + if !ok { + t.Fatal("test gift should project") + } + value := domain.UserEmojiStatus{DocumentID: collectible.DocumentID, Collectible: collectible} + update, ok := tgOtherUpdateFromEvent(domain.UpdateEvent{ + UserID: 1, Type: domain.UpdateEventUserEmojiStatus, EmojiStatus: value, + }).(*tg.UpdateUserEmojiStatus) + if !ok { + t.Fatal("durable event did not produce updateUserEmojiStatus") + } + if status, ok := update.EmojiStatus.(*tg.EmojiStatusCollectible); !ok || status.PatternDocumentID != collectible.PatternDocumentID { + t.Fatalf("durable wire status = %T %#v", update.EmojiStatus, update.EmojiStatus) + } +} diff --git a/internal/rpc/bots_longtail.go b/internal/rpc/bots_longtail.go index 097cb8b8..174f9f01 100644 --- a/internal/rpc/bots_longtail.go +++ b/internal/rpc/bots_longtail.go @@ -422,7 +422,7 @@ func (r *Router) onBotsUpdateUserEmojiStatus(ctx context.Context, req *tg.BotsUp if !ok { return false, userPermissionDeniedErr() } - u, err := svc.UpdateEmojiStatus(ctx, target.ID, documentID, until) + u, err := svc.UpdateEmojiStatus(ctx, target.ID, domain.UserEmojiStatus{DocumentID: documentID, Until: until}) if err != nil { if errors.Is(err, domain.ErrPremiumRequired) { return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED") diff --git a/internal/rpc/channel_fanout_dispatcher.go b/internal/rpc/channel_fanout_dispatcher.go index 076351a4..827aebd6 100644 --- a/internal/rpc/channel_fanout_dispatcher.go +++ b/internal/rpc/channel_fanout_dispatcher.go @@ -993,6 +993,22 @@ func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID i }) } +// enqueueMonoforumMessageFanout only targets the subscriber sub-dialog and active parent-channel +// admins. A monoforum has no ordinary members, so member recomputation would either drop the +// message or leak it to an invalid historical membership. +func (r *Router) enqueueMonoforumMessageFanout(ctx context.Context, originUserID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) { + fanoutCache := newViewerPeerCache(r) + ownerIDs := channelMessageFanoutOwnerIDs(res, []int64{savedPeer.ID}) + r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutExplicit, originUserID, mono.ID, res.Event.Pts, res.Recipients, + 0, + func(bgCtx context.Context, viewers []int64) { + r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) + }, + func(bgCtx context.Context, viewerUserID int64) *tg.Updates { + return r.monoforumDeliveryUpdates(bgCtx, viewerUserID, mono, savedPeer, res) + }) +} + // skipDeliverySet 把 SkipDeliveryUserIDs 切片转成查找集合(nil 表示无排除)。 func skipDeliverySet(ids []int64) map[int64]struct{} { if len(ids) == 0 { diff --git a/internal/rpc/channels_stubs.go b/internal/rpc/channels_stubs.go index 791496e4..3a0f951d 100644 --- a/internal/rpc/channels_stubs.go +++ b/internal/rpc/channels_stubs.go @@ -669,6 +669,8 @@ func channelInvalidErr(err error) error { return tgerr400("CHAT_WRITE_FORBIDDEN") case errors.Is(err, domain.ErrChannelAdminRequired): return tgerr400("CHAT_ADMIN_REQUIRED") + case errors.Is(err, domain.ErrChannelMonoforumUnsupported): + return tgerr400("CHANNEL_MONOFORUM_UNSUPPORTED") case errors.Is(err, domain.ErrUserAlreadyParticipant): return tgerr400("USER_ALREADY_PARTICIPANT") case errors.Is(err, domain.ErrReplyMessageIDInvalid): diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index a53cc0a5..60a0021b 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -82,7 +82,7 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla return nil } peer := &tg.PeerChannel{ChannelID: m.ChannelID} - outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && m.From.Type != domain.PeerTypeChannel + outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && (m.SavedPeer.ID != 0 || m.From.Type != domain.PeerTypeChannel) from := tg.PeerClass(nil) if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 { from = tgPeer(*m.SendAs) @@ -139,6 +139,12 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla // 频道私信(monoforum):saved_peer_id 让客户端把消息归入对应订阅者子会话。 msg.SetSavedPeerID(tgPeer(m.SavedPeer)) } + if suggested, ok := tgSuggestedPost(m.SuggestedPost); ok { + msg.SetSuggestedPost(suggested) + } + if m.PaidMessageStars > 0 { + msg.SetPaidMessageStars(m.PaidMessageStars) + } if m.Pinned { msg.SetPinned(true) } diff --git a/internal/rpc/convert_dialogs.go b/internal/rpc/convert_dialogs.go index ca72a92a..d554cbe1 100644 --- a/internal/rpc/convert_dialogs.go +++ b/internal/rpc/convert_dialogs.go @@ -165,6 +165,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass { if rich := mustTGRichMessage(d.RichMessage); rich != nil { out.SetRichMessage(*rich) } + if suggested, ok := tgSuggestedPost(d.SuggestedPost); ok { + out.SetSuggestedPost(suggested) + } return out } diff --git a/internal/rpc/convert_updates.go b/internal/rpc/convert_updates.go index 0a65d45d..efdb6bea 100644 --- a/internal/rpc/convert_updates.go +++ b/internal/rpc/convert_updates.go @@ -237,6 +237,11 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass { return nil } return &tg.UpdateUserPhone{UserID: event.UserID, Phone: event.Phone} + case domain.UpdateEventUserEmojiStatus: + if event.UserID == 0 || !event.EmojiStatus.Valid() { + return nil + } + return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)} case domain.UpdateEventChannelState: if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { return nil diff --git a/internal/rpc/convert_users.go b/internal/rpc/convert_users.go index 1a333599..1d2e05d7 100644 --- a/internal/rpc/convert_users.go +++ b/internal/rpc/convert_users.go @@ -85,9 +85,35 @@ func tgUserEmojiStatus(u domain.User, now int64) tg.EmojiStatusClass { if !u.EmojiStatusActiveAt(now) { return &tg.EmojiStatusEmpty{} } - status := &tg.EmojiStatus{DocumentID: u.EmojiStatusDocumentID} - if u.EmojiStatusUntil > 0 { - status.SetUntil(u.EmojiStatusUntil) + return tgUserEmojiStatusValue(u.EmojiStatus()) +} + +// tgUserEmojiStatusValue converts an already validated absolute snapshot. It +// is shared by inline user projections and durable updateUserEmojiStatus. +func tgUserEmojiStatusValue(value domain.UserEmojiStatus) tg.EmojiStatusClass { + if !value.Valid() || value.Empty() { + return &tg.EmojiStatusEmpty{} + } + if collectible := value.Collectible; !collectible.Empty() { + status := &tg.EmojiStatusCollectible{ + CollectibleID: collectible.CollectibleID, + DocumentID: collectible.DocumentID, + Title: collectible.Title, + Slug: collectible.Slug, + PatternDocumentID: collectible.PatternDocumentID, + CenterColor: collectible.CenterColor, + EdgeColor: collectible.EdgeColor, + PatternColor: collectible.PatternColor, + TextColor: collectible.TextColor, + } + if value.Until > 0 { + status.SetUntil(value.Until) + } + return status + } + status := &tg.EmojiStatus{DocumentID: value.DocumentID} + if value.Until > 0 { + status.SetUntil(value.Until) } return status } diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 2185c832..1aded8b5 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -298,7 +298,14 @@ type UserIdentityService interface { type UserPremiumService interface { GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) - UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) + UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) +} + +// UserEmojiStatusDurableService exposes the aggregate state+event write used +// by account.updateEmojiStatus. The bool is false for lightweight stores that +// require the RPC Updates service to append the event separately. +type UserEmojiStatusDurableService interface { + UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, date int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, bool, error) } // UserColorService 是 UsersService 的个人色板扩展能力。用于 account.updateColor @@ -429,6 +436,13 @@ type UpdatesService interface { RecordDraftMessage(ctx context.Context, stateAuthKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) } +// UserEmojiStatusUpdatesService is the optional durable settings-update +// extension used by account.updateEmojiStatus. Keeping it separate preserves +// lightweight test/service implementations of the core UpdatesService. +type UserEmojiStatusUpdatesService interface { + RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) +} + // ContactsService 抽象通讯录查询。 type ContactsService interface { GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) @@ -871,6 +885,7 @@ type GiftsService interface { UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) + ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 365cf7d2..d429bf1f 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -129,6 +129,10 @@ func effectIDInvalidErr() error { return tgerr.New(400, "EFFECT_ID_INVALID") } func paymentUnsupportedErr() error { return tgerr.New(406, "PAYMENT_UNSUPPORTED") } +func allowPaymentRequiredErr(stars int64) error { + return tgerr.New(403, fmt.Sprintf("ALLOW_PAYMENT_REQUIRED_%d", stars)) +} + func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") } func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") } diff --git a/internal/rpc/messages_dialogs.go b/internal/rpc/messages_dialogs.go index b9f98689..ee5d4e4a 100644 --- a/internal/rpc/messages_dialogs.go +++ b/internal/rpc/messages_dialogs.go @@ -3,10 +3,12 @@ package rpc import ( "context" "errors" + "unicode/utf8" + "github.com/iamxvbaba/td/tg" "go.uber.org/zap" + "telesrv/internal/domain" - "unicode/utf8" ) func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDraftRequest) (bool, error) { @@ -159,8 +161,18 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee if len(req.Entities) > maxMessageEntityCount { return domain.DialogDraft{}, limitInvalidErr() } - if !req.SuggestedPost.Zero() { - return domain.DialogDraft{}, suggestedPostPeerInvalidErr() + suggestedInput, hasSuggestedPost := req.GetSuggestedPost() + suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost) + if err != nil { + return domain.DialogDraft{}, err + } + if hasSuggestedPost { + if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return domain.DialogDraft{}, suggestedPostPeerInvalidErr() + } + if _, _, resolveErr := r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID); resolveErr != nil { + return domain.DialogDraft{}, suggestedPostPeerInvalidErr() + } } replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo) if err != nil { @@ -182,17 +194,18 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee 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, - RichMessage: richMessage, + 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, + SuggestedPost: suggestedPost, + RichMessage: richMessage, }, nil } diff --git a/internal/rpc/messages_monoforum.go b/internal/rpc/messages_monoforum.go index c0fb50a6..fabfb9ce 100644 --- a/internal/rpc/messages_monoforum.go +++ b/internal/rpc/messages_monoforum.go @@ -103,8 +103,8 @@ func (r *Router) monoforumSavedHistory(ctx context.Context, userID int64, mono d }, nil } -// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身(直接投影,管理员 -// 非其成员故不能走可见性受限的 GetChannels)+ 母广播频道(管理员是其成员)。 +// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身直接投影, +// 再按 viewer 补母广播频道。订阅者没有 monoforum member row,管理员身份也只来自母频道。 func (r *Router) monoforumChats(ctx context.Context, userID int64, mono domain.Channel) []tg.ChatClass { chats := []tg.ChatClass{tgChannelChatForView(userID, domain.ChannelView{Channel: mono})} if mono.LinkedMonoforumID != 0 && r.deps.Channels != nil { @@ -151,8 +151,8 @@ func (r *Router) monoforumSubscriberUsers(ctx context.Context, userID int64, dia return r.tgUsers(found) } -// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否带 monoforum_peer_id(频道私信发送的唯一标志)。 -// 普通发送恒不带,故据此 gate monoforum 分支,普通发送热路径零额外成本。 +// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否显式携带 monoforum_peer_id。 +// 管理员回复必须带目标订阅者;普通订阅者按官方 TDesktop 行为不携带 reply_to,目标由调用者推导。 func monoforumReplyPresent(input tg.InputReplyToClass) bool { switch v := input.(type) { case *tg.InputReplyToMonoForum: @@ -189,46 +189,75 @@ func (r *Router) monoforumReplyTargetPeer(userID int64, input tg.InputReplyToCla return r.domainPeerFromInputPeer(userID, inputPeer) } +func (r *Router) monoforumSavedPeerForSender(userID int64, isAdmin bool, replyTo tg.InputReplyToClass) (domain.Peer, error) { + savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + if monoforumReplyPresent(replyTo) { + var valid bool + savedPeer, valid = r.monoforumReplyTargetPeer(userID, replyTo) + if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 { + return domain.Peer{}, replyToMonoforumPeerInvalidErr() + } + } else if isAdmin { + return domain.Peer{}, replyToMonoforumPeerInvalidErr() + } + if !isAdmin && savedPeer.ID != userID { + return domain.Peer{}, replyToMonoforumPeerInvalidErr() + } + return savedPeer, nil +} + +// monoforumMessageReplyFromInput separates the sub-dialog selector from the actual message reply. +// InputReplyToMonoForum only selects a subscriber; InputReplyToMessage may carry both the selector +// and a real reply_to_msg_id, so clear flags.5 before reusing the common structural validator. +func (r *Router) monoforumMessageReplyFromInput(ctx context.Context, userID int64, peer domain.Peer, input tg.InputReplyToClass) (*domain.MessageReply, error) { + switch value := input.(type) { + case nil, *tg.InputReplyToMonoForum: + return nil, nil + case *tg.InputReplyToMessage: + if value == nil { + return nil, nil + } + clean := *value + clean.Flags.Unset(5) + clean.MonoforumPeerID = nil + return r.messageReplyFromInput(ctx, userID, peer, &clean) + default: + return r.messageReplyFromInput(ctx, userID, peer, input) + } +} + // sendMonoforumMessage 处理向频道私信(monoforum)发送:订阅者发到自己的子会话,管理员回复到目标订阅者。 -// saved_peer 来自 reply_to 的 monoforum_peer_id;管理员可写任意订阅者子会话,普通订阅者只能写自己的。 -func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSendMessageRequest, fingerprint []byte, preflighted bool) (tg.UpdatesClass, error) { +// saved_peer 对订阅者由调用者推导、对管理员来自 reply_to;管理员可写任意订阅者子会话,订阅者只能写自己的。 +func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, mono domain.Channel, isAdmin bool, req domain.SendMonoforumMessageRequest) (tg.UpdatesClass, error) { if r.deps.Channels == nil { return nil, notImplementedErr() } if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { return nil, peerIDInvalidErr() } - mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID) - if err != nil { - if errors.Is(err, domain.ErrChannelInvalid) { - // 带 monoforum_peer_id 却不是 monoforum 频道。 - return nil, tgerr400("CHANNEL_MONOFORUM_UNSUPPORTED") - } - return nil, internalErr() - } - savedPeer, ok := r.monoforumReplyTargetPeer(userID, req.ReplyTo) - if !ok || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 { + if mono.ID != peer.ID || !mono.Monoforum || req.SavedPeer.Type != domain.PeerTypeUser || req.SavedPeer.ID == 0 { return nil, replyToMonoforumPeerInvalidErr() } - if !isAdmin && savedPeer.ID != userID { + if !isAdmin && req.SavedPeer.ID != userID { // 普通订阅者只能写自己的子会话,不能写他人的。 return nil, replyToMonoforumPeerInvalidErr() } - res, err := r.deps.Channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ - MonoforumID: mono.ID, - SenderUserID: userID, - SavedPeer: savedPeer, - RandomID: req.RandomID, - IdempotencyFingerprint: fingerprint, - IdempotencyPreflighted: preflighted, - Message: req.Message, - Entities: domainMessageEntities(req.Entities), - Date: int(r.clock.Now().Unix()), - }) + req.MonoforumID = mono.ID + req.SenderUserID = userID + if req.Date == 0 { + req.Date = int(r.clock.Now().Unix()) + } + res, err := r.deps.Channels.SendMonoforumMessage(ctx, req) if err != nil { return nil, messageSendErr(err) } - return r.monoforumSendUpdates(ctx, userID, mono, savedPeer, res), nil + if req.ClearDraft { + r.clearDraftAfterSend(ctx, userID, peer, req.ReplyTo) + } + if !res.Duplicate { + r.enqueueMonoforumMessageFanout(ctx, userID, mono, req.SavedPeer, res) + } + return r.monoforumSendUpdates(ctx, userID, mono, req.SavedPeer, res), nil } // monoforumSendUpdates 给发送者构造回声 Updates:updateMessageID(关联 random_id)+ updateNewChannelMessage @@ -245,6 +274,11 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID} } updates = append(updates, newMsg) + if res.SenderStarsBalance != nil && res.Message.SenderUserID == userID { + updates = append(updates, &tg.UpdateStarsBalance{ + Balance: &tg.StarsAmount{Amount: res.SenderStarsBalance.Balance}, + }) + } date := int(r.clock.Now().Unix()) if res.Duplicate && res.ReplayDeleteEvent != nil { if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != nil { @@ -261,3 +295,18 @@ func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono do Date: date, } } + +func (r *Router) monoforumDeliveryUpdates(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) *tg.Updates { + updates, _ := r.monoforumSendUpdates(ctx, userID, mono, savedPeer, res).(*tg.Updates) + if updates == nil { + return nil + } + filtered := make([]tg.UpdateClass, 0, len(updates.Updates)) + for _, update := range updates.Updates { + if _, randomMapping := update.(*tg.UpdateMessageID); !randomMapping { + filtered = append(filtered, update) + } + } + updates.Updates = filtered + return updates +} diff --git a/internal/rpc/messages_monoforum_rpc_test.go b/internal/rpc/messages_monoforum_rpc_test.go index 5a70beb7..fd9057bc 100644 --- a/internal/rpc/messages_monoforum_rpc_test.go +++ b/internal/rpc/messages_monoforum_rpc_test.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "strings" "testing" "github.com/iamxvbaba/td/bin" @@ -10,6 +11,7 @@ import ( "go.uber.org/zap/zaptest" appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" appusers "telesrv/internal/app/users" "telesrv/internal/domain" "telesrv/internal/store/memory" @@ -17,7 +19,7 @@ import ( // TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经 // getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史 -// (消息带 saved_peer_id);非管理员被拒。 +// (消息带 saved_peer_id);订阅者经普通 getHistory 只看自己的子会话。 func TestMonoforumSavedDialogsAndHistory(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() @@ -98,12 +100,31 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) { if !seenChats[monoID] || !seenChats[created.Channel.ID] { t.Fatalf("main monoforum chats = %+v, want monoforum %d and parent %d", seenChats, monoID, created.Channel.ID) } - var deniedRaw bin.Buffer - if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&deniedRaw); err != nil { + var subscriberRaw bin.Buffer + if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&subscriberRaw); err != nil { t.Fatalf("encode non-admin getHistory(monoforum): %v", err) } - if _, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &deniedRaw); err == nil { - t.Fatalf("non-admin getHistory(monoforum) = nil err, want denied") + subscriberEnc, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &subscriberRaw) + if err != nil { + t.Fatalf("non-admin getHistory(monoforum): %v", err) + } + subscriberHistory, ok := subscriberEnc.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("non-admin getHistory(monoforum) = %T, want *tg.MessagesChannelMessages", subscriberEnc) + } + if len(subscriberHistory.Messages) != 1 { + t.Fatalf("non-admin getHistory(monoforum) = %d msgs, want own sublist message", len(subscriberHistory.Messages)) + } + subscriberMessage, ok := subscriberHistory.Messages[0].(*tg.Message) + if !ok || subscriberMessage.Message != "hello channel" { + t.Fatalf("non-admin history[0] = %#v, want own 'hello channel'", subscriberHistory.Messages[0]) + } + subscriberSavedPeer, ok := subscriberMessage.GetSavedPeerID() + if !ok { + t.Fatalf("non-admin history message missing saved_peer_id") + } + if peer, ok := subscriberSavedPeer.(*tg.PeerUser); !ok || peer.UserID != sub.ID { + t.Fatalf("non-admin history saved_peer_id = %#v, want self %d", subscriberSavedPeer, sub.ID) } // 管理员看私信列表。 @@ -179,9 +200,9 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) { } } -// TestMonoforumSendMessageWritePath 验证写侧:订阅者经 sendMessage(peer=monoforum, -// reply_to=InputReplyToMonoForum{自己}) 发私信;管理员回复到目标订阅者;订阅者不能写他人子会话; -// 普通发送(无 monoforum_peer_id)不受影响。 +// TestMonoforumSendMessageWritePath 验证写侧:订阅者按 TDesktop 实际请求仅以 +// peer=monoforum 发到自己的子会话;管理员必须显式指定目标订阅者;suggested_post 被持久化返回; +// 订阅者不能写他人子会话。 func TestMonoforumSendMessageWritePath(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() @@ -196,39 +217,160 @@ func TestMonoforumSendMessageWritePath(t *testing.T) { channelStore := memory.NewChannelStore() channelSvc := appchannels.NewService(channelStore) + dialogSvc := appdialogs.NewService(memory.NewDialogStore(), channelStore) r := New(Config{}, Deps{ Users: appusers.NewService(userStore), Channels: channelSvc, + Dialogs: dialogSvc, }, zaptest.NewLogger(t), clock.System) created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000}) if err != nil { t.Fatalf("create channel: %v", err) } - enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true) + enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 10, true) if err != nil { t.Fatalf("enable DM: %v", err) } monoID := enabled.Channel.LinkedMonoforumID - monoInput := &tg.InputPeerChannel{ChannelID: monoID} + mono, err := channelStore.GetChannelByID(ctx, monoID) + if err != nil { + t.Fatalf("get monoforum: %v", err) + } + monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash} + monoChannelInput := &tg.InputChannel{ChannelID: monoID, AccessHash: mono.AccessHash} - // 订阅者发私信到自己的子会话。 + // 订阅者不是 monoforum 成员,但 TDesktop 打开会话时必须能读取 full channel shell。 + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, sub.ID), monoChannelInput) + if err != nil { + t.Fatalf("subscriber getFullChannel(monoforum): %v", err) + } + if full == nil || full.FullChat == nil { + t.Fatalf("subscriber getFullChannel(monoforum) = %#v, want full chat", full) + } + + // monoforum 永远不能通过 join 变成普通频道成员,否则会生成错误的 joined service message。 + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, sub.ID), monoChannelInput); err == nil || !strings.Contains(err.Error(), "CHANNEL_MONOFORUM_UNSUPPORTED") { + t.Fatalf("subscriber joinChannel(monoforum) err = %v, want CHANNEL_MONOFORUM_UNSUPPORTED", err) + } + + // TDesktop 在发送前保存相同 suggested_post 草稿;它必须可写、可恢复,不能变成 CHANNEL_PRIVATE。 + draftSuggested := tg.SuggestedPost{} + draftSuggested.SetPrice(&tg.StarsAmount{Amount: 10}) + draftSuggested.SetScheduleDate(1_700_100_000) + draftReq := &tg.MessagesSaveDraftRequest{Peer: monoInput, Message: "pending suggested post"} + draftReq.SetSuggestedPost(draftSuggested) + if ok, err := r.onMessagesSaveDraft(WithUserID(ctx, sub.ID), draftReq); err != nil || !ok { + t.Fatalf("subscriber saveDraft(monoforum) = %v, %v; want true, nil", ok, err) + } + storedDraft, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0) + if err != nil || !found { + t.Fatalf("get persisted monoforum draft = %+v, %v, %v; want found", storedDraft, found, err) + } + if storedDraft.Message != "pending suggested post" || storedDraft.SuggestedPost == nil || storedDraft.SuggestedPost.Price == nil || storedDraft.SuggestedPost.Price.Amount != 10 || storedDraft.SuggestedPost.ScheduleDate != 1_700_100_000 { + t.Fatalf("persisted monoforum draft = %+v, want suggested post content", storedDraft) + } + tooLow := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "under-authorized", RandomID: 554} + tooLow.SetAllowPaidStars(9) + if _, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), tooLow); err == nil || !strings.Contains(err.Error(), "ALLOW_PAYMENT_REQUIRED") || !strings.Contains(err.Error(), "(10)") { + t.Fatalf("under-authorized paid message err = %v, want ALLOW_PAYMENT_REQUIRED_10", err) + } + + // TDesktop 的订阅者请求不携带 InputReplyToMonoForum;服务端必须从调用者推导 saved_peer=self。 subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub", RandomID: 555} - subReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}}) + subReq.ClearDraft = true + subReq.SetAllowPaidStars(20) + suggestedInput := tg.SuggestedPost{} + suggestedInput.SetPrice(&tg.StarsAmount{Amount: 10}) + suggestedInput.SetScheduleDate(1_700_100_000) + subReq.SetSuggestedPost(suggestedInput) subUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq) if err != nil { t.Fatalf("subscriber sendMessage(monoforum): %v", err) } - if _, ok := subUpd.(*tg.Updates); !ok { + subUpdates, ok := subUpd.(*tg.Updates) + if !ok { t.Fatalf("subscriber send updates = %T, want *tg.Updates", subUpd) } + var subMessageID int + var subPaidStars, subBalance int64 + for _, update := range subUpdates.Updates { + if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok { + if message, ok := newMessage.Message.(*tg.Message); ok { + subMessageID = message.ID + subPaidStars, _ = message.GetPaidMessageStars() + } + } + if balance, ok := update.(*tg.UpdateStarsBalance); ok { + if amount, ok := balance.Balance.(*tg.StarsAmount); ok { + subBalance = amount.Amount + } + } + } + if subMessageID == 0 || subPaidStars != 10 || subBalance != 990 { + t.Fatalf("subscriber send updates id/paid/balance = %d/%d/%d, want id>0/10/990: %#v", subMessageID, subPaidStars, subBalance, subUpdates.Updates) + } + if _, found, err := dialogSvc.GetDraft(ctx, sub.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, 0); err != nil || found { + t.Fatalf("clear_draft after paid send found/err = %v/%v, want false/nil", found, err) + } + duplicateUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq) + if err != nil { + t.Fatalf("subscriber paid replay: %v", err) + } + duplicateUpdates, ok := duplicateUpd.(*tg.Updates) + if !ok { + t.Fatalf("subscriber paid replay = %T, want *tg.Updates", duplicateUpd) + } + var duplicateBalance int64 + for _, update := range duplicateUpdates.Updates { + if balance, ok := update.(*tg.UpdateStarsBalance); ok { + if amount, ok := balance.Balance.(*tg.StarsAmount); ok { + duplicateBalance = amount.Amount + } + } + } + if duplicateBalance != 990 { + t.Fatalf("subscriber paid replay balance = %d, want 990 without a second debit", duplicateBalance) + } - // 管理员回复到该订阅者的子会话。 + // 管理员回复到该订阅者的子会话:同一个 inputReplyToMessage 同时携带真实 reply id + // 和 monoforum target,两部分都必须保留。 adminReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "admin reply", RandomID: 556} - adminReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}}) - if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq); err != nil { + adminReply := &tg.InputReplyToMessage{ReplyToMsgID: subMessageID} + adminReply.SetMonoforumPeerID(&tg.InputPeerUser{UserID: sub.ID}) + adminReq.SetReplyTo(adminReply) + adminReq.SetAllowPaidStars(100) + adminUpd, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq) + if err != nil { t.Fatalf("admin reply sendMessage(monoforum): %v", err) } + if updates, ok := adminUpd.(*tg.Updates); ok { + for _, update := range updates.Updates { + if _, balance := update.(*tg.UpdateStarsBalance); balance { + t.Fatalf("admin free reply emitted a balance debit: %#v", updates.Updates) + } + if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok { + if message, ok := newMessage.Message.(*tg.Message); ok { + if stars, paid := message.GetPaidMessageStars(); paid || stars != 0 { + t.Fatalf("admin reply paid_message_stars = %d/%v, want 0/false", stars, paid) + } + } + } + } + } + + // 带媒体的 suggested post 必须走同一 monoforum 子会话,不能退化为普通频道消息或丢 flags。 + mediaSuggested := tg.SuggestedPost{} + mediaSuggested.SetPrice(&tg.StarsAmount{Amount: 15}) + mediaReq := &tg.MessagesSendMediaRequest{ + Peer: monoInput, RandomID: 558, Message: "media suggestion", + Media: &tg.InputMediaContact{PhoneNumber: "+15550003003", FirstName: "Media", LastName: "Contact", Vcard: ""}, + } + mediaReq.SetSuggestedPost(mediaSuggested) + mediaReq.SetAllowPaidStars(15) + if _, err := r.onMessagesSendMedia(WithUserID(ctx, sub.ID), mediaReq); err != nil { + t.Fatalf("subscriber sendMedia(monoforum): %v", err) + } // 订阅者不能写他人(owner)的子会话。 sneaky := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "sneaky", RandomID: 557} @@ -237,7 +379,7 @@ func TestMonoforumSendMessageWritePath(t *testing.T) { t.Fatalf("subscriber writing another's sublist = nil err, want REPLY_TO_MONOFORUM_PEER_INVALID") } - // 经管理员读历史:子会话含两条(订阅者发 + 管理员回复),倒序。 + // 经管理员读历史:子会话含三条(订阅者文本 + 管理员回复 + 订阅者媒体),倒序。 hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}} hreq.SetParentPeer(monoInput) hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq) @@ -248,11 +390,54 @@ func TestMonoforumSendMessageWritePath(t *testing.T) { if !ok { t.Fatalf("getSavedHistory = %T, want *tg.MessagesMessagesSlice", hres) } - if len(slice.Messages) != 2 { - t.Fatalf("history = %d msgs, want 2 (sub + admin)", len(slice.Messages)) + if len(slice.Messages) != 3 { + t.Fatalf("history = %d msgs, want 3 (sub text + admin + sub media)", len(slice.Messages)) } top, ok := slice.Messages[0].(*tg.Message) - if !ok || top.Message != "admin reply" { - t.Fatalf("history[0] = %#v, want newest 'admin reply'", slice.Messages[0]) + if !ok || top.Message != "media suggestion" { + t.Fatalf("history[0] = %#v, want newest media suggestion", slice.Messages[0]) + } + if _, ok := top.Media.(*tg.MessageMediaContact); !ok { + t.Fatalf("history[0] media = %T, want MessageMediaContact", top.Media) + } + if paid, ok := top.GetPaidMessageStars(); !ok || paid != 10 { + t.Fatalf("media paid_message_stars = %d/%v, want actual configured price 10", paid, ok) + } + topSuggested, ok := top.GetSuggestedPost() + if !ok { + t.Fatalf("media message missing suggested_post") + } + topPrice, ok := topSuggested.GetPrice() + if !ok { + t.Fatalf("media suggested_post missing price") + } + if stars, ok := topPrice.(*tg.StarsAmount); !ok || stars.Amount != 15 { + t.Fatalf("media suggested_post price = %#v, want 15 Stars", topPrice) + } + adminMessage, ok := slice.Messages[1].(*tg.Message) + if !ok || adminMessage.Message != "admin reply" { + t.Fatalf("history[1] = %#v, want admin reply", slice.Messages[1]) + } + if header, ok := adminMessage.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != subMessageID { + t.Fatalf("admin reply header = %#v, want reply_to_msg_id %d", adminMessage.ReplyTo, subMessageID) + } + suggestedMessage, ok := slice.Messages[2].(*tg.Message) + if !ok { + t.Fatalf("history[2] = %T, want *tg.Message", slice.Messages[2]) + } + suggested, ok := suggestedMessage.GetSuggestedPost() + if !ok { + t.Fatalf("subscriber message missing suggested_post") + } + price, ok := suggested.GetPrice() + if !ok { + t.Fatalf("suggested_post missing price") + } + stars, ok := price.(*tg.StarsAmount) + if !ok || stars.Amount != 10 { + t.Fatalf("suggested_post price = %#v, want 10 Stars", price) + } + if scheduleDate, ok := suggested.GetScheduleDate(); !ok || scheduleDate != 1_700_100_000 { + t.Fatalf("suggested_post schedule = %d/%v, want 1700100000/true", scheduleDate, ok) } } diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index d247c2b1..a81715a7 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -3,10 +3,12 @@ package rpc import ( "context" "errors" - "github.com/iamxvbaba/td/tg" "strings" - "telesrv/internal/domain" "unicode/utf8" + + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" ) func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) { @@ -63,12 +65,39 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend sendErr = internalErr() return nil, sendErr } - // 频道私信(monoforum):仅当 reply_to 带 monoforum_peer_id 时走专用发送路径(普通发送恒不带, - // 故此 gate 对普通发送零额外成本)。peer 解析为 monoforum 频道时按订阅者子会话发送。 - if monoforumReplyPresent(req.ReplyTo) { - savedPeer, valid := r.monoforumReplyTargetPeer(userID, req.ReplyTo) - if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 { - sendErr = replyToMonoforumPeerInvalidErr() + suggestedInput, hasSuggestedPost := req.GetSuggestedPost() + // monoforum 普通用户发送不带 reply_to,saved_peer 必须由服务端推导为自己;管理员回复才必须 + // 显式携带 monoforum_peer_id。仅凭 reply_to 判路由会把用户请求误送进普通 megagroup 路径。 + var mono domain.Channel + var monoforum, monoforumAdmin bool + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID) + switch { + case err == nil: + monoforum = true + case !errors.Is(err, domain.ErrChannelInvalid): + sendErr = internalErr() + return nil, sendErr + } + } + if hasSuggestedPost && !monoforum { + sendErr = suggestedPostPeerInvalidErr() + return nil, sendErr + } + if monoforum { + suggestedPost, suggestedErr := domainSuggestedPost(suggestedInput, hasSuggestedPost) + if suggestedErr != nil { + sendErr = suggestedErr + return nil, sendErr + } + savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo) + if err != nil { + sendErr = err + return nil, sendErr + } + replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, peer, req.ReplyTo) + if err != nil { + sendErr = err return nil, sendErr } replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint) @@ -78,6 +107,9 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend } if replay.found { duplicate = true + if req.ClearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil } if err := r.checkSendRateLimit(ctx, userID, 1); err != nil { @@ -89,13 +121,30 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend sendErr = err return nil, sendErr } - updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, req, idempotencyFingerprint, replay.checked) + updates, err := r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{ + SavedPeer: savedPeer, + RandomID: req.RandomID, + IdempotencyFingerprint: idempotencyFingerprint, + IdempotencyPreflighted: replay.checked, + Message: req.Message, + Entities: domainMessageEntities(req.Entities), + ReplyTo: replyTo, + Silent: req.Silent, + NoForwards: req.Noforwards, + SuggestedPost: suggestedPost, + AllowPaidStars: req.AllowPaidStars, + ClearDraft: req.ClearDraft, + }) if err != nil { sendErr = err return nil, sendErr } return updates, nil } + if req.AllowPaidStars > 0 { + sendErr = paymentUnsupportedErr() + return nil, sendErr + } replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint) if err != nil { sendErr = err @@ -201,7 +250,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend } func messageSendErr(err error) error { + var paymentRequired *domain.StarsPaymentRequiredError switch { + case errors.As(err, &paymentRequired) && paymentRequired.Stars > 0: + return allowPaymentRequiredErr(paymentRequired.Stars) + case errors.Is(err, domain.ErrStarsInsufficient): + return balanceTooLowErr() case errors.Is(err, domain.ErrUserFrozen): return frozenMethodInvalidErr() case errors.Is(err, domain.ErrReplyMessageIDInvalid): @@ -314,10 +368,8 @@ func sendMessageUnsupportedOptionErr(req *tg.MessagesSendMessageRequest) error { // req.Effect 不再一律拒绝:消息特效已实现,合法性在 messageEffectInvalid 单独校验。 case req.AllowPaidStars < 0: return starsAmountInvalidErr() - case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: + case req.AllowPaidFloodskip: return paymentUnsupportedErr() - case !req.SuggestedPost.Zero(): - return suggestedPostPeerInvalidErr() default: return nil } diff --git a/internal/rpc/messages_suggested_post.go b/internal/rpc/messages_suggested_post.go new file mode 100644 index 00000000..8cb59754 --- /dev/null +++ b/internal/rpc/messages_suggested_post.go @@ -0,0 +1,73 @@ +package rpc + +import ( + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" +) + +const ( + minSuggestedPostStars int64 = 5 + maxSuggestedPostStars int64 = 100_000 + minSuggestedPostNanoTON int64 = 10_000_000 + maxSuggestedPostNanoTON int64 = 10_000_000_000_000 +) + +func domainSuggestedPost(input tg.SuggestedPost, present bool) (*domain.SuggestedPost, error) { + if !present { + return nil, nil + } + if input.GetAccepted() || input.GetRejected() { + return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID") + } + out := &domain.SuggestedPost{} + if date, ok := input.GetScheduleDate(); ok { + if date <= 0 { + return nil, scheduleDateInvalidErr() + } + out.ScheduleDate = date + } + if price, ok := input.GetPrice(); ok { + switch value := price.(type) { + case *tg.StarsAmount: + if value == nil || value.Amount < minSuggestedPostStars || value.Amount > maxSuggestedPostStars || + value.Nanos < 0 || value.Nanos >= 1_000_000_000 || value.Amount == maxSuggestedPostStars && value.Nanos != 0 { + return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID") + } + out.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: value.Amount, Nanos: value.Nanos} + case *tg.StarsTonAmount: + if value == nil || value.Amount < minSuggestedPostNanoTON || value.Amount > maxSuggestedPostNanoTON { + return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID") + } + out.Price = &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceTON, Amount: value.Amount} + default: + return nil, tgerr400("SUGGESTED_POST_AMOUNT_INVALID") + } + } + return out, nil +} + +func tgSuggestedPost(input *domain.SuggestedPost) (tg.SuggestedPost, bool) { + if input == nil { + return tg.SuggestedPost{}, false + } + out := tg.SuggestedPost{} + if input.Accepted { + out.SetAccepted(true) + } + if input.Rejected { + out.SetRejected(true) + } + if input.ScheduleDate > 0 { + out.SetScheduleDate(input.ScheduleDate) + } + if input.Price != nil { + switch input.Price.Kind { + case domain.SuggestedPostPriceStars: + out.SetPrice(&tg.StarsAmount{Amount: input.Price.Amount, Nanos: input.Price.Nanos}) + case domain.SuggestedPostPriceTON: + out.SetPrice(&tg.StarsTonAmount{Amount: input.Price.Amount}) + } + } + return out, true +} diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go index 51895422..3bef3978 100644 --- a/internal/rpc/payments.go +++ b/internal/rpc/payments.go @@ -453,6 +453,8 @@ func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction { switch t.Reason { case domain.StarsReasonReaction: item.Reaction = true + case domain.StarsReasonPaidMessage: + item.SetPaidMessages(1) case domain.StarsReasonGift: item.Gift = true case domain.StarsReasonGiftUpgrade: diff --git a/internal/rpc/payments_stars_rpc_test.go b/internal/rpc/payments_stars_rpc_test.go index 6493a39b..76702ca1 100644 --- a/internal/rpc/payments_stars_rpc_test.go +++ b/internal/rpc/payments_stars_rpc_test.go @@ -85,6 +85,22 @@ func TestOnPaymentsGetStarsTransactions(t *testing.T) { } } +func TestTGStarsTransactionsPaidMessage(t *testing.T) { + out := tgStarsTransactions([]domain.StarsTransaction{{ + ID: 1, UserID: 42, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 50}, + Amount: -10, Date: 1700002002, Reason: domain.StarsReasonPaidMessage, Title: "Paid message", + }}) + if len(out) != 1 { + t.Fatalf("paid-message transactions = %d, want 1", len(out)) + } + if paid, ok := out[0].GetPaidMessages(); !ok || paid != 1 { + t.Fatalf("paid_messages = %d/%v, want 1/true", paid, ok) + } + if amount, ok := out[0].Amount.(*tg.StarsAmount); !ok || amount.Amount != -10 { + t.Fatalf("paid-message amount = %#v, want -10", out[0].Amount) + } +} + // deps.Stars==nil 兜底:返回合法的空 starsStatus(余额 0),不崩。 func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) { r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index 5912bf8b..bd69d3a5 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -274,6 +274,89 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe if !ok || peer.ID == 0 { return nil, peerIDInvalidErr() } + suggestedInput, hasSuggestedPost := req.GetSuggestedPost() + var mono domain.Channel + var monoforum, monoforumAdmin bool + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + mono, monoforumAdmin, err = r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID) + switch { + case err == nil: + monoforum = true + case !errors.Is(err, domain.ErrChannelInvalid): + return nil, internalErr() + } + } + if hasSuggestedPost && !monoforum { + return nil, suggestedPostPeerInvalidErr() + } + if monoforum { + if req.AllowPaidStars < 0 { + return nil, starsAmountInvalidErr() + } + if req.AllowPaidFloodskip { + return nil, paymentUnsupportedErr() + } + if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { + return nil, scheduleDateInvalidErr() + } + suggestedPost, err := domainSuggestedPost(suggestedInput, hasSuggestedPost) + if err != nil { + return nil, err + } + savedPeer, err := r.monoforumSavedPeerForSender(userID, monoforumAdmin, req.ReplyTo) + if err != nil { + return nil, err + } + replyTo, err := r.monoforumMessageReplyFromInput(ctx, userID, peer, req.ReplyTo) + if err != nil { + return nil, err + } + replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint) + if err != nil { + return nil, err + } + if replay.found { + if req.ClearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } + return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil + } + if r.messageEffectInvalid(ctx, req.Effect) { + return nil, effectIDInvalidErr() + } + if err := r.checkSendRateLimit(ctx, userID, 1); err != nil { + return nil, err + } + checkedPeer, 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() + } + return r.sendMonoforumMessage(ctx, userID, checkedPeer, mono, monoforumAdmin, domain.SendMonoforumMessageRequest{ + SavedPeer: savedPeer, + RandomID: req.RandomID, + IdempotencyFingerprint: idempotencyFingerprint, + IdempotencyPreflighted: replay.checked, + Message: req.Message, + Entities: domainMessageEntities(req.Entities), + Media: media, + ReplyTo: replyTo, + Silent: req.Silent, + NoForwards: req.Noforwards, + SuggestedPost: suggestedPost, + AllowPaidStars: req.AllowPaidStars, + ClearDraft: req.ClearDraft, + }) + } + if req.AllowPaidStars > 0 || req.AllowPaidFloodskip { + return nil, paymentUnsupportedErr() + } replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint) if err != nil { return nil, err diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go index 515df5c9..bda20e6c 100644 --- a/internal/store/memory/channel_helpers.go +++ b/internal/store/memory/channel_helpers.go @@ -290,6 +290,12 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, af } out = append(out, channelID) } + for channelID, channel := range s.channels { + if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) || containsInt64(out, channelID) { + 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] @@ -324,6 +330,24 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}) } } + for channelID, channel := range s.channels { + if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) { + continue + } + checkpoint := s.channelUpdateCheckpointLocked(channelID, channel) + if checkpoint.LatestEventDate > sinceDate { + found := false + for _, item := range out { + if item.ChannelID == channelID { + found = true + break + } + } + if !found { + out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}) + } + } + } sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID }) if len(out) > limit { out = out[:limit] @@ -331,6 +355,34 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID return out, nil } +func (s *ChannelStore) monoforumVisibleToUserLocked(mono domain.Channel, userID int64) bool { + if userID == 0 || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 { + return false + } + parent, ok := s.channels[mono.LinkedMonoforumID] + if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != mono.ID { + return false + } + if member, ok := s.members[parent.ID][userID]; ok && member.Status == domain.ChannelMemberActive && isChannelAdmin(member) { + return true + } + for _, msg := range s.messages[mono.ID] { + if !msg.Deleted && msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return true + } + } + return false +} + +func containsInt64(items []int64, target int64) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} + func (s *ChannelStore) nextChannelIDLocked() int64 { id := s.nextID s.nextID++ @@ -369,6 +421,10 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) { return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil } + parent, ok := s.channels[channel.LinkedMonoforumID] + if ok && !parent.Deleted && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID { + return channel, syntheticMonoforumUserMember(channel, userID), true, nil + } } if !publicPreviewableChannel(channel) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate diff --git a/internal/store/memory/channel_invites.go b/internal/store/memory/channel_invites.go index f55d542b..7f4fd218 100644 --- a/internal/store/memory/channel_invites.go +++ b/internal/store/memory/channel_invites.go @@ -17,6 +17,9 @@ func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUser if err != nil { return domain.CreateChannelResult{}, err } + if channel.Monoforum { + return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported + } inviter := s.members[channelID][inviterUserID] if !canInviteToChannel(channel, inviter) { return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired diff --git a/internal/store/memory/channel_members.go b/internal/store/memory/channel_members.go index f0d4cf48..94f6c491 100644 --- a/internal/store/memory/channel_members.go +++ b/internal/store/memory/channel_members.go @@ -109,6 +109,9 @@ func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, d if !ok || channel.Deleted { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } + if channel.Monoforum { + return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported + } preJoinTopID := channel.TopMessageID if existing, ok := s.members[channelID][userID]; ok { if existing.Status == domain.ChannelMemberActive { @@ -1050,6 +1053,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan return member } +func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember { + return domain.ChannelMember{ + ChannelID: mono.ID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + } +} + func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) { if !publicSearchableChannel(channel) { return 0, false diff --git a/internal/store/memory/channel_message_helpers.go b/internal/store/memory/channel_message_helpers.go index 2b9fb3a8..d42ea089 100644 --- a/internal/store/memory/channel_message_helpers.go +++ b/internal/store/memory/channel_message_helpers.go @@ -34,6 +34,14 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage { in.Discussion = cloneChannelDiscussionRef(in.Discussion) in.Replies = cloneChannelMessageReplies(in.Replies) in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) + if in.SuggestedPost != nil { + suggested := *in.SuggestedPost + if suggested.Price != nil { + price := *suggested.Price + suggested.Price = &price + } + in.SuggestedPost = &suggested + } if in.SendAs != nil { p := *in.SendAs in.SendAs = &p diff --git a/internal/store/memory/channel_message_history.go b/internal/store/memory/channel_message_history.go index 824033dd..b9134681 100644 --- a/internal/store/memory/channel_message_history.go +++ b/internal/store/memory/channel_message_history.go @@ -24,12 +24,18 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64, // 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。 query := strings.ToLower(strings.TrimSpace(filter.Query)) matched := make([]domain.ChannelMessage, 0, len(items)) + monoforumUserView := channel.Monoforum && !isChannelAdmin(member) for _, msg := range items { if msg.Deleted { continue } - if channel.Monoforum && msg.SavedPeer.ID != 0 { - continue + if channel.Monoforum { + if monoforumUserView && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) { + continue + } + if !monoforumUserView && msg.SavedPeer.ID != 0 { + continue + } } if msg.ID <= member.AvailableMinID { continue diff --git a/internal/store/memory/channel_message_send.go b/internal/store/memory/channel_message_send.go index b5ebcd7c..e8138569 100644 --- a/internal/store/memory/channel_message_send.go +++ b/internal/store/memory/channel_message_send.go @@ -316,13 +316,21 @@ func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendRepla Message: cloneChannelMessage(replay), SenderUserID: first.SenderUserID, } - return domain.SendChannelMessageResult{ + result := domain.SendChannelMessageResult{ Channel: cloneChannel(channel), Message: cloneChannelMessage(replay), Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete, - }, true, nil + } + if first.PaidMessageStars > 0 { + balance, ok := s.starsBalances[first.SenderUserID] + if !ok { + return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory paid-message replay has no sender balance") + } + result.SenderStarsBalance = &domain.StarsBalance{UserID: first.SenderUserID, Balance: balance, Granted: true} + } + return result, true, nil } func channelDeliverySkipSet(ids []int64) map[int64]struct{} { diff --git a/internal/store/memory/channel_monoforum.go b/internal/store/memory/channel_monoforum.go index 077f8190..8634f7dc 100644 --- a/internal/store/memory/channel_monoforum.go +++ b/internal/store/memory/channel_monoforum.go @@ -10,13 +10,19 @@ import ( "telesrv/internal/store" ) +const paidMessageChannelCommissionPermille int64 = 850 + // SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 -// 与 postgres 行为一致:复用 channel pts/事件;只校验 monoforum 存在,不要求发件人是成员。 +// 与 postgres 行为一致:复用 channel pts/事件;订阅者无需成员记录且只能写自己的 saved_peer, +// 母频道管理员可以回复任意订阅者。 func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || - req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" { + req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + if req.AllowPaidStars < 0 { + return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount + } var fingerprint []byte var err error if req.RandomID != 0 { @@ -43,23 +49,81 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo if !ok || channel.Deleted || !channel.Monoforum { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + parent, ok := s.channels[channel.LinkedMonoforumID] + if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != channel.ID { + return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate + } + parentMember, parentMemberOK := s.members[parent.ID][req.SenderUserID] + isAdmin := parentMemberOK && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) + if req.SenderUserID != req.SavedPeer.ID && !isAdmin { + return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired + } + if req.ReplyTo != nil { + if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) { + return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid + } + found := false + for _, candidate := range s.messages[channel.ID] { + if candidate.ID == req.ReplyTo.MessageID && !candidate.Deleted && candidate.SavedPeer == req.SavedPeer { + found = true + break + } + } + if !found { + return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid + } + } + var senderBalance *domain.StarsBalance + paidMessageStars := int64(0) + balanceAfter := int64(0) + if !isAdmin && channel.SendPaidMessagesStars > 0 { + if channel.SendPaidMessagesStars != parent.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.AllowPaidStars < channel.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars} + } + current, ok := s.starsBalances[req.SenderUserID] + if !ok { + current = domain.DefaultStarsStartingGrant + } + if current < channel.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient + } + paidMessageStars = channel.SendPaidMessagesStars + balanceAfter = current - paidMessageStars + senderBalance = &domain.StarsBalance{UserID: req.SenderUserID, Balance: balanceAfter, Granted: true} + } + from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + if isAdmin { + from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID} + } if req.Date == 0 { req.Date = int(time.Now().Unix()) } pts := s.nextChannelPtsLocked(req.MonoforumID) msgID := s.nextChannelMessageIDLocked(req.MonoforumID) msg := domain.ChannelMessage{ - ChannelID: req.MonoforumID, - ID: msgID, - RandomID: req.RandomID, - SenderUserID: req.SenderUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, - SavedPeer: req.SavedPeer, - Date: req.Date, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Pts: pts, + ChannelID: req.MonoforumID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.SenderUserID, + From: from, + SavedPeer: req.SavedPeer, + SuggestedPost: req.SuggestedPost, + PaidMessageStars: paidMessageStars, + Date: req.Date, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ReplyTo: req.ReplyTo, + Pts: pts, } + // Store owns the persisted snapshot; callers must not be able to mutate it through + // SuggestedPost/Media pointers after SendMonoforumMessage returns. + msg = cloneChannelMessage(msg) var sendSnapshot []byte if req.RandomID != 0 { var snapshotErr error @@ -78,6 +142,10 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo SenderUserID: req.SenderUserID, } s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg) + if paidMessageStars > 0 { + s.starsBalances[req.SenderUserID] = balanceAfter + s.channelStarsBalances[parent.ID] += paidMessageStars * paidMessageChannelCommissionPermille / 1000 + } if req.RandomID != 0 { replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID} s.sendSnapshots[replayKey] = sendSnapshot @@ -87,7 +155,13 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo channel.TopMessageID = msgID channel.Pts = pts s.channels[req.MonoforumID] = channel - return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event)}, nil + recipients := []int64{req.SavedPeer.ID} + for userID, member := range s.members[parent.ID] { + if member.Status == domain.ChannelMemberActive && isChannelAdmin(member) { + recipients = append(recipients, userID) + } + } + return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0), SenderStarsBalance: senderBalance}, nil } // findMonoforumDuplicateLocked 按 (sender, saved_peer, random_id) 查 monoforum 子会话内的重发消息。 diff --git a/internal/store/memory/channel_monoforum_send_test.go b/internal/store/memory/channel_monoforum_send_test.go index 9652db64..370883f6 100644 --- a/internal/store/memory/channel_monoforum_send_test.go +++ b/internal/store/memory/channel_monoforum_send_test.go @@ -35,11 +35,14 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if m1.Message.SavedPeer != sub || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 { t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message) } + if !containsInt64(m1.Recipients, 1) || !containsInt64(m1.Recipients, 42) || len(m1.Recipients) != 2 { + t.Fatalf("m1 recipients = %v, want subscriber 42 + parent admin 1", m1.Recipients) + } if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 112, Message: "again", Date: 1_700_001_002}); err != nil { t.Fatalf("subscriber send 2: %v", err) } // 管理员回复:发件人是 creator,saved_peer 仍是该订阅者(同一子会话)。 - if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", Date: 1_700_001_003}); err != nil { + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_003}); err != nil { t.Fatalf("admin reply: %v", err) } @@ -58,8 +61,17 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID { t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID) } - if _, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil { - t.Fatalf("subscriber main monoforum history = nil err, want denied") + subscriberHist, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("subscriber monoforum history: %v", err) + } + if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 { + t.Fatalf("subscriber monoforum history count=%d len=%d, want 3 own messages", subscriberHist.Count, len(subscriberHist.Messages)) + } + for _, message := range subscriberHist.Messages { + if message.SavedPeer != sub { + t.Fatalf("subscriber history leaked saved_peer=%+v, want self %+v", message.SavedPeer, sub) + } } // 幂等:相同 randomID 返回原消息、不重复。 @@ -84,6 +96,12 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if hist.Messages[0].Body != "reply" { t.Fatalf("history[0] = %q, want newest 'reply'", hist.Messages[0].Body) } + if hist.Messages[0].ReplyTo == nil || hist.Messages[0].ReplyTo.MessageID != m1.Message.ID { + t.Fatalf("history[0] reply = %+v, want message %d", hist.Messages[0].ReplyTo, m1.Message.ID) + } + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 114, Message: "cross reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err) + } for _, m := range hist.Messages { if m.SavedPeer != sub { t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer) @@ -99,6 +117,40 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if subHist.Count != 3 { t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count) } + subscriberChannelHistory, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("subscriber channel history after other subscriber: %v", err) + } + if subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 { + t.Fatalf("subscriber channel history after other = %d/%d, want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages)) + } + for _, message := range subscriberChannelHistory.Messages { + if message.SavedPeer != sub { + t.Fatalf("subscriber channel history leaked message %+v", message) + } + } + diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: 0, Limit: 100}) + if err != nil { + t.Fatalf("subscriber channel difference: %v", err) + } + if diff.Pts != store.channels[monoID].Pts { + t.Fatalf("subscriber difference pts = %d, want channel pts %d despite filtered events", diff.Pts, store.channels[monoID].Pts) + } + if len(diff.NewMessages) != 3 { + t.Fatalf("subscriber difference messages = %d, want own 3", len(diff.NewMessages)) + } + for _, message := range diff.NewMessages { + if message.SavedPeer != sub { + t.Fatalf("subscriber difference leaked message %+v", message) + } + } + activeChannelIDs, err := store.ListActiveChannelIDsForUser(ctx, 42, 0, 10) + if err != nil { + t.Fatalf("subscriber active channels: %v", err) + } + if !containsInt64(activeChannelIDs, monoID) { + t.Fatalf("subscriber active channels = %v, want monoforum %d for offline recovery", activeChannelIDs, monoID) + } // 去重按订阅者子会话维度:同一发件人(此处管理员)用相同 random_id 向两个不同订阅者发,不得互相去重。 a, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_010}) @@ -141,6 +193,13 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if err != nil { t.Fatalf("delete monoforum message: %v", err) } + deleteDiff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10}) + if err != nil { + t.Fatalf("subscriber difference after own delete: %v", err) + } + if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID { + t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts) + } ptsBeforeReplay, eventsBeforeReplay := store.ptsSeq[monoID], len(store.events[monoID]) deletedReplay, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_014}) if err != nil { @@ -153,3 +212,73 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", store.ptsSeq[monoID], len(store.events[monoID]), ptsBeforeReplay, eventsBeforeReplay) } } + +func TestSendPaidMonoforumMessageLedger(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "Paid DM", Broadcast: true, Date: 1_700_002_000}) + if err != nil { + t.Fatalf("create: %v", err) + } + enabled, err := store.SetPaidMessagesPrice(ctx, 1, broadcast.Channel.ID, 10, true) + if err != nil { + t.Fatalf("enable paid DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42} + baseMessages := len(store.messages[monoID]) + + low := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3001, Message: "too low", AllowPaidStars: 9, Date: 1_700_002_001} + var required *domain.StarsPaymentRequiredError + if _, err := store.SendMonoforumMessage(ctx, low); !errors.As(err, &required) || required.Stars != 10 { + t.Fatalf("low authorization err = %v, want 10-Star payment required", err) + } + if len(store.messages[monoID]) != baseMessages { + t.Fatalf("low authorization wrote a message") + } + + store.starsBalances[42] = 25 + paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3002, Message: "paid", AllowPaidStars: 99, Date: 1_700_002_002} + paid, err := store.SendMonoforumMessage(ctx, paidReq) + if err != nil { + t.Fatalf("paid send: %v", err) + } + if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 { + t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance) + } + if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 { + t.Fatalf("ledger sender/channel = %d/%d, want 15/8", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID]) + } + + duplicate, err := store.SendMonoforumMessage(ctx, paidReq) + if err != nil { + t.Fatalf("paid replay: %v", err) + } + if !duplicate.Duplicate || duplicate.Message.ID != paid.Message.ID || duplicate.SenderStarsBalance == nil || duplicate.SenderStarsBalance.Balance != 15 { + t.Fatalf("paid replay = %+v, want original message and balance 15", duplicate) + } + if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 { + t.Fatalf("paid replay double charged: sender/channel=%d/%d", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID]) + } + + admin, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 3003, Message: "free admin reply", AllowPaidStars: 100, Date: 1_700_002_003, + }) + if err != nil { + t.Fatalf("admin reply: %v", err) + } + if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil || store.channelStarsBalances[broadcast.Channel.ID] != 8 { + t.Fatalf("admin reply charged: message=%+v balance=%+v channel=%d", admin.Message, admin.SenderStarsBalance, store.channelStarsBalances[broadcast.Channel.ID]) + } + + store.starsBalances[99] = 5 + other := domain.Peer{Type: domain.PeerTypeUser, ID: 99} + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 3004, Message: "insufficient", AllowPaidStars: 10, Date: 1_700_002_004, + }); !errors.Is(err, domain.ErrStarsInsufficient) { + t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err) + } + if store.starsBalances[99] != 5 || store.channelStarsBalances[broadcast.Channel.ID] != 8 { + t.Fatalf("insufficient send mutated ledger: sender/channel=%d/%d", store.starsBalances[99], store.channelStarsBalances[broadcast.Channel.ID]) + } +} diff --git a/internal/store/memory/channel_store.go b/internal/store/memory/channel_store.go index fac32d7a..abcc0033 100644 --- a/internal/store/memory/channel_store.go +++ b/internal/store/memory/channel_store.go @@ -73,27 +73,29 @@ type ChannelStore struct { messages map[int64][]domain.ChannelMessage reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction // paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。 - paidReactions map[int64]map[int]map[int64]memoryPaidReaction - 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]memoryMention - msgViews map[int64]map[int]int - msgViewers map[int64]map[int]map[int64]struct{} - events map[int64][]domain.ChannelUpdateEvent - retention map[int64]domain.ChannelUpdateRetentionCheckpoint - 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 - sendSnapshots map[channelMessageReplayKey][]byte - sendFingerprints map[channelMessageReplayKey][]byte - deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent - boostSlots map[boostSlotKey]domain.PremiumBoostSlot - readMarks map[int64]channelReadWatermark + paidReactions map[int64]map[int]map[int64]memoryPaidReaction + 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]memoryMention + msgViews map[int64]map[int]int + msgViewers map[int64]map[int]map[int64]struct{} + events map[int64][]domain.ChannelUpdateEvent + retention map[int64]domain.ChannelUpdateRetentionCheckpoint + 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 + sendSnapshots map[channelMessageReplayKey][]byte + sendFingerprints map[channelMessageReplayKey][]byte + deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent + starsBalances map[int64]int64 + channelStarsBalances map[int64]int64 + boostSlots map[boostSlotKey]domain.PremiumBoostSlot + readMarks map[int64]channelReadWatermark // topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。 topicReads map[int64]map[int64]map[int]memoryTopicRead // polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。 @@ -108,35 +110,37 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) { // 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), - paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction), - 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]memoryMention), - msgViews: make(map[int64]map[int]int), - msgViewers: make(map[int64]map[int]map[int64]struct{}), - events: make(map[int64][]domain.ChannelUpdateEvent), - retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint), - 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), - sendSnapshots: make(map[channelMessageReplayKey][]byte), - sendFingerprints: make(map[channelMessageReplayKey][]byte), - deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent), - boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot), - readMarks: make(map[int64]channelReadWatermark), - topicReads: make(map[int64]map[int64]map[int]memoryTopicRead), + 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), + paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction), + 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]memoryMention), + msgViews: make(map[int64]map[int]int), + msgViewers: make(map[int64]map[int]map[int64]struct{}), + events: make(map[int64][]domain.ChannelUpdateEvent), + retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint), + 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), + sendSnapshots: make(map[channelMessageReplayKey][]byte), + sendFingerprints: make(map[channelMessageReplayKey][]byte), + deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent), + starsBalances: make(map[int64]int64), + channelStarsBalances: make(map[int64]int64), + boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot), + readMarks: make(map[int64]channelReadWatermark), + topicReads: make(map[int64]map[int64]map[int]memoryTopicRead), } } diff --git a/internal/store/memory/channel_updates.go b/internal/store/memory/channel_updates.go index 3776ac7c..8f626a96 100644 --- a/internal/store/memory/channel_updates.go +++ b/internal/store/memory/channel_updates.go @@ -49,6 +49,9 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann if msg.Deleted { continue } + if channel.Monoforum && !isChannelAdmin(member) && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) { + continue + } if msg.ID <= member.AvailableMinID { continue } @@ -68,6 +71,16 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann } events := make([]domain.ChannelUpdateEvent, 0, limit) lastPts := req.Pts + var visibleMonoforumMessageIDs map[int]struct{} + if channel.Monoforum && !isChannelAdmin(member) { + visibleMonoforumMessageIDs = make(map[int]struct{}) + savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} + for _, message := range s.messages[req.ChannelID] { + if message.SavedPeer == savedPeer { + visibleMonoforumMessageIDs[message.ID] = struct{}{} + } + } + } for _, event := range s.events[req.ChannelID] { if event.Pts <= req.Pts { continue @@ -77,6 +90,12 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann if !ok { continue } + if channel.Monoforum && !isChannelAdmin(member) { + visible, ok = filterMonoforumEventForUser(visible, req.UserID, visibleMonoforumMessageIDs) + if !ok { + continue + } + } if preview && visible.Type == domain.ChannelUpdateParticipant { continue } @@ -121,6 +140,27 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann return diff, nil } +func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) { + savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + if event.Message.ID != 0 { + return event, event.Message.SavedPeer == savedPeer + } + if len(event.MessageIDs) == 0 { + return event, false + } + visibleIDs := make([]int, 0, len(event.MessageIDs)) + for _, id := range event.MessageIDs { + if _, ok := visibleMessageIDs[id]; ok { + visibleIDs = append(visibleIDs, id) + } + } + if len(visibleIDs) == 0 { + return event, false + } + event.MessageIDs = visibleIDs + return event, true +} + func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/store/memory/dialogs.go b/internal/store/memory/dialogs.go index 4f68b1fd..66054389 100644 --- a/internal/store/memory/dialogs.go +++ b/internal/store/memory/dialogs.go @@ -868,6 +868,14 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft { draft.WebPage = &webpage } draft.RichMessage = cloneRichMessage(draft.RichMessage) + if draft.SuggestedPost != nil { + suggested := *draft.SuggestedPost + if suggested.Price != nil { + price := *suggested.Price + suggested.Price = &price + } + draft.SuggestedPost = &suggested + } return draft } diff --git a/internal/store/memory/star_gift.go b/internal/store/memory/star_gift.go index 958fd34f..2a6733f5 100644 --- a/internal/store/memory/star_gift.go +++ b/internal/store/memory/star_gift.go @@ -325,6 +325,25 @@ func (s *StarGiftStore) UniqueByIDs(_ context.Context, uniqueGiftIDs []int64) (m return out, nil } +func (s *StarGiftStore) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) { + if owner.ID <= 0 || limit <= 0 { + return []domain.UniqueStarGift{}, nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.UniqueStarGift, 0, min(limit, len(s.uniqueByID))) + for _, gift := range s.uniqueByID { + if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" { + out = append(out, gift) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) { if !validSavedStarGift(gift) { return 0, domain.ErrStarGiftInvalid diff --git a/internal/store/memory/users.go b/internal/store/memory/users.go index 049f64fa..91954348 100644 --- a/internal/store/memory/users.go +++ b/internal/store/memory/users.go @@ -306,19 +306,20 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int) return out, nil } -// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。 -func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentID int64, until int) (domain.User, error) { +// UpdateEmojiStatus 更新用户自定义 emoji status(零值表示清除)。 +func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[userID] if !ok || u.Deleted { return domain.User{}, domain.ErrUserNotFound } - if documentID == 0 { - until = 0 + if !status.Valid() { + return domain.User{}, domain.ErrStarGiftCollectibleInvalid } - u.EmojiStatusDocumentID = documentID - u.EmojiStatusUntil = until + u.EmojiStatusDocumentID = status.DocumentID + u.EmojiStatusUntil = status.Until + u.EmojiStatusCollectible = status.Collectible s.byID[userID] = u return u, nil } diff --git a/internal/store/postgres/account_lifecycle.go b/internal/store/postgres/account_lifecycle.go index df2a00b9..45373504 100644 --- a/internal/store/postgres/account_lifecycle.go +++ b/internal/store/postgres/account_lifecycle.go @@ -180,6 +180,7 @@ UPDATE users SET phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '', verified = false, support = false, last_seen_at = 0, premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0, + emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb, color_set = false, color = 0, color_background_emoji_id = 0, profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0, birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0, diff --git a/internal/store/postgres/channel_core.go b/internal/store/postgres/channel_core.go index c3a5363a..7c8aa5b2 100644 --- a/internal/store/postgres/channel_core.go +++ b/internal/store/postgres/channel_core.go @@ -377,6 +377,20 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids) if err != nil { return nil, err } + parentIDs := make([]int64, 0, len(channels)) + for _, channel := range channels { + if channel.Monoforum && channel.LinkedMonoforumID != 0 { + parentIDs = append(parentIDs, channel.LinkedMonoforumID) + } + } + parents, err := listChannelsByIDs(ctx, s.db, parentIDs) + if err != nil { + return nil, err + } + parentsByID := make(map[int64]domain.Channel, len(parents)) + for _, parent := range parents { + parentsByID[parent.ID] = parent + } linkedGuests, err := s.listLinkedDiscussionGuests(ctx, s.db, viewerUserID, remaining) if err != nil { return nil, err @@ -402,6 +416,17 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids) } continue } + if channel.Monoforum && channel.LinkedMonoforumID != 0 { + if parent, ok := parentsByID[channel.LinkedMonoforumID]; ok && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID { + member := syntheticMonoforumUserMember(channel, viewerUserID) + views[channel.ID] = domain.ChannelView{ + Channel: channel, + Self: member, + Dialog: previewChannelDialog(viewerUserID, channel, member), + } + continue + } + } if !publicPreviewableChannel(channel) { continue } diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go index 338add5b..c3650cab 100644 --- a/internal/store/postgres/channel_helpers.go +++ b/internal/store/postgres/channel_helpers.go @@ -298,12 +298,26 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID, limit = domain.MaxSynchronousChannelDialogFanout } rows, err := s.db.Query(ctx, ` -SELECT channel_id -FROM user_channel_member_index -WHERE user_id = $1 - AND status = 'active' - AND NOT deleted - AND channel_id > $2 +WITH visible_channels AS ( + SELECT channel_id + FROM user_channel_member_index + WHERE user_id = $1 AND status = 'active' AND NOT deleted + UNION + SELECT mono.id + FROM channels mono + JOIN channels parent ON parent.id = mono.linked_monoforum_id + AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id + WHERE mono.monoforum AND NOT mono.deleted + AND (EXISTS ( + SELECT 1 FROM channel_members admin + WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin') + ) OR EXISTS ( + SELECT 1 FROM channel_messages message + WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted + )) +) +SELECT channel_id FROM visible_channels +WHERE channel_id > $2 ORDER BY channel_id LIMIT $3`, userID, afterChannelID, limit) if err != nil { @@ -329,16 +343,31 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userI limit = domain.MaxChannelDifferenceLimit } rows, err := s.db.Query(ctx, ` -SELECT i.channel_id, c.pts -FROM user_channel_member_index i -JOIN channels c ON c.id = i.channel_id AND NOT c.deleted -JOIN channel_update_checkpoints cp ON cp.channel_id = i.channel_id -WHERE i.user_id = $1 - AND i.status = 'active' - AND NOT i.deleted - AND i.channel_id > $3 +WITH visible_channels AS ( + SELECT channel_id + FROM user_channel_member_index + WHERE user_id = $1 AND status = 'active' AND NOT deleted + UNION + SELECT mono.id + FROM channels mono + JOIN channels parent ON parent.id = mono.linked_monoforum_id + AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id + WHERE mono.monoforum AND NOT mono.deleted + AND (EXISTS ( + SELECT 1 FROM channel_members admin + WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin') + ) OR EXISTS ( + SELECT 1 FROM channel_messages message + WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted + )) +) +SELECT visible.channel_id, c.pts +FROM visible_channels visible +JOIN channels c ON c.id = visible.channel_id AND NOT c.deleted +JOIN channel_update_checkpoints cp ON cp.channel_id = visible.channel_id +WHERE visible.channel_id > $3 AND cp.latest_event_date > $2 -ORDER BY i.channel_id ASC +ORDER BY visible.channel_id ASC LIMIT $4`, userID, sinceDate, afterChannelID, limit) if err != nil { return nil, fmt.Errorf("list dirty active channels for user: %w", err) @@ -381,6 +410,15 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, } else if ok { return ch, member, true, nil } + if ch.Monoforum && ch.LinkedMonoforumID != 0 { + parent, parentErr := s.channelByID(ctx, db, ch.LinkedMonoforumID) + if parentErr != nil { + return domain.Channel{}, domain.ChannelMember{}, false, parentErr + } + if parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == ch.ID { + return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil + } + } if !publicPreviewableChannel(ch) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate } diff --git a/internal/store/postgres/channel_invite_members.go b/internal/store/postgres/channel_invite_members.go index 0ffb75b8..ee546aaf 100644 --- a/internal/store/postgres/channel_invite_members.go +++ b/internal/store/postgres/channel_invite_members.go @@ -29,6 +29,9 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs if err != nil { return domain.CreateChannelResult{}, err } + if channel.Monoforum { + return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported + } if !canInviteToChannel(channel, inviter) { return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired } diff --git a/internal/store/postgres/channel_member_helpers.go b/internal/store/postgres/channel_member_helpers.go index 1092e2fe..ff26a5bb 100644 --- a/internal/store/postgres/channel_member_helpers.go +++ b/internal/store/postgres/channel_member_helpers.go @@ -478,6 +478,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan return member } +func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember { + return domain.ChannelMember{ + ChannelID: mono.ID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + } +} + func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { return rights == domain.ChannelAdminRights{} } diff --git a/internal/store/postgres/channel_member_join.go b/internal/store/postgres/channel_member_join.go index 3676d0e6..a948248a 100644 --- a/internal/store/postgres/channel_member_join.go +++ b/internal/store/postgres/channel_member_join.go @@ -33,6 +33,9 @@ func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64, if err != nil { return domain.CreateChannelResult{}, err } + if channel.Monoforum { + return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported + } existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID) if existingErr == nil { switch { diff --git a/internal/store/postgres/channel_message_helpers.go b/internal/store/postgres/channel_message_helpers.go index 93452fd1..74679124 100644 --- a/internal/store/postgres/channel_message_helpers.go +++ b/internal/store/postgres/channel_message_helpers.go @@ -33,17 +33,19 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { var richMessageJSON string var savedPeerType string var savedPeerID int64 + var suggestedPostJSON 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, - &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, + &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON, ); err != nil { return domain.ChannelMessage{}, err } msg.From.Type = domain.PeerType(fromType) msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} + msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON) if sendAsType.Valid && sendAsID.Valid { msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} } @@ -90,17 +92,19 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er var richMessageJSON string var savedPeerType string var savedPeerID int64 + var suggestedPostJSON 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, - &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count, + &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON, &count, ); err != nil { return domain.ChannelMessage{}, 0, err } msg.From.Type = domain.PeerType(fromType) msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} + msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON) if sendAsType.Valid && sendAsID.Valid { msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} } diff --git a/internal/store/postgres/channel_message_history.go b/internal/store/postgres/channel_message_history.go index 99f8fd00..aaa011f7 100644 --- a/internal/store/postgres/channel_message_history.go +++ b/internal/store/postgres/channel_message_history.go @@ -25,7 +25,12 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6 base := "channel_id = $1 AND NOT deleted" extraChannels := []domain.Channel(nil) if channel.Monoforum { - base += " AND saved_peer_id = 0" + if isChannelAdmin(member) { + base += " AND saved_peer_id = 0" + } else { + baseArgs = append(baseArgs, viewerUserID) + base += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(baseArgs)) + } if channel.LinkedMonoforumID != 0 { if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil { extraChannels = append(extraChannels, parent) diff --git a/internal/store/postgres/channel_message_send.go b/internal/store/postgres/channel_message_send.go index 615cb541..05de824e 100644 --- a/internal/store/postgres/channel_message_send.go +++ b/internal/store/postgres/channel_message_send.go @@ -470,7 +470,16 @@ WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan( Message: replay, SenderUserID: first.SenderUserID, } - return domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}, nil + result := domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete} + if first.PaidMessageStars > 0 { + balance := domain.StarsBalance{UserID: first.SenderUserID} + if err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, first.SenderUserID). + Scan(&balance.Balance, &balance.Granted); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("load paid-message replay balance: %w", err) + } + result.SenderStarsBalance = &balance + } + return result, nil } func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) { @@ -578,6 +587,10 @@ func insertChannelMessageWithFingerprintTx(ctx context.Context, tx pgx.Tx, msg d if err != nil { return err } + suggestedPost, err := marshalJSON(msg.SuggestedPost, "{}") + if err != nil { + return err + } sendSnapshot := []byte("{}") if msg.RandomID != 0 { sendSnapshot, err = store.EncodeChannelSendSnapshot(msg) @@ -613,12 +626,12 @@ 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, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, send_snapshot, request_fingerprint -) 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,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38::jsonb,$39::bytea)`, + fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post, send_snapshot, request_fingerprint + ) 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,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39::jsonb,$40::jsonb,$41::bytea)`, 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, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, sendSnapshot, requestFingerprint); err != nil { + forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, msg.PaidMessageStars, suggestedPost, sendSnapshot, requestFingerprint); err != nil { return fmt.Errorf("insert channel message: %w", err) } // 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。 diff --git a/internal/store/postgres/channel_monoforum.go b/internal/store/postgres/channel_monoforum.go index 3e8fd0a9..ff728a92 100644 --- a/internal/store/postgres/channel_monoforum.go +++ b/internal/store/postgres/channel_monoforum.go @@ -12,14 +12,19 @@ import ( "telesrv/internal/store" ) +const paidMessageChannelCommissionPermille int64 = 850 + // SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 -// 私信消息存进 channel_messages(复用 channel pts/事件/difference);发件权限(订阅者身份/管理员) -// 由 RPC 层校验,store 只校验 monoforum 频道存在,不要求发件人是成员(订阅者不是 monoforum 成员)。 +// 私信消息存进 channel_messages(复用 channel pts/事件/difference);store 在写边界再次强制:订阅者 +// 无需成员记录但只能写自己的 saved_peer,母频道管理员可以回复任意订阅者。 func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || - req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" { + req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + if req.AllowPaidStars < 0 { + return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount + } requestFingerprint, err := store.MonoforumSendFingerprint(req) if err != nil { return domain.SendChannelMessageResult{}, err @@ -65,6 +70,101 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send if channel.Deleted || !channel.Monoforum { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + parent, err := getChannelByID(ctx, tx, channel.LinkedMonoforumID) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + var monoDeleted, parentDeleted, directEnabled bool + var linkedMonoforumID, monoPrice, parentPrice int64 + if err := tx.QueryRow(ctx, ` +SELECT m.deleted, p.deleted, p.broadcast_messages_allowed, p.linked_monoforum_id, + m.send_paid_messages_stars, p.send_paid_messages_stars +FROM channels m +JOIN channels p ON p.id = m.linked_monoforum_id +WHERE m.id = $1 +FOR SHARE OF m, p`, channel.ID).Scan( + &monoDeleted, &parentDeleted, &directEnabled, &linkedMonoforumID, &monoPrice, &parentPrice, + ); err != nil { + return domain.SendChannelMessageResult{}, err + } + if monoDeleted || parentDeleted || !directEnabled || linkedMonoforumID != channel.ID { + return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate + } + if monoPrice != parentPrice || monoPrice < 0 { + return domain.SendChannelMessageResult{}, fmt.Errorf("monoforum %d paid-message price disagrees with parent %d", channel.ID, parent.ID) + } + channel.SendPaidMessagesStars = monoPrice + parent.SendPaidMessagesStars = parentPrice + parentMember, parentMemberErr := s.getChannelMember(ctx, tx, parent.ID, req.SenderUserID) + if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) { + return domain.SendChannelMessageResult{}, parentMemberErr + } + isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) + if req.SenderUserID != req.SavedPeer.ID && !isAdmin { + return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired + } + var senderBalance *domain.StarsBalance + paidMessageStars := int64(0) + if !isAdmin && channel.SendPaidMessagesStars > 0 { + if req.AllowPaidStars < channel.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars} + } + balance := domain.StarsBalance{UserID: req.SenderUserID} + if err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, req.SenderUserID). + Scan(&balance.Balance, &balance.Granted); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient + } + return domain.SendChannelMessageResult{}, fmt.Errorf("lock paid-message sender balance: %w", err) + } + if balance.Balance < channel.SendPaidMessagesStars { + return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient + } + paidMessageStars = channel.SendPaidMessagesStars + if err := tx.QueryRow(ctx, ` +UPDATE stars_balances +SET balance = balance - $2, updated_at = now() +WHERE user_id = $1 +RETURNING balance`, req.SenderUserID, paidMessageStars).Scan(&balance.Balance); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("debit paid-message sender balance: %w", err) + } + if err := insertStarsTxn(ctx, tx, req.SenderUserID, -paidMessageStars, domain.StarsReasonPaidMessage, + domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, req.Date, "Paid message", ""); err != nil { + return domain.SendChannelMessageResult{}, err + } + channelCredit := paidMessageStars * paidMessageChannelCommissionPermille / 1000 + if channelCredit > 0 { + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_stars_balances(channel_id, balance) +VALUES($1, $2) +ON CONFLICT(channel_id) DO UPDATE +SET balance = channel_stars_balances.balance + EXCLUDED.balance, updated_at = now()`, parent.ID, channelCredit); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("credit paid-message channel balance: %w", err) + } + } + senderBalance = &balance + } + if req.ReplyTo != nil { + if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) { + return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid + } + var exists bool + if err := tx.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 FROM channel_messages + WHERE channel_id = $1 AND id = $2 AND NOT deleted + AND saved_peer_type = $3 AND saved_peer_id = $4 +)`, channel.ID, req.ReplyTo.MessageID, string(req.SavedPeer.Type), req.SavedPeer.ID).Scan(&exists); err != nil { + return domain.SendChannelMessageResult{}, err + } + if !exists { + return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid + } + } + from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + if isAdmin { + from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID} + } msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.MonoforumID) if err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err) @@ -74,16 +174,22 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err) } msg := domain.ChannelMessage{ - ChannelID: req.MonoforumID, - ID: msgID, - RandomID: req.RandomID, - SenderUserID: req.SenderUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, - SavedPeer: req.SavedPeer, - Date: req.Date, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Pts: pts, + ChannelID: req.MonoforumID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.SenderUserID, + From: from, + SavedPeer: req.SavedPeer, + SuggestedPost: req.SuggestedPost, + PaidMessageStars: paidMessageStars, + Date: req.Date, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ReplyTo: req.ReplyTo, + Pts: pts, } event := domain.ChannelUpdateEvent{ ChannelID: req.MonoforumID, @@ -130,13 +236,31 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.MonoforumID, msgID, pts); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err) } + recipients := []int64{req.SavedPeer.ID} + rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator', 'admin') ORDER BY user_id`, parent.ID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err) + } + for rows.Next() { + var recipient int64 + if err := rows.Scan(&recipient); err != nil { + rows.Close() + return domain.SendChannelMessageResult{}, err + } + recipients = append(recipients, recipient) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.SendChannelMessageResult{}, err + } + rows.Close() if err := tx.Commit(ctx); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err) } committed = true channel.TopMessageID = msgID channel.Pts = pts - return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event}, nil + return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0), SenderStarsBalance: senderBalance}, nil } // ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。 @@ -205,9 +329,11 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m return domain.Channel{}, false, domain.ErrChannelInvalid } isAdmin := false - if _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); err == nil { + if _, member, memberErr := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); memberErr == nil { isAdmin = member.Status == domain.ChannelMemberActive && (member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin) + } else if !errors.Is(memberErr, domain.ErrChannelPrivate) { + return domain.Channel{}, false, memberErr } return mono, isAdmin, nil } diff --git a/internal/store/postgres/channel_monoforum_send_integration_test.go b/internal/store/postgres/channel_monoforum_send_integration_test.go index 64bc5c1b..a607e699 100644 --- a/internal/store/postgres/channel_monoforum_send_integration_test.go +++ b/internal/store/postgres/channel_monoforum_send_integration_test.go @@ -3,6 +3,7 @@ package postgres import ( "context" "errors" + "slices" "testing" "telesrv/internal/domain" @@ -55,18 +56,46 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { channelIDs = append(channelIDs, monoID) subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID} + if _, err := channels.GetChannel(ctx, sub.ID, monoID); err != nil { + t.Fatalf("subscriber get enabled monoforum without membership: %v", err) + } + if _, err := channels.JoinChannel(ctx, monoID, sub.ID, 1700001001); !errors.Is(err, domain.ErrChannelMonoforumUnsupported) { + t.Fatalf("subscriber join monoforum err = %v, want ErrChannelMonoforumUnsupported", err) + } + suggestedDraft := domain.DialogDraft{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, Message: "pending suggested post", Date: 1700001001, + SuggestedPost: &domain.SuggestedPost{ + Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, + ScheduleDate: 1700100000, + }, + } + dialogStore := NewDialogStore(pool) + if err := dialogStore.SaveDraft(ctx, sub.ID, suggestedDraft); err != nil { + t.Fatalf("save subscriber monoforum draft: %v", err) + } + loadedDraft, found, err := dialogStore.GetDraft(ctx, sub.ID, suggestedDraft.Peer, 0) + if err != nil || !found || loadedDraft.SuggestedPost == nil || loadedDraft.SuggestedPost.Price == nil || loadedDraft.SuggestedPost.Price.Amount != 10 || loadedDraft.SuggestedPost.ScheduleDate != 1700100000 { + t.Fatalf("loaded subscriber monoforum draft = %+v, %v, %v; want suggested post", loadedDraft, found, err) + } - m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001}) + suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000} + m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001, + SuggestedPost: suggestedPost, + }) if err != nil { t.Fatalf("subscriber send 1: %v", err) } if m1.Message.SavedPeer != subPeer || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 { t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message) } + if len(m1.Recipients) != 2 || !slices.Contains(m1.Recipients, owner.ID) || !slices.Contains(m1.Recipients, sub.ID) { + t.Fatalf("m1 recipients = %v, want subscriber %d + parent admin %d", m1.Recipients, sub.ID, owner.ID) + } if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 112, Message: "again", Date: 1700001002}); err != nil { t.Fatalf("subscriber send 2: %v", err) } - if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", Date: 1700001003}); err != nil { + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001003}); err != nil { t.Fatalf("admin reply: %v", err) } @@ -85,12 +114,21 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID { t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID) } - if _, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil { - t.Fatalf("subscriber main monoforum history = nil err, want denied") + subscriberHist, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("subscriber monoforum history: %v", err) + } + if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 { + t.Fatalf("subscriber monoforum history count=%d len=%d, want own 3", subscriberHist.Count, len(subscriberHist.Messages)) + } + for _, message := range subscriberHist.Messages { + if message.SavedPeer != subPeer { + t.Fatalf("subscriber history leaked message %+v", message) + } } // 幂等。 - dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001004}) + dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004}) if err != nil { t.Fatalf("dup send: %v", err) } @@ -124,6 +162,16 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer) } } + oldest := hist.Messages[len(hist.Messages)-1] + if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 { + t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost) + } + if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID { + t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID) + } + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 114, Message: "bad reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err) + } // 另一个订阅者不串会话。 otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} @@ -134,6 +182,31 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { if subHist.Count != 3 { t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count) } + subscriberChannelHistory, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil || subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 { + t.Fatalf("subscriber channel history after other = %d/%d, %v; want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages), err) + } + for _, message := range subscriberChannelHistory.Messages { + if message.SavedPeer != subPeer { + t.Fatalf("subscriber channel history leaked message %+v", message) + } + } + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100}) + if err != nil { + t.Fatalf("subscriber channel difference: %v", err) + } + if len(diff.NewMessages) != 3 { + t.Fatalf("subscriber channel difference messages = %d, want own 3", len(diff.NewMessages)) + } + for _, message := range diff.NewMessages { + if message.SavedPeer != subPeer { + t.Fatalf("subscriber difference leaked message %+v", message) + } + } + activeChannelIDs, err := channels.ListActiveChannelIDsForUser(ctx, sub.ID, 0, 10) + if err != nil || !slices.Contains(activeChannelIDs, monoID) { + t.Fatalf("subscriber active channels = %v, %v; want monoforum %d", activeChannelIDs, err, monoID) + } // 去重按订阅者子会话维度(迁移 0022 唯一索引含 saved_peer_id):管理员用相同 random_id 向两个不同 // 订阅者发,不得互相去重(与 memory 行为一致)。 @@ -202,6 +275,13 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { if err := tx.Commit(ctx); err != nil { t.Fatalf("commit monoforum delete: %v", err) } + deleteDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10}) + if err != nil { + t.Fatalf("subscriber difference after own delete: %v", err) + } + if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID { + t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts) + } var ptsBeforeReplay, eventsBeforeReplay int if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsBeforeReplay); err != nil { t.Fatalf("load monoforum pts: %v", err) @@ -227,3 +307,117 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", ptsAfterReplay, eventsAfterReplay, ptsBeforeReplay, eventsBeforeReplay) } } + +func TestSendPaidMonoforumMessageLedgerPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 191, Phone: "+1789" + suffix + "41", FirstName: "PaidMonoOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sub, err := users.Create(ctx, domain.User{AccessHash: 192, Phone: "+1789" + suffix + "42", FirstName: "PaidMonoSub"}) + if err != nil { + t.Fatalf("create sub: %v", err) + } + other, err := users.Create(ctx, domain.User{AccessHash: 193, Phone: "+1789" + suffix + "43", FirstName: "PaidMonoOther"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + channels := NewChannelStore(pool) + broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Paid Mono " + suffix, Broadcast: true, Date: 1700002000}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, broadcast.Channel.ID, 10, true) + if err != nil { + t.Fatalf("enable paid DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{broadcast.Channel.ID, monoID}) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, sub.ID, other.ID}) + }) + stars := NewStarsStore(pool) + if _, _, err := stars.EnsureGrant(ctx, sub.ID, 25, 1700002000); err != nil { + t.Fatalf("grant subscriber stars: %v", err) + } + if _, _, err := stars.EnsureGrant(ctx, other.ID, 5, 1700002000); err != nil { + t.Fatalf("grant other stars: %v", err) + } + subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID} + var beforeMessages int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&beforeMessages); err != nil { + t.Fatalf("count messages before paid send: %v", err) + } + lowReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4001, Message: "too low", AllowPaidStars: 9, Date: 1700002001} + var required *domain.StarsPaymentRequiredError + if _, err := channels.SendMonoforumMessage(ctx, lowReq); !errors.As(err, &required) || required.Stars != 10 { + t.Fatalf("low authorization err = %v, want 10-Star payment required", err) + } + var afterLowMessages int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&afterLowMessages); err != nil || afterLowMessages != beforeMessages { + t.Fatalf("low authorization message count = %d/%v, want %d", afterLowMessages, err, beforeMessages) + } + + paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4002, Message: "paid", AllowPaidStars: 99, Date: 1700002002} + paid, err := channels.SendMonoforumMessage(ctx, paidReq) + if err != nil { + t.Fatalf("paid send: %v", err) + } + if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 { + t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance) + } + var senderBalance, channelBalance, persistedPaid int64 + if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil { + t.Fatalf("load sender balance: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil { + t.Fatalf("load channel balance: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT paid_message_stars FROM channel_messages WHERE channel_id=$1 AND id=$2`, monoID, paid.Message.ID).Scan(&persistedPaid); err != nil { + t.Fatalf("load persisted paid stars: %v", err) + } + if senderBalance != 15 || channelBalance != 8 || persistedPaid != 10 { + t.Fatalf("persisted sender/channel/message = %d/%d/%d, want 15/8/10", senderBalance, channelBalance, persistedPaid) + } + + replay, err := channels.SendMonoforumMessage(ctx, paidReq) + if err != nil { + t.Fatalf("paid replay: %v", err) + } + if !replay.Duplicate || replay.Message.ID != paid.Message.ID || replay.SenderStarsBalance == nil || replay.SenderStarsBalance.Balance != 15 { + t.Fatalf("paid replay = %+v, want exact original and balance 15", replay) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil || senderBalance != 15 { + t.Fatalf("paid replay sender balance = %d/%v, want 15", senderBalance, err) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 { + t.Fatalf("paid replay channel balance = %d/%v, want 8", channelBalance, err) + } + + admin, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 4003, Message: "free admin reply", AllowPaidStars: 100, Date: 1700002003, + }) + if err != nil { + t.Fatalf("admin reply: %v", err) + } + if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil { + t.Fatalf("admin reply charged: message=%+v balance=%+v", admin.Message, admin.SenderStarsBalance) + } + + otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 4004, Message: "insufficient", AllowPaidStars: 10, Date: 1700002004, + }); !errors.Is(err, domain.ErrStarsInsufficient) { + t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err) + } + var otherBalance int64 + if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, other.ID).Scan(&otherBalance); err != nil || otherBalance != 5 { + t.Fatalf("insufficient sender balance = %d/%v, want 5", otherBalance, err) + } + if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 { + t.Fatalf("insufficient channel balance = %d/%v, want 8", channelBalance, err) + } +} diff --git a/internal/store/postgres/channel_store.go b/internal/store/postgres/channel_store.go index c21e2bfa..21357fd4 100644 --- a/internal/store/postgres/channel_store.go +++ b/internal/store/postgres/channel_store.go @@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p 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, -reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id` +reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post::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, diff --git a/internal/store/postgres/channel_updates.go b/internal/store/postgres/channel_updates.go index 331768fe..bf03c14c 100644 --- a/internal/store/postgres/channel_updates.go +++ b/internal/store/postgres/channel_updates.go @@ -48,6 +48,10 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } + if channel.Monoforum && !isChannelAdmin(member) { + args = append(args, req.UserID) + where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args)) + } args = append(args, domain.MaxChannelDifferenceTooLongMessages) rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` @@ -100,11 +104,15 @@ 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 + type differenceEventRow struct { + event domain.ChannelUpdateEvent + messageID int + } + eventRows := make([]differenceEventRow, 0, limit) for rows.Next() { event, messageID, err := scanChannelEvent(rows) if err != nil { @@ -131,6 +139,27 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) break } lastPts = event.Pts + eventRows = append(eventRows, differenceEventRow{event: event, messageID: messageID}) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.ChannelDifference{}, err + } + rows.Close() + var visibleMonoforumMessageIDs map[int]struct{} + if channel.Monoforum && !isChannelAdmin(member) { + messageIDs := make([]int, 0) + for _, row := range eventRows { + messageIDs = append(messageIDs, row.event.MessageIDs...) + } + visibleMonoforumMessageIDs, err = s.monoforumVisibleMessageIDs(ctx, req.ChannelID, req.UserID, messageIDs) + if err != nil { + return domain.ChannelDifference{}, err + } + } + for _, row := range eventRows { + event := row.event + messageID := row.messageID if messageID != 0 && event.Message.ID == 0 { msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID) if err != nil { @@ -143,6 +172,12 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) continue } event = visibleEvent + if channel.Monoforum && !isChannelAdmin(member) { + event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs) + if !ok { + continue + } + } if preview && event.Type == domain.ChannelUpdateParticipant { continue } @@ -156,9 +191,6 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) 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 { @@ -208,6 +240,55 @@ LIMIT $3`, req.ChannelID, req.Pts, limit) return diff, nil } +func (s *ChannelStore) monoforumVisibleMessageIDs(ctx context.Context, channelID, userID int64, ids []int) (map[int]struct{}, error) { + visible := make(map[int]struct{}) + if len(ids) == 0 { + return visible, nil + } + rows, err := s.db.Query(ctx, ` +SELECT id +FROM channel_messages +WHERE channel_id = $1 + AND id = ANY($2::int[]) + AND saved_peer_type = 'user' + AND saved_peer_id = $3`, channelID, int32s(ids), userID) + if err != nil { + return nil, fmt.Errorf("list visible monoforum message ids: %w", err) + } + defer rows.Close() + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + visible[id] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, err + } + return visible, nil +} + +func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) { + if event.Message.ID != 0 { + return event, event.Message.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) + } + if len(event.MessageIDs) == 0 { + return event, false + } + ids := make([]int, 0, len(event.MessageIDs)) + for _, id := range event.MessageIDs { + if _, ok := visibleMessageIDs[id]; ok { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return event, false + } + event.MessageIDs = ids + return event, true +} + func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) { var pts int err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts) diff --git a/internal/store/postgres/contact.go b/internal/store/postgres/contact.go index fb34a2a8..f8e1786a 100644 --- a/internal/store/postgres/contact.go +++ b/internal/store/postgres/contact.go @@ -87,6 +87,8 @@ SELECT COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -137,6 +139,8 @@ SELECT COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -278,6 +282,8 @@ SELECT COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, 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 @@ -342,6 +348,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do premiumUntil int64 emojiStatusDocID int64 emojiStatusUntil int64 + emojiCollectibleID *int64 + emojiCollectibleJSON []byte lastSeenAt int64 reverseMutualChanged bool ) @@ -366,6 +374,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do &premiumUntil, &emojiStatusDocID, &emojiStatusUntil, + &emojiCollectibleID, + &emojiCollectibleJSON, &lastSeenAt, &reverseMutualChanged, ); err != nil { @@ -376,7 +386,7 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do 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, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)) + out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate upsert contacts many: %w", err) @@ -556,7 +566,7 @@ func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, erro 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, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) { @@ -564,7 +574,7 @@ func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) { 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, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) { @@ -572,7 +582,7 @@ func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) 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, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) { @@ -580,7 +590,7 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, 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, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } // contactFromFields 组装 domain.Contact。getContacts 主路径(List/Get/Upsert/UpdateNote @@ -588,27 +598,28 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, // raw-scan 调用传 false/0——bot 无 phone 不经手机号导入,bot 加联系人走 username // 的单条 UpsertContact 路径(已带真实 bot 列)。premium/emoji status 列所有路径必须 // 传真实值:TDesktop 对任何缺 emoji_status 字段的 user TL 一律清空本地状态。 -func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact { +func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil int, emojiCollectibleID *int64, emojiCollectibleJSON []byte, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend 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, - Bot: isBot, - BotInfoVersion: botInfoVersion, - PremiumUntil: premiumUntil, - EmojiStatusDocumentID: emojiStatusDocumentID, - EmojiStatusUntil: emojiStatusUntil, - LastSeenAt: lastSeenAt, - Contact: true, - Mutual: mutual, - CloseFriend: closeFriend, + ID: id, + AccessHash: accessHash, + Phone: phone, + FirstName: firstName, + LastName: lastName, + Username: username, + CountryCode: countryCode, + Verified: verified, + Support: support, + Bot: isBot, + BotInfoVersion: botInfoVersion, + PremiumUntil: premiumUntil, + EmojiStatusDocumentID: emojiStatusDocumentID, + EmojiStatusUntil: emojiStatusUntil, + EmojiStatusCollectible: mustDecodeEmojiStatusCollectible(emojiCollectibleID, emojiCollectibleJSON), + LastSeenAt: lastSeenAt, + Contact: true, + Mutual: mutual, + CloseFriend: closeFriend, }, FirstName: contactFirstName, LastName: contactLastName, @@ -626,27 +637,29 @@ type contactScanner interface { func scanContactRows(row contactScanner) (domain.Contact, error) { var ( - contactUserID int64 - mutual bool - closeFriend 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 - premiumUntil int64 - emojiStatusDocID int64 - emojiStatusUntil int64 - lastSeenAt int32 + contactUserID int64 + mutual bool + closeFriend 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 + premiumUntil int64 + emojiStatusDocID int64 + emojiStatusUntil int64 + emojiCollectibleID *int64 + emojiCollectibleJSON []byte + lastSeenAt int32 ) if err := row.Scan( &contactUserID, @@ -669,6 +682,8 @@ func scanContactRows(row contactScanner) (domain.Contact, error) { &premiumUntil, &emojiStatusDocID, &emojiStatusUntil, + &emojiCollectibleID, + &emojiCollectibleJSON, &lastSeenAt, ); err != nil { return domain.Contact{}, err @@ -677,32 +692,34 @@ func scanContactRows(row contactScanner) (domain.Contact, error) { if err != nil { return domain.Contact{}, err } - return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil + return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil } func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { var ( - ownerUserID int64 - mutual bool - closeFriend 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 - premiumUntil int64 - emojiStatusDocID int64 - emojiStatusUntil int64 - lastSeenAt int32 + ownerUserID int64 + mutual bool + closeFriend 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 + premiumUntil int64 + emojiStatusDocID int64 + emojiStatusUntil int64 + emojiCollectibleID *int64 + emojiCollectibleJSON []byte + lastSeenAt int32 ) if err := row.Scan( &ownerUserID, @@ -725,6 +742,8 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { &premiumUntil, &emojiStatusDocID, &emojiStatusUntil, + &emojiCollectibleID, + &emojiCollectibleJSON, &lastSeenAt, ); err != nil { return 0, domain.Contact{}, err @@ -733,7 +752,7 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { if err != nil { return 0, domain.Contact{}, err } - contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend) + contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend) return ownerUserID, contact, nil } diff --git a/internal/store/postgres/emoji_status_codec_test.go b/internal/store/postgres/emoji_status_codec_test.go new file mode 100644 index 00000000..b7d3faff --- /dev/null +++ b/internal/store/postgres/emoji_status_codec_test.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "testing" + + "telesrv/internal/domain" +) + +func testCollectibleEmojiStatusValue() domain.UserEmojiStatus { + return domain.UserEmojiStatus{ + DocumentID: 101, + Until: 2_000_000_000, + Collectible: domain.EmojiStatusCollectible{ + CollectibleID: 1001, DocumentID: 101, Title: "Gift", Slug: "Gift-1", + PatternDocumentID: 102, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4, + }, + } +} + +func TestCollectibleEmojiStatusUserAndEventCodecsRoundTrip(t *testing.T) { + value := testCollectibleEmojiStatusValue() + raw, id, err := encodeEmojiStatusCollectible(value) + if err != nil { + t.Fatalf("encode user collectible: %v", err) + } + if id == nil || *id != value.Collectible.CollectibleID { + t.Fatalf("collectible id = %v", id) + } + if got := mustDecodeEmojiStatusCollectible(id, raw); got != value.Collectible { + t.Fatalf("decoded user collectible = %+v, want %+v", got, value.Collectible) + } + + eventRaw, err := encodeEventEmojiStatus(value) + if err != nil { + t.Fatalf("encode event collectible: %v", err) + } + got, err := decodeEventEmojiStatus(string(eventRaw)) + if err != nil || got != value { + t.Fatalf("decoded event collectible = %+v err=%v, want %+v", got, err, value) + } +} + +func TestCollectibleEmojiStatusCodecRejectsPartialSnapshot(t *testing.T) { + value := domain.UserEmojiStatus{ + DocumentID: 101, + Collectible: domain.EmojiStatusCollectible{CollectibleID: 1001, DocumentID: 101}, + } + if _, _, err := encodeEmojiStatusCollectible(value); err == nil { + t.Fatal("partial user snapshot encoded successfully") + } + if _, err := encodeEventEmojiStatus(value); err == nil { + t.Fatal("partial event snapshot encoded successfully") + } +} diff --git a/internal/store/postgres/emoji_status_event_integration_test.go b/internal/store/postgres/emoji_status_event_integration_test.go new file mode 100644 index 00000000..82042580 --- /dev/null +++ b/internal/store/postgres/emoji_status_event_integration_test.go @@ -0,0 +1,62 @@ +package postgres + +import ( + "context" + "fmt" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestUpdateEmojiStatusWithEventIsAtomic(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + u, err := users.Create(ctx, domain.User{ + AccessHash: time.Now().UnixNano(), + Phone: fmt.Sprintf("1666%d", time.Now().UnixNano()), + FirstName: "Emoji status event", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, u.ID) }) + + status := domain.UserEmojiStatus{DocumentID: 42} + event := domain.UpdateEvent{ + Type: domain.UpdateEventUserEmojiStatus, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}, + EmojiStatus: status, + Date: int(time.Now().Unix()), + PtsCount: 1, + } + // A nonzero session without its auth-key half violates the outbox + // exclusion-pair invariant. The event failure must roll back the users row. + if _, _, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, [8]byte{}, 77); err == nil { + t.Fatal("UpdateEmojiStatusWithEvent unexpectedly accepted a partial exclusion pair") + } + got, found, err := users.ByID(ctx, u.ID) + if err != nil || !found || !got.EmojiStatus().Empty() { + t.Fatalf("failed aggregate write leaked user state: user=%+v found=%v err=%v", got, found, err) + } + + authKeyID := [8]byte{1, 2, 3, 4, 5, 6, 7, 8} + got, storedEvent, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, authKeyID, 77) + if err != nil { + t.Fatalf("UpdateEmojiStatusWithEvent: %v", err) + } + if got.EmojiStatus() != status || storedEvent.Pts <= 0 || storedEvent.EmojiStatus != status { + t.Fatalf("aggregate result: user=%+v event=%+v", got.EmojiStatus(), storedEvent) + } + loaded, err := NewUpdateEventStore(pool).ListAfter(ctx, u.ID, storedEvent.Pts-1, 1) + if err != nil || len(loaded) != 1 || loaded[0].EmojiStatus != status { + t.Fatalf("durable event: events=%+v err=%v", loaded, err) + } + var outboxCount int + if err := pool.QueryRow(ctx, ` +SELECT COUNT(*) FROM dispatch_outbox +WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, u.ID, storedEvent.Pts).Scan(&outboxCount); err != nil || outboxCount != 1 { + t.Fatalf("dispatch outbox count=%d err=%v", outboxCount, err) + } +} diff --git a/internal/store/postgres/message_delete.go b/internal/store/postgres/message_delete.go index 862b2da4..3d4406ef 100644 --- a/internal/store/postgres/message_delete.go +++ b/internal/store/postgres/message_delete.go @@ -331,19 +331,20 @@ func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event do 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("[]"), - StoryPayload: []byte("{}"), - ReactionPayload: []byte("{}"), + 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("[]"), + StoryPayload: []byte("{}"), + ReactionPayload: []byte("{}"), + EmojiStatusPayload: []byte("{}"), }); err != nil { return fmt.Errorf("append delete messages event: %w", err) } diff --git a/internal/store/postgres/message_send.go b/internal/store/postgres/message_send.go index c0436c2d..d7e2199d 100644 --- a/internal/store/postgres/message_send.go +++ b/internal/store/postgres/message_send.go @@ -656,22 +656,23 @@ func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.M 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("[]"), - StoryPayload: []byte("{}"), - ReactionPayload: []byte("{}"), - MessageBoxID: &boxID, - PeerType: &peerType, - PeerID: &peerID, + 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("[]"), + StoryPayload: []byte("{}"), + ReactionPayload: []byte("{}"), + EmojiStatusPayload: []byte("{}"), + MessageBoxID: &boxID, + PeerType: &peerType, + PeerID: &peerID, }); err != nil { return fmt.Errorf("append new message event: %w", err) } diff --git a/internal/store/postgres/queries/contact.sql b/internal/store/postgres/queries/contact.sql index 7fa76e30..359c043e 100644 --- a/internal/store/postgres/queries/contact.sql +++ b/internal/store/postgres/queries/contact.sql @@ -22,6 +22,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -52,6 +54,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -130,6 +134,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at, EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed FROM upserted c @@ -168,6 +174,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM updated c JOIN users u ON u.id = c.contact_user_id; diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql index 1c0e96b4..4d4d64ea 100644 --- a/internal/store/postgres/queries/user.sql +++ b/internal/store/postgres/queries/user.sql @@ -37,6 +37,8 @@ WITH matched AS ( u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.color_set, u.color, u.color_background_emoji_id, @@ -86,6 +88,8 @@ SELECT premium_expires_at, emoji_status_document_id, emoji_status_until, + emoji_status_collectible_id, + emoji_status_collectible, color_set, color, color_background_emoji_id, @@ -165,6 +169,8 @@ RETURNING *; UPDATE users SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint, emoji_status_until = sqlc.arg(emoji_status_until)::bigint, + emoji_status_collectible_id = sqlc.narg(emoji_status_collectible_id)::bigint, + emoji_status_collectible = sqlc.arg(emoji_status_collectible)::jsonb, updated_at = now() WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL RETURNING *; diff --git a/internal/store/postgres/queries/user_update_event.sql b/internal/store/postgres/queries/user_update_event.sql index 230b49e8..d3232eb2 100644 --- a/internal/store/postgres/queries/user_update_event.sql +++ b/internal/store/postgres/queries/user_update_event.sql @@ -15,6 +15,7 @@ INSERT INTO user_update_events ( folder_peers, story_payload, reaction_payload, + emoji_status_payload, message_box_id, peer_type, peer_id, @@ -40,6 +41,7 @@ INSERT INTO user_update_events ( sqlc.arg(folder_peers)::jsonb, sqlc.arg(story_payload)::jsonb, sqlc.arg(reaction_payload)::jsonb, + sqlc.arg(emoji_status_payload)::jsonb, sqlc.narg(message_box_id), sqlc.narg(peer_type)::text, sqlc.narg(peer_id)::bigint, @@ -68,6 +70,7 @@ SELECT COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, + COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, @@ -341,6 +344,7 @@ SELECT COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, + COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, diff --git a/internal/store/postgres/sqlcgen/bot.sql.go b/internal/store/postgres/sqlcgen/bot.sql.go index 2798528f..4ade2e28 100644 --- a/internal/store/postgres/sqlcgen/bot.sql.go +++ b/internal/store/postgres/sqlcgen/bot.sql.go @@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error { const insertBotUser = `-- name: InsertBotUser :one INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) VALUES ($1, '', $2, '', $3, '', TRUE, 1) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type InsertBotUserParams struct { @@ -213,6 +213,8 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } diff --git a/internal/store/postgres/sqlcgen/contact.sql.go b/internal/store/postgres/sqlcgen/contact.sql.go index 9142a2cf..a05989c9 100644 --- a/internal/store/postgres/sqlcgen/contact.sql.go +++ b/internal/store/postgres/sqlcgen/contact.sql.go @@ -67,6 +67,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -80,29 +82,31 @@ type GetContactParams struct { } type GetContactRow struct { - ContactUserID int64 - Mutual bool - CloseFriend 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 - IsBot bool - BotInfoVersion int32 - PremiumExpiresAt pgtype.Timestamptz - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend 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 + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LastSeenAt int64 } func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) { @@ -131,6 +135,8 @@ func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetCont &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.LastSeenAt, ) return i, err @@ -160,6 +166,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -168,29 +176,31 @@ ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u ` type ListContactsByUserRow struct { - ContactUserID int64 - Mutual bool - CloseFriend 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 - IsBot bool - BotInfoVersion int32 - PremiumExpiresAt pgtype.Timestamptz - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend 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 + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LastSeenAt int64 } func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) { @@ -225,6 +235,8 @@ func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListC &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.LastSeenAt, ); err != nil { return nil, err @@ -270,6 +282,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at FROM updated c JOIN users u ON u.id = c.contact_user_id @@ -283,29 +297,31 @@ type UpdateContactNoteParams struct { } type UpdateContactNoteRow struct { - ContactUserID int64 - Mutual bool - CloseFriend 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 - IsBot bool - BotInfoVersion int32 - PremiumExpiresAt pgtype.Timestamptz - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend 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 + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LastSeenAt int64 } func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) { @@ -339,6 +355,8 @@ func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNotePa &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.LastSeenAt, ) return i, err @@ -416,6 +434,8 @@ SELECT u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.last_seen_at, EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed FROM upserted c @@ -433,30 +453,32 @@ type UpsertContactParams struct { } type UpsertContactRow struct { - ContactUserID int64 - Mutual bool - CloseFriend 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 - IsBot bool - BotInfoVersion int32 - PremiumExpiresAt pgtype.Timestamptz - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - LastSeenAt int64 - ReverseMutualChanged bool + ContactUserID int64 + Mutual bool + CloseFriend 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 + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + LastSeenAt int64 + ReverseMutualChanged bool } func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) { @@ -493,6 +515,8 @@ func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (U &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.LastSeenAt, &i.ReverseMutualChanged, ) diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index e463dcdc..ed83afce 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -2144,6 +2144,8 @@ type User struct { DeletionSource string DeletionReason string AccountDeleteAt pgtype.Timestamptz + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte } type UserBusinessProfile struct { @@ -2219,33 +2221,34 @@ type UserTopReaction struct { } 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 - ChannelPts int32 - FolderID int32 - QuickReplies []byte - QuickReplyMessage []byte - StoryPayload []byte - ReactionPayload []byte - EventPhone string + 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 + ChannelPts int32 + FolderID int32 + QuickReplies []byte + QuickReplyMessage []byte + StoryPayload []byte + ReactionPayload []byte + EventPhone string + EmojiStatusPayload []byte } type UserUpdateRetention struct { diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go index ba00aa72..4d8891e6 100644 --- a/internal/store/postgres/sqlcgen/user.sql.go +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -14,7 +14,7 @@ import ( const createUser = `-- name: CreateUser :one INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type CreateUserParams struct { @@ -72,12 +72,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) 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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE id = $1 +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { @@ -117,12 +119,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) 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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE phone = $1 AND deleted_at IS NULL +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE phone = $1 AND deleted_at IS NULL ` func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { @@ -162,12 +166,14 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) 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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL ` func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) { @@ -207,12 +213,14 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) 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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE id = ANY($1::bigint[]) ORDER BY id @@ -261,6 +269,8 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ); err != nil { return nil, err } @@ -273,7 +283,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error } 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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE phone = ANY($1::text[]) AND deleted_at IS NULL ORDER BY id @@ -322,6 +332,8 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ); err != nil { return nil, err } @@ -351,6 +363,8 @@ WITH matched AS ( u.premium_expires_at, u.emoji_status_document_id, u.emoji_status_until, + u.emoji_status_collectible_id, + u.emoji_status_collectible, u.color_set, u.color, u.color_background_emoji_id, @@ -400,6 +414,8 @@ SELECT premium_expires_at, emoji_status_document_id, emoji_status_until, + emoji_status_collectible_id, + emoji_status_collectible, color_set, color, color_background_emoji_id, @@ -438,6 +454,8 @@ type SearchUsersRow struct { PremiumExpiresAt pgtype.Timestamptz EmojiStatusDocumentID int64 EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte ColorSet bool Color int32 ColorBackgroundEmojiID int64 @@ -480,6 +498,8 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea &i.PremiumExpiresAt, &i.EmojiStatusDocumentID, &i.EmojiStatusUntil, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, &i.ColorSet, &i.Color, &i.ColorBackgroundEmojiID, @@ -505,7 +525,7 @@ UPDATE users SET premium_expires_at = $1::timestamptz, updated_at = now() WHERE id = $2::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type SetUserPremiumUntilParams struct { @@ -550,6 +570,8 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -559,7 +581,7 @@ UPDATE users SET verified = $1::boolean, updated_at = now() WHERE id = $2::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type SetUserVerifiedParams struct { @@ -604,6 +626,8 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -620,7 +644,7 @@ WHERE id IN ( ORDER BY premium_expires_at LIMIT $2::int ) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type SweepExpiredPremiumParams struct { @@ -671,6 +695,8 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ); err != nil { return nil, err } @@ -689,7 +715,7 @@ SET birthday_day = $1::int, birthday_year = $3::int, updated_at = now() WHERE id = $4::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type UpdateUserBirthdayParams struct { @@ -741,6 +767,8 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -752,7 +780,7 @@ SET color_set = $1::boolean, color_background_emoji_id = $3::bigint, updated_at = now() WHERE id = $4::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type UpdateUserColorParams struct { @@ -804,6 +832,8 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -812,19 +842,29 @@ const updateUserEmojiStatus = `-- name: UpdateUserEmojiStatus :one UPDATE users SET emoji_status_document_id = $1::bigint, emoji_status_until = $2::bigint, + emoji_status_collectible_id = $3::bigint, + emoji_status_collectible = $4::jsonb, updated_at = now() -WHERE id = $3::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +WHERE id = $5::bigint AND deleted_at IS NULL +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type UpdateUserEmojiStatusParams struct { - EmojiStatusDocumentID int64 - EmojiStatusUntil int64 - ID int64 + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + EmojiStatusCollectibleID *int64 + EmojiStatusCollectible []byte + ID int64 } func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmojiStatusParams) (User, error) { - row := q.db.QueryRow(ctx, updateUserEmojiStatus, arg.EmojiStatusDocumentID, arg.EmojiStatusUntil, arg.ID) + row := q.db.QueryRow(ctx, updateUserEmojiStatus, + arg.EmojiStatusDocumentID, + arg.EmojiStatusUntil, + arg.EmojiStatusCollectibleID, + arg.EmojiStatusCollectible, + arg.ID, + ) var i User err := row.Scan( &i.ID, @@ -860,6 +900,8 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -886,7 +928,7 @@ UPDATE users SET personal_channel_id = $1::bigint, updated_at = now() WHERE id = $2::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type UpdateUserPersonalChannelParams struct { @@ -931,6 +973,8 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -940,7 +984,7 @@ UPDATE users SET phone = $1::text, updated_at = now() WHERE id = $2::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type UpdateUserPhoneParams struct { @@ -985,6 +1029,8 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -996,7 +1042,7 @@ SET first_name = $2, about = $4, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type UpdateUserProfileParams struct { @@ -1048,6 +1094,8 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -1059,7 +1107,7 @@ SET profile_color_set = $1::boolean, profile_color_background_emoji_id = $3::bigint, updated_at = now() WHERE id = $4::bigint AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type UpdateUserProfileColorParams struct { @@ -1111,6 +1159,8 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) return i, err } @@ -1120,7 +1170,7 @@ UPDATE users SET username = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible ` type UpdateUserUsernameParams struct { @@ -1165,6 +1215,8 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername &i.DeletionSource, &i.DeletionReason, &i.AccountDeleteAt, + &i.EmojiStatusCollectibleID, + &i.EmojiStatusCollectible, ) 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 index c864c11c..cf57ce80 100644 --- a/internal/store/postgres/sqlcgen/user_update_event.sql.go +++ b/internal/store/postgres/sqlcgen/user_update_event.sql.go @@ -26,6 +26,7 @@ INSERT INTO user_update_events ( folder_peers, story_payload, reaction_payload, + emoji_status_payload, message_box_id, peer_type, peer_id, @@ -51,43 +52,45 @@ INSERT INTO user_update_events ( $13::jsonb, $14::jsonb, $15::jsonb, - $16, - $17::text, - $18::bigint, - $19::int, + $16::jsonb, + $17, + $18::text, + $19::bigint, $20::int, $21::int, $22::int, - $23::boolean, - $24::int + $23::int, + $24::boolean, + $25::int ) ` type AppendUserUpdateEventParams struct { - UserID int64 - Pts int32 - PtsCount int32 - Date int32 - EventType string - EventBool bool - EventPhone string - EventPeers []byte - PeerSettings []byte - MessageIds []byte - DialogFilter []byte - FilterOrder []byte - FolderPeers []byte - StoryPayload []byte - ReactionPayload []byte - MessageBoxID *int32 - PeerType *string - PeerID *int64 - FilterID int32 - MaxID int32 - StillUnreadCount int32 - ChannelPts int32 - TagsEnabled bool - FolderID int32 + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + EventBool bool + EventPhone string + EventPeers []byte + PeerSettings []byte + MessageIds []byte + DialogFilter []byte + FilterOrder []byte + FolderPeers []byte + StoryPayload []byte + ReactionPayload []byte + EmojiStatusPayload []byte + MessageBoxID *int32 + PeerType *string + PeerID *int64 + FilterID int32 + MaxID int32 + StillUnreadCount int32 + ChannelPts int32 + TagsEnabled bool + FolderID int32 } func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error { @@ -107,6 +110,7 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat arg.FolderPeers, arg.StoryPayload, arg.ReactionPayload, + arg.EmojiStatusPayload, arg.MessageBoxID, arg.PeerType, arg.PeerID, @@ -137,6 +141,7 @@ SELECT COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, + COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, @@ -274,6 +279,7 @@ type BatchListDispatchEventsRow struct { FolderPeersJson string StoryPayloadJson string ReactionPayloadJson string + EmojiStatusPayloadJson string EventPeerType string EventPeerID int64 FilterID int32 @@ -409,6 +415,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.FolderPeersJson, &i.StoryPayloadJson, &i.ReactionPayloadJson, + &i.EmojiStatusPayloadJson, &i.EventPeerType, &i.EventPeerID, &i.FilterID, @@ -788,6 +795,7 @@ SELECT COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, + COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, @@ -928,6 +936,7 @@ type ListUserUpdateEventsAfterRow struct { FolderPeersJson string StoryPayloadJson string ReactionPayloadJson string + EmojiStatusPayloadJson string EventPeerType string EventPeerID int64 FilterID int32 @@ -1061,6 +1070,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.FolderPeersJson, &i.StoryPayloadJson, &i.ReactionPayloadJson, + &i.EmojiStatusPayloadJson, &i.EventPeerType, &i.EventPeerID, &i.FilterID, diff --git a/internal/store/postgres/star_gift_collectibles.go b/internal/store/postgres/star_gift_collectibles.go index 4e2260b6..7bad16aa 100644 --- a/internal/store/postgres/star_gift_collectibles.go +++ b/internal/store/postgres/star_gift_collectibles.go @@ -349,6 +349,37 @@ func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) return out, nil } +func (s *StarGiftStore) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) { + if owner.ID <= 0 || limit <= 0 { + return []domain.UniqueStarGift{}, nil + } + if limit > domain.MaxSavedStarGiftsLimit { + limit = domain.MaxSavedStarGiftsLimit + } + rows, err := s.db.Query(ctx, uniqueStarGiftQuery(` +u.owner_peer_type=$1 AND u.owner_peer_id=$2 +AND NOT u.burned AND u.owner_address='' +AND sg.lifecycle_status='active'`)+` +ORDER BY u.id DESC +LIMIT $3`, string(owner.Type), owner.ID, limit) + if err != nil { + return nil, fmt.Errorf("list unique star gifts by owner: %w", err) + } + defer rows.Close() + out := make([]domain.UniqueStarGift, 0, limit) + for rows.Next() { + gift, err := scanUniqueStarGift(rows) + if err != nil { + return nil, err + } + out = append(out, gift) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate unique star gifts by owner: %w", err) + } + return out, nil +} + func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) { row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value) unique, err := scanUniqueStarGift(row) diff --git a/internal/store/postgres/star_gift_lifecycle_integration_test.go b/internal/store/postgres/star_gift_lifecycle_integration_test.go index f3c82592..fdb50782 100644 --- a/internal/store/postgres/star_gift_lifecycle_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_integration_test.go @@ -213,6 +213,21 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) { if err != nil || resold.Unique.Owner.ID != resaleBuyer.ID || resold.Balance.Balance != 999000 || resold.Saved.TransferStars != 25 { t.Fatalf("TON resale = %+v err %v", resold, err) } + selected, valid := domain.CollectibleEmojiStatus(resold.Unique) + if !valid { + t.Fatalf("resold collectible cannot project emoji status: %+v", resold.Unique) + } + if _, err := users.UpdateEmojiStatus(ctx, resaleBuyer.ID, domain.UserEmojiStatus{ + DocumentID: selected.DocumentID, + Collectible: selected, + }); err != nil { + t.Fatalf("wear resold collectible: %v", err) + } + updateEvents := NewUpdateEventStore(pool) + statusPtsBeforeTransfer, err := updateEvents.MaxContiguousPts(ctx, resaleBuyer.ID) + if err != nil { + t.Fatalf("emoji status pts before transfer: %v", err) + } if sellerTON, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || sellerTON != 1_000_900 { t.Fatalf("TON seller local balance = %d err %v", sellerTON, err) } @@ -233,6 +248,29 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) { if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 { t.Fatalf("paid transfer = %+v err %v", transferred, err) } + clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID) + if err != nil || !found || !clearedUser.EmojiStatus().Empty() { + t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err) + } + statusEvents, err := updateEvents.ListAfter(ctx, resaleBuyer.ID, statusPtsBeforeTransfer, 20) + if err != nil { + t.Fatalf("load collectible invalidation event: %v", err) + } + var clearEvent domain.UpdateEvent + for _, event := range statusEvents { + if event.Type == domain.UpdateEventUserEmojiStatus { + clearEvent = event + break + } + } + if clearEvent.Pts == 0 || !clearEvent.EmojiStatus.Empty() || clearEvent.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}) { + t.Fatalf("collectible invalidation event = %+v", clearEvent) + } + var clearOutboxCount int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox +WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBuyer.ID, clearEvent.Pts).Scan(&clearOutboxCount); err != nil || clearOutboxCount != 1 { + t.Fatalf("collectible invalidation outbox count=%d err=%v, want 1", clearOutboxCount, err) + } // A second prepaid collectible makes craft chance exactly 1000‰. Success // preserves the first aggregate as crafted and burns the other input. The diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index 24eb00ac..7f25e224 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) { if err != nil { t.Fatalf("migrate star gift lifecycle schema: %v", err) } - if status.Dirty || status.Empty || status.Version != 107 { - t.Fatalf("migration status = %+v, want clean version 107", status) + if status.Dirty || status.Empty || status.Version != 118 { + t.Fatalf("migration status = %+v, want clean version 118", status) } } diff --git a/internal/store/postgres/update_event.go b/internal/store/postgres/update_event.go index f937528e..364a831b 100644 --- a/internal/store/postgres/update_event.go +++ b/internal/store/postgres/update_event.go @@ -211,31 +211,36 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer if err != nil { return err } + emojiStatusPayload, err := encodeEventEmojiStatus(event.EmojiStatus) + 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, - EventPhone: event.Phone, - EventPeers: peers, - PeerSettings: settings, - MessageIds: messageIDs, - DialogFilter: dialogFilter, - FilterOrder: filterOrder, - FolderPeers: folderPeers, - StoryPayload: storyPayload, - ReactionPayload: reactionPayload, - MaxID: pgInt32NonNegative(event.MaxID), - StillUnreadCount: int32(event.StillUnreadCount), - ChannelPts: int32(event.ChannelPts), - FilterID: pgInt32NonNegative(event.FilterID), - TagsEnabled: event.TagsEnabled, - FolderID: pgInt32NonNegative(event.FolderID), - MessageBoxID: messageID, - PeerType: peerType, - PeerID: peerID, + UserID: userID, + Pts: int32(event.Pts), + PtsCount: int32(event.PtsCount), + Date: int32(event.Date), + EventType: string(event.Type), + EventBool: event.Bool, + EventPhone: event.Phone, + EventPeers: peers, + PeerSettings: settings, + MessageIds: messageIDs, + DialogFilter: dialogFilter, + FilterOrder: filterOrder, + FolderPeers: folderPeers, + StoryPayload: storyPayload, + ReactionPayload: reactionPayload, + EmojiStatusPayload: emojiStatusPayload, + MaxID: pgInt32NonNegative(event.MaxID), + StillUnreadCount: int32(event.StillUnreadCount), + ChannelPts: int32(event.ChannelPts), + FilterID: pgInt32NonNegative(event.FilterID), + TagsEnabled: event.TagsEnabled, + FolderID: pgInt32NonNegative(event.FolderID), + MessageBoxID: messageID, + PeerType: peerType, + PeerID: peerID, }); err != nil { return err } @@ -385,6 +390,10 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim if err != nil { return nil, fmt.Errorf("decode reaction payload: %w", err) } + emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson) + if err != nil { + return nil, fmt.Errorf("decode emoji status payload: %w", err) + } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return nil, fmt.Errorf("decode message media: %w", err) @@ -420,6 +429,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim TagsEnabled: row.TagsEnabled, FolderID: int(row.FolderID), Reaction: reaction, + EmojiStatus: emojiStatus, Message: domain.Message{ ID: int(row.MessageID), UID: row.PrivateMessageID, @@ -579,6 +589,10 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev if err != nil { return nil, fmt.Errorf("decode reaction payload: %w", err) } + emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson) + if err != nil { + return nil, fmt.Errorf("decode emoji status payload: %w", err) + } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return nil, fmt.Errorf("decode message media: %w", err) @@ -614,6 +628,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev TagsEnabled: row.TagsEnabled, FolderID: int(row.FolderID), Reaction: reaction, + EmojiStatus: emojiStatus, Message: domain.Message{ ID: int(row.MessageID), UID: row.PrivateMessageID, @@ -979,6 +994,31 @@ func decodeEventReaction(raw string) (*domain.MessageReaction, error) { return decodeStoryReaction(raw) } +func encodeEventEmojiStatus(status domain.UserEmojiStatus) ([]byte, error) { + if !status.Valid() { + return nil, domain.ErrStarGiftCollectibleInvalid + } + raw, err := json.Marshal(status) + if err != nil { + return nil, fmt.Errorf("marshal event emoji status: %w", err) + } + return raw, nil +} + +func decodeEventEmojiStatus(raw string) (domain.UserEmojiStatus, error) { + if raw == "" || raw == "{}" || raw == "null" { + return domain.UserEmojiStatus{}, nil + } + var status domain.UserEmojiStatus + if err := json.Unmarshal([]byte(raw), &status); err != nil { + return domain.UserEmojiStatus{}, err + } + if !status.Valid() { + return domain.UserEmojiStatus{}, domain.ErrStarGiftCollectibleInvalid + } + return status, nil +} + type peerSettingsJSON struct { AddContact bool `json:"add_contact,omitempty"` BlockContact bool `json:"block_contact,omitempty"` diff --git a/internal/store/postgres/user.go b/internal/store/postgres/user.go index 9c754dd9..afc5391e 100644 --- a/internal/store/postgres/user.go +++ b/internal/store/postgres/user.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -134,27 +135,29 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon Results: make([]domain.User, 0, len(rows)), } for _, row := range rows { + collectible := mustDecodeEmojiStatusCollectible(row.EmojiStatusCollectibleID, row.EmojiStatusCollectible) 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, - Bot: row.IsBot, - BotInfoVersion: int(row.BotInfoVersion), - PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt), - EmojiStatusDocumentID: row.EmojiStatusDocumentID, - EmojiStatusUntil: int(row.EmojiStatusUntil), - Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID), - ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID), - LastSeenAt: int(row.LastSeenAt), - Contact: row.Contact, - Mutual: row.Mutual, + 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, + Bot: row.IsBot, + BotInfoVersion: int(row.BotInfoVersion), + PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt), + EmojiStatusDocumentID: row.EmojiStatusDocumentID, + EmojiStatusUntil: int(row.EmojiStatusUntil), + EmojiStatusCollectible: collectible, + Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID), + ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID), + LastSeenAt: int(row.LastSeenAt), + Contact: row.Contact, + Mutual: row.Mutual, } if row.Contact { out.MyResults = append(out.MyResults, u) @@ -336,22 +339,110 @@ func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit in return out, nil } -// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。 -func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) { - row, err := s.q.UpdateUserEmojiStatus(ctx, sqlcgen.UpdateUserEmojiStatusParams{ - ID: userID, - EmojiStatusDocumentID: documentID, - EmojiStatusUntil: int64(until), - }) +// UpdateEmojiStatus atomically replaces the complete emoji-status snapshot. +func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) { + collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status) + if err != nil { + return domain.User{}, err + } + params := sqlcgen.UpdateUserEmojiStatusParams{ + ID: userID, + EmojiStatusDocumentID: status.DocumentID, + EmojiStatusUntil: int64(status.Until), + EmojiStatusCollectibleID: collectibleID, + EmojiStatusCollectible: collectibleJSON, + } + var row sqlcgen.User + if status.Collectible.Empty() { + row, err = updateEmojiStatusRow(ctx, s.db, s.q, userID, status, params) + } else { + // Serialize selection against transfer/export/burn. RPC-level ownership + // checks are advisory; this lock is the write-boundary invariant that + // prevents a concurrent lifecycle commit from leaving a non-owned gift + // installed after its invalidation trigger already ran. + err = withTx(ctx, s.db, "update collectible emoji status", func(tx pgx.Tx) error { + row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params) + return err + }) + } if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.User{}, domain.ErrUserNotFound } + if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + return domain.User{}, err + } return domain.User{}, fmt.Errorf("update user emoji status: %w", err) } return userFromModel(row), nil } +// UpdateEmojiStatusWithEvent commits the user snapshot, allocated pts event +// and dispatch outbox row as one aggregate transaction. This is the production +// boundary used by account.updateEmojiStatus; no success can expose a users +// row whose change is absent from updates.getDifference. +func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, error) { + collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status) + if err != nil { + return domain.User{}, domain.UpdateEvent{}, err + } + if event.Type != domain.UpdateEventUserEmojiStatus || event.EmojiStatus != status || + event.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return domain.User{}, domain.UpdateEvent{}, domain.ErrStarGiftCollectibleInvalid + } + params := sqlcgen.UpdateUserEmojiStatusParams{ + ID: userID, + EmojiStatusDocumentID: status.DocumentID, + EmojiStatusUntil: int64(status.Until), + EmojiStatusCollectibleID: collectibleID, + EmojiStatusCollectible: collectibleJSON, + } + var row sqlcgen.User + err = withTx(ctx, s.db, "update emoji status with event", func(tx pgx.Tx) error { + row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params) + if err != nil { + return err + } + event, err = NewUpdateEventStore(tx).AppendAllocatedWithDispatch( + ctx, userID, event, excludeAuthKeyID, excludeSessionID, + ) + return err + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.UpdateEvent{}, domain.ErrUserNotFound + } + if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) { + return domain.User{}, domain.UpdateEvent{}, err + } + return domain.User{}, domain.UpdateEvent{}, fmt.Errorf("update user emoji status with event: %w", err) + } + return userFromModel(row), event, nil +} + +func updateEmojiStatusRow(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, status domain.UserEmojiStatus, params sqlcgen.UpdateUserEmojiStatusParams) (sqlcgen.User, error) { + if !status.Collectible.Empty() { + var lockedID int64 + if err := db.QueryRow(ctx, ` +SELECT id FROM unique_star_gifts WHERE id=$1 FOR UPDATE`, status.Collectible.CollectibleID).Scan(&lockedID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid + } + return sqlcgen.User{}, err + } + gift, found, err := NewStarGiftStore(db).UniqueByID(ctx, lockedID) + if err != nil { + return sqlcgen.User{}, err + } + expected, valid := domain.CollectibleEmojiStatus(gift) + if !found || !valid || gift.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) || + gift.Burned || gift.OwnerAddress != "" || expected != status.Collectible { + return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid + } + } + return q.UpdateUserEmojiStatus(ctx, params) +} + // UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。 func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{ @@ -444,32 +535,34 @@ func escapeLike(s string) string { } func userFromModel(r sqlcgen.User) domain.User { + collectible := mustDecodeEmojiStatusCollectible(r.EmojiStatusCollectibleID, r.EmojiStatusCollectible) u := 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, - Bot: r.IsBot, - BotInfoVersion: int(r.BotInfoVersion), - PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt), - EmojiStatusDocumentID: r.EmojiStatusDocumentID, - EmojiStatusUntil: int(r.EmojiStatusUntil), - Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)}, - PersonalChannelID: r.PersonalChannelID, - Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), - ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), - LastSeenAt: int(r.LastSeenAt), - Deleted: r.DeletedAt.Valid, - DeletionSource: domain.AccountDeletionSource(r.DeletionSource), - DeletionReason: r.DeletionReason, - CreatedAt: r.CreatedAt.Time, - AccountDeleteAt: r.AccountDeleteAt.Time, + 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, + Bot: r.IsBot, + BotInfoVersion: int(r.BotInfoVersion), + PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt), + EmojiStatusDocumentID: r.EmojiStatusDocumentID, + EmojiStatusUntil: int(r.EmojiStatusUntil), + EmojiStatusCollectible: collectible, + Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)}, + PersonalChannelID: r.PersonalChannelID, + Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), + ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), + LastSeenAt: int(r.LastSeenAt), + Deleted: r.DeletedAt.Valid, + DeletionSource: domain.AccountDeletionSource(r.DeletionSource), + DeletionReason: r.DeletionReason, + CreatedAt: r.CreatedAt.Time, + AccountDeleteAt: r.AccountDeleteAt.Time, } if r.DeletedAt.Valid { u.DeletedAt = r.DeletedAt.Time.Unix() @@ -478,6 +571,38 @@ func userFromModel(r sqlcgen.User) domain.User { return u } +func encodeEmojiStatusCollectible(status domain.UserEmojiStatus) ([]byte, *int64, error) { + if !status.Valid() { + return nil, nil, domain.ErrStarGiftCollectibleInvalid + } + if status.Collectible.Empty() { + return []byte(`{}`), nil, nil + } + raw, err := json.Marshal(status.Collectible) + if err != nil { + return nil, nil, fmt.Errorf("encode collectible emoji status: %w", err) + } + id := status.Collectible.CollectibleID + return raw, &id, nil +} + +func mustDecodeEmojiStatusCollectible(id *int64, raw []byte) domain.EmojiStatusCollectible { + var collectible domain.EmojiStatusCollectible + if err := json.Unmarshal(raw, &collectible); err != nil { + panic(fmt.Sprintf("invalid users.emoji_status_collectible JSON: %v", err)) + } + if id == nil { + if !collectible.Empty() { + panic("users emoji-status invariant: snapshot exists without collectible id") + } + return domain.EmojiStatusCollectible{} + } + if !collectible.Valid() || collectible.CollectibleID != *id { + panic("users emoji-status invariant: incomplete or mismatched collectible snapshot") + } + return collectible +} + func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor { return domain.PeerColor{ HasColor: hasColor, diff --git a/internal/store/private_send_idempotency.go b/internal/store/private_send_idempotency.go index f737cc13..80346527 100644 --- a/internal/store/private_send_idempotency.go +++ b/internal/store/private_send_idempotency.go @@ -69,11 +69,17 @@ type channelSendFingerprintPayload struct { } type monoforumSendFingerprintPayload struct { - Version int `json:"version"` - ChannelID int64 `json:"channel_id"` - SavedPeer domain.Peer `json:"saved_peer"` - Message string `json:"message"` - Entities []domain.MessageEntity `json:"entities"` + Version int `json:"version"` + ChannelID int64 `json:"channel_id"` + SavedPeer domain.Peer `json:"saved_peer"` + Message string `json:"message"` + Entities []domain.MessageEntity `json:"entities"` + Media *domain.MessageMedia `json:"media"` + ReplyTo *domain.MessageReply `json:"reply_to"` + Silent bool `json:"silent"` + NoForwards bool `json:"noforwards"` + SuggestedPost *domain.SuggestedPost `json:"suggested_post,omitempty"` + AllowPaidStars int64 `json:"allow_paid_stars"` } // PrivateSendFingerprint returns a SHA-256 fingerprint of the original send @@ -155,11 +161,17 @@ func MonoforumSendFingerprint(req domain.SendMonoforumMessageRequest) ([]byte, e return append([]byte(nil), req.IdempotencyFingerprint...), nil } payload, err := json.Marshal(monoforumSendFingerprintPayload{ - Version: channelSendFingerprintVersion, - ChannelID: req.MonoforumID, - SavedPeer: req.SavedPeer, - Message: req.Message, - Entities: req.Entities, + Version: channelSendFingerprintVersion, + ChannelID: req.MonoforumID, + SavedPeer: req.SavedPeer, + Message: req.Message, + Entities: req.Entities, + Media: req.Media, + ReplyTo: req.ReplyTo, + Silent: req.Silent, + NoForwards: req.NoForwards, + SuggestedPost: req.SuggestedPost, + AllowPaidStars: req.AllowPaidStars, }) if err != nil { return nil, fmt.Errorf("marshal monoforum send fingerprint: %w", err) diff --git a/internal/store/redisstore/user_cache.go b/internal/store/redisstore/user_cache.go index 092ea10b..3470470f 100644 --- a/internal/store/redisstore/user_cache.go +++ b/internal/store/redisstore/user_cache.go @@ -47,9 +47,10 @@ type userBaseValue struct { BotInfoVersion int `json:"bot_info_version,omitempty"` // premium / emoji status 同理必须随缓存往返:丢失会让缓存命中路径把 // 会员输出成非会员,跨路径状态漂移(与 bot 列同一坑位)。 - PremiumUntil int `json:"premium_until,omitempty"` - EmojiStatusDocumentID int64 `json:"emoji_status_document_id,omitempty"` - EmojiStatusUntil int `json:"emoji_status_until,omitempty"` + PremiumUntil int `json:"premium_until,omitempty"` + EmojiStatusDocumentID int64 `json:"emoji_status_document_id,omitempty"` + EmojiStatusUntil int `json:"emoji_status_until,omitempty"` + EmojiStatusCollectible domain.EmojiStatusCollectible `json:"emoji_status_collectible,omitempty"` // birthday / personal channel 同理必须随缓存往返:缓存命中路径丢失会让刚保存的 // 生日 / 个人频道在重新打开资料时归零(与 bot/premium 列同一坑位)。 BirthdayDay int `json:"birthday_day,omitempty"` @@ -82,6 +83,7 @@ func baseValueFromUser(u domain.User) userBaseValue { PremiumUntil: u.PremiumUntil, EmojiStatusDocumentID: u.EmojiStatusDocumentID, EmojiStatusUntil: u.EmojiStatusUntil, + EmojiStatusCollectible: u.EmojiStatusCollectible, BirthdayDay: u.Birthday.Day, BirthdayMonth: u.Birthday.Month, BirthdayYear: u.Birthday.Year, @@ -98,23 +100,24 @@ func baseValueFromUser(u domain.User) userBaseValue { func (v userBaseValue) user() domain.User { return domain.User{ - ID: v.ID, - AccessHash: v.AccessHash, - Phone: v.Phone, - FirstName: v.FirstName, - LastName: v.LastName, - About: v.About, - Username: v.Username, - CountryCode: v.CountryCode, - Verified: v.Verified, - Support: v.Support, - Bot: v.Bot, - BotInfoVersion: v.BotInfoVersion, - PremiumUntil: v.PremiumUntil, - EmojiStatusDocumentID: v.EmojiStatusDocumentID, - EmojiStatusUntil: v.EmojiStatusUntil, - Birthday: domain.Birthday{Day: v.BirthdayDay, Month: v.BirthdayMonth, Year: v.BirthdayYear}, - PersonalChannelID: v.PersonalChannelID, + ID: v.ID, + AccessHash: v.AccessHash, + Phone: v.Phone, + FirstName: v.FirstName, + LastName: v.LastName, + About: v.About, + Username: v.Username, + CountryCode: v.CountryCode, + Verified: v.Verified, + Support: v.Support, + Bot: v.Bot, + BotInfoVersion: v.BotInfoVersion, + PremiumUntil: v.PremiumUntil, + EmojiStatusDocumentID: v.EmojiStatusDocumentID, + EmojiStatusUntil: v.EmojiStatusUntil, + EmojiStatusCollectible: v.EmojiStatusCollectible, + Birthday: domain.Birthday{Day: v.BirthdayDay, Month: v.BirthdayMonth, Year: v.BirthdayYear}, + PersonalChannelID: v.PersonalChannelID, Color: domain.PeerColor{ HasColor: v.ColorSet, Color: v.Color, diff --git a/internal/store/star_gift.go b/internal/store/star_gift.go index 6e843f23..957bf4d0 100644 --- a/internal/store/star_gift.go +++ b/internal/store/star_gift.go @@ -31,6 +31,9 @@ type StarGiftStore interface { UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) UniqueByID(ctx context.Context, uniqueGiftID int64) (domain.UniqueStarGift, bool, error) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64) (map[int64]domain.UniqueStarGift, error) + // ListUniqueByOwner returns active, locally owned collectibles in a bounded + // stable order. Exported/burned/transferred gifts are deliberately excluded. + ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) // Create 写一条收到的礼物实例,返回行 id;频道礼物未显式给 saved_id 时以该行 id 作为 saved_id。 Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) diff --git a/internal/store/user.go b/internal/store/user.go index fe2eabfa..c55f509d 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -27,9 +27,9 @@ type UserStore interface { // SweepExpiredPremium 把到期(premium_expires_at <= now)的会员行清空并 // 返回清理后的用户(供推送 updateUser);单次最多处理 limit 行。 SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) - // UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除, - // until=0 表示永久)。 - UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) + // UpdateEmojiStatus 更新用户自定义 emoji status。零值清除;collectible + // 必须是完整且与 DocumentID 一致的不可变快照。 + UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) // UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。 UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) @@ -37,6 +37,13 @@ type UserStore interface { UpdatePersonalChannel(ctx context.Context, userID int64, channelID int64) (domain.User, error) } +// UserEmojiStatusEventStore is the aggregate write boundary used by the +// account RPC in durable deployments. The user snapshot, pts event and online +// dispatch row must commit or roll back together. +type UserEmojiStatusEventStore interface { + UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, error) +} + // UserCache 缓存 viewer 无关的 users 表基础资料。 // 联系人备注、隐私裁剪、头像选择和 presence 不应写入该缓存。 type UserCache interface {