feat: sync monoforum and collectible emoji status
Sync telesrv bd15657 (feat(account): implement collectible emoji status). Skipped telesrv docs changes per public sync rules.
This commit is contained in:
parent
edb7057757
commit
0c99ae0a9d
91 changed files with 4061 additions and 693 deletions
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE channel_messages DROP COLUMN IF EXISTS suggested_post;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
61
deploy/migrations/0114_collectible_emoji_status.down.sql
Normal file
61
deploy/migrations/0114_collectible_emoji_status.down.sql
Normal file
|
|
@ -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)
|
||||
);
|
||||
166
deploy/migrations/0114_collectible_emoji_status.up.sql
Normal file
166
deploy/migrations/0114_collectible_emoji_status.up.sql
Normal file
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
34
internal/app/stargifts/collectible_emoji_status_test.go
Normal file
34
internal/app/stargifts/collectible_emoji_status_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 列持久化)。
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -604,6 +604,10 @@ type ChannelMessage struct {
|
|||
SendAs *Peer
|
||||
// SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。
|
||||
SavedPeer Peer
|
||||
// SuggestedPost 是频道私信建议投稿的不可变发送快照;普通频道消息为 nil。
|
||||
SuggestedPost *SuggestedPost
|
||||
// PaidMessageStars 是本条频道私信实际扣除的 Stars;管理员免费回复及普通频道消息为 0。
|
||||
PaidMessageStars int64
|
||||
Date int
|
||||
EditDate int
|
||||
Post bool
|
||||
|
|
@ -1459,6 +1463,14 @@ type SendMonoforumMessageRequest struct {
|
|||
IdempotencyPreflighted bool
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
ReplyTo *MessageReply
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
SuggestedPost *SuggestedPost
|
||||
// AllowPaidStars 是客户端授权的最高可扣金额;实际扣款取频道当前价格,绝不按授权上限扣款。
|
||||
AllowPaidStars int64
|
||||
ClearDraft bool
|
||||
Date int
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ var (
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -101,6 +101,28 @@ 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
|
||||
|
|
@ -113,6 +135,7 @@ type DialogDraft struct {
|
|||
ReplyTo *MessageReply
|
||||
WebPage *DialogDraftWebPage
|
||||
Effect int64
|
||||
SuggestedPost *SuggestedPost
|
||||
RichMessage *MessageRichMessage
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package domain
|
|||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ const (
|
|||
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
|
||||
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
|
||||
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
|
||||
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
|
||||
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
|
||||
)
|
||||
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ const (
|
|||
// UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL
|
||||
// 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。
|
||||
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)。
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 表示永久。
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
134
internal/rpc/account_collectible_emoji_status_test.go
Normal file
134
internal/rpc/account_collectible_emoji_status_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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,9 +161,19 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
|
|||
if len(req.Entities) > maxMessageEntityCount {
|
||||
return domain.DialogDraft{}, limitInvalidErr()
|
||||
}
|
||||
if !req.SuggestedPost.Zero() {
|
||||
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 {
|
||||
return domain.DialogDraft{}, err
|
||||
|
|
@ -192,6 +204,7 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
|
|||
ReplyTo: replyTo,
|
||||
WebPage: webpage,
|
||||
Effect: req.Effect,
|
||||
SuggestedPost: suggestedPost,
|
||||
RichMessage: richMessage,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
73
internal/rpc/messages_suggested_post.go
Normal file
73
internal/rpc/messages_suggested_post.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,13 +24,19 @@ 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 {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{} {
|
||||
|
|
|
|||
|
|
@ -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,6 +49,55 @@ 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())
|
||||
}
|
||||
|
|
@ -53,13 +108,22 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 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 子会话内的重发消息。
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ type ChannelStore struct {
|
|||
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 水位)。
|
||||
|
|
@ -135,6 +137,8 @@ func NewChannelStore() *ChannelStore {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 媒体标签页。
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -78,11 +178,17 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
|
|||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 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{
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,7 +598,7 @@ 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,
|
||||
|
|
@ -605,6 +615,7 @@ func contactFromFields(id, accessHash int64, phone, firstName, lastName, usernam
|
|||
PremiumUntil: premiumUntil,
|
||||
EmojiStatusDocumentID: emojiStatusDocumentID,
|
||||
EmojiStatusUntil: emojiStatusUntil,
|
||||
EmojiStatusCollectible: mustDecodeEmojiStatusCollectible(emojiCollectibleID, emojiCollectibleJSON),
|
||||
LastSeenAt: lastSeenAt,
|
||||
Contact: true,
|
||||
Mutual: mutual,
|
||||
|
|
@ -646,6 +657,8 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
|
|||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
emojiCollectibleID *int64
|
||||
emojiCollectibleJSON []byte
|
||||
lastSeenAt int32
|
||||
)
|
||||
if err := row.Scan(
|
||||
|
|
@ -669,6 +682,8 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
|
|||
&premiumUntil,
|
||||
&emojiStatusDocID,
|
||||
&emojiStatusUntil,
|
||||
&emojiCollectibleID,
|
||||
&emojiCollectibleJSON,
|
||||
&lastSeenAt,
|
||||
); err != nil {
|
||||
return domain.Contact{}, err
|
||||
|
|
@ -677,7 +692,7 @@ 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) {
|
||||
|
|
@ -702,6 +717,8 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
|
|||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
emojiCollectibleID *int64
|
||||
emojiCollectibleJSON []byte
|
||||
lastSeenAt int32
|
||||
)
|
||||
if err := row.Scan(
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
54
internal/store/postgres/emoji_status_codec_test.go
Normal file
54
internal/store/postgres/emoji_status_codec_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -344,6 +344,7 @@ func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event do
|
|||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
EmojiStatusPayload: []byte("{}"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("append delete messages event: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -669,6 +669,7 @@ func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.M
|
|||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
EmojiStatusPayload: []byte("{}"),
|
||||
MessageBoxID: &boxID,
|
||||
PeerType: &peerType,
|
||||
PeerID: &peerID,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 *;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -102,6 +104,8 @@ type GetContactRow struct {
|
|||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -190,6 +198,8 @@ type ListContactsByUserRow struct {
|
|||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -305,6 +319,8 @@ type UpdateContactNoteRow struct {
|
|||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -455,6 +475,8 @@ type UpsertContactRow struct {
|
|||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
ReverseMutualChanged bool
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2144,6 +2144,8 @@ type User struct {
|
|||
DeletionSource string
|
||||
DeletionReason string
|
||||
AccountDeleteAt pgtype.Timestamptz
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
}
|
||||
|
||||
type UserBusinessProfile struct {
|
||||
|
|
@ -2246,6 +2248,7 @@ type UserUpdateEvent struct {
|
|||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
EventPhone string
|
||||
EmojiStatusPayload []byte
|
||||
}
|
||||
|
||||
type UserUpdateRetention struct {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 +52,16 @@ 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
|
||||
)
|
||||
`
|
||||
|
||||
|
|
@ -79,6 +81,7 @@ type AppendUserUpdateEventParams struct {
|
|||
FolderPeers []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
EmojiStatusPayload []byte
|
||||
MessageBoxID *int32
|
||||
PeerType *string
|
||||
PeerID *int64
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,10 @@ 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),
|
||||
|
|
@ -227,6 +231,7 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
|
|||
FolderPeers: folderPeers,
|
||||
StoryPayload: storyPayload,
|
||||
ReactionPayload: reactionPayload,
|
||||
EmojiStatusPayload: emojiStatusPayload,
|
||||
MaxID: pgInt32NonNegative(event.MaxID),
|
||||
StillUnreadCount: int32(event.StillUnreadCount),
|
||||
ChannelPts: int32(event.ChannelPts),
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
|
@ -134,6 +135,7 @@ 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,
|
||||
|
|
@ -150,6 +152,7 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon
|
|||
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),
|
||||
|
|
@ -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{
|
||||
// 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: documentID,
|
||||
EmojiStatusUntil: int64(until),
|
||||
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,6 +535,7 @@ 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,
|
||||
|
|
@ -460,6 +552,7 @@ func userFromModel(r sqlcgen.User) domain.User {
|
|||
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),
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ type monoforumSendFingerprintPayload struct {
|
|||
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
|
||||
|
|
@ -160,6 +166,12 @@ func MonoforumSendFingerprint(req domain.SendMonoforumMessageRequest) ([]byte, e
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ type userBaseValue struct {
|
|||
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,
|
||||
|
|
@ -113,6 +115,7 @@ func (v userBaseValue) user() domain.User {
|
|||
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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue