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:
A 2026-07-19 20:37:10 +08:00
parent edb7057757
commit 0c99ae0a9d
91 changed files with 4061 additions and 693 deletions

View file

@ -0,0 +1 @@
ALTER TABLE channel_messages DROP COLUMN IF EXISTS suggested_post;

View file

@ -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;

View file

@ -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;

View file

@ -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);

View 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)
);

View 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)
);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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)
}
}

View file

@ -368,11 +368,8 @@ func (s *Service) materializeCollectibleAttributes(ctx context.Context, attribut
ID: documentID, AccessHash: accessHash, FileReference: fileReference, ID: documentID, AccessHash: accessHash, FileReference: fileReference,
Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker", Date: int(time.Now().Unix()), MimeType: "application/x-tgsticker",
Size: int64(len(animation.TGS)), DCID: s.dc, Size: int64(len(animation.TGS)), DCID: s.dc,
Attributes: []domain.DocumentAttribute{ Attributes: collectibleDocumentAttributes(attributes[i].Kind),
{Kind: domain.DocAttrImageSize, W: 512, H: 512}, Thumbs: collectibleDocumentThumbs(attributes[i].Kind),
{Kind: domain.DocAttrSticker, Alt: "🎁"},
{Kind: domain.DocAttrFilename, FileName: string(attributes[i].Kind) + ".tgs"},
},
} }
attributes[i].Blob = &domain.FileBlob{ attributes[i].Blob = &domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", documentID), Backend: domain.MediaBackend(s.blobs.Name()), 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 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) { func (s *Service) CollectiblePreview(ctx context.Context, giftID int64) (domain.StarGiftUpgradePreview, bool, error) {
if s == nil || s.store == nil || giftID <= 0 { if s == nil || s.store == nil || giftID <= 0 {
return domain.StarGiftUpgradePreview{}, false, nil 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) 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) { func (s *Service) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error) {
if s == nil || s.upgrades == nil { if s == nil || s.upgrades == nil {
return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured") return domain.StarGiftUpgradeResult{}, fmt.Errorf("star gift upgrade store is not configured")

View file

@ -593,6 +593,20 @@ func (s *Service) RecordContactsReset(ctx context.Context, stateAuthKeyID [8]byt
}, true, excludeSessionID) }, 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 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对 // RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
// 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts // 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts
// aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。 // aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。

View file

@ -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) { func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) {
ctx := context.Background() ctx := context.Background()
authKeyID := [8]byte{4} authKeyID := [8]byte{4}

View file

@ -107,7 +107,7 @@ func TestUpdateEmojiStatusPremiumGate(t *testing.T) {
svc := NewService(store) svc := NewService(store)
// 非会员设置被拒(PREMIUM_ACCOUNT_REQUIRED)。 // 非会员设置被拒(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) 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 { if _, err := store.SetPremiumUntil(ctx, u.ID, int(time.Now().Add(time.Hour).Unix())); err != nil {
t.Fatalf("grant: %v", err) 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 { if err != nil || set.EmojiStatusDocumentID != 42 {
t.Fatalf("premium set = %+v err %v, want document 42", set, err) t.Fatalf("premium set = %+v err %v, want document 42", set, err)
} }
if _, err := store.SetPremiumUntil(ctx, u.ID, 0); err != nil { if _, err := store.SetPremiumUntil(ctx, u.ID, 0); err != nil {
t.Fatalf("downgrade: %v", err) 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 { if err != nil || cleared.EmojiStatusDocumentID != 0 {
t.Fatalf("clear after downgrade = %+v err %v, want cleared", cleared, err) t.Fatalf("clear after downgrade = %+v err %v, want cleared", cleared, err)
} }

View file

@ -375,17 +375,14 @@ func (s *Service) SweepExpiredPremium(ctx context.Context, now int64, limit int)
return users, nil 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) { func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
self, err := s.loadSelf(ctx, userID) self, err := s.validateEmojiStatusUpdate(ctx, userID, status)
if err != nil { if err != nil {
return domain.User{}, err return domain.User{}, err
} }
if documentID != 0 && !self.PremiumActiveAt(time.Now().Unix()) { u, err := s.users.UpdateEmojiStatus(ctx, self.ID, status)
return domain.User{}, domain.ErrPremiumRequired
}
u, err := s.users.UpdateEmojiStatus(ctx, self.ID, documentID, until)
if err != nil { if err != nil {
return domain.User{}, err return domain.User{}, err
} }
@ -393,6 +390,52 @@ func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentI
return u, nil 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 表示清除。 // UpdateBirthday 设置/清除用户生日(account.updateBirthday)。零值 Birthday 表示清除。
func (s *Service) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { func (s *Service) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
self, err := s.loadSelf(ctx, userID) 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 s.cache != nil {
if cached, err := s.cache.GetByIDs(ctx, ids); err == nil && len(cached) > 0 { if cached, err := s.cache.GetByIDs(ctx, ids); err == nil && len(cached) > 0 {
for id, u := range cached { for id, u := range cached {
if u.ID != 0 { if u.ID != 0 && u.EmojiStatusCollectible.Empty() {
loaded[id] = u loaded[id] = u
} }
} }
@ -561,7 +604,18 @@ func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) {
if s.cache == nil || len(users) == 0 { if s.cache == nil || len(users) == 0 {
return 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) { func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) {

View file

@ -27,7 +27,7 @@ type BotsService interface {
} }
type UsersService 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 { type WebAppService interface {
@ -661,7 +661,7 @@ func (h *handler) setUserEmojiStatus(w http.ResponseWriter, r *http.Request, bot
} }
until = n 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) { if errors.Is(err, domain.ErrPremiumRequired) {
writeAPIError(w, http.StatusBadRequest, "PREMIUM_ACCOUNT_REQUIRED") writeAPIError(w, http.StatusBadRequest, "PREMIUM_ACCOUNT_REQUIRED")
return return

View file

@ -603,17 +603,21 @@ type ChannelMessage struct {
From Peer From Peer
SendAs *Peer SendAs *Peer
// SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。 // SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。
SavedPeer Peer SavedPeer Peer
Date int // SuggestedPost 是频道私信建议投稿的不可变发送快照;普通频道消息为 nil。
EditDate int SuggestedPost *SuggestedPost
Post bool // PaidMessageStars 是本条频道私信实际扣除的 Stars;管理员免费回复及普通频道消息为 0。
Silent bool PaidMessageStars int64
NoForwards bool Date int
Body string EditDate int
Entities []MessageEntity Post bool
ReplyTo *MessageReply Silent bool
Forward *MessageForward NoForwards bool
ViaBotID int64 Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
ViaBotID int64
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
GroupedID int64 GroupedID int64
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
@ -1459,7 +1463,15 @@ type SendMonoforumMessageRequest struct {
IdempotencyPreflighted bool IdempotencyPreflighted bool
Message string Message string
Entities []MessageEntity Entities []MessageEntity
Date int Media *MessageMedia
ReplyTo *MessageReply
Silent bool
NoForwards bool
SuggestedPost *SuggestedPost
// AllowPaidStars 是客户端授权的最高可扣金额;实际扣款取频道当前价格,绝不按授权上限扣款。
AllowPaidStars int64
ClearDraft bool
Date int
} }
// ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one // ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one
@ -1579,6 +1591,8 @@ type SendChannelMessageResult struct {
Event ChannelUpdateEvent Event ChannelUpdateEvent
Recipients []int64 Recipients []int64
Duplicate bool Duplicate bool
// SenderStarsBalance 仅在实际发生 paid-message 借记时返回;RPC 只向发件人投影余额更新。
SenderStarsBalance *StarsBalance
// ReplayDeleteEvent is the existing durable channel delete event paired // ReplayDeleteEvent is the existing durable channel delete event paired
// with a deleted exact-random_id replay. It must be returned only to the // 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. // caller echo and must never be fanned out as a fresh event.

View file

@ -6,37 +6,38 @@ import (
) )
var ( var (
ErrChannelInvalid = errors.New("channel invalid") ErrChannelInvalid = errors.New("channel invalid")
ErrChannelPrivate = errors.New("channel private") ErrChannelPrivate = errors.New("channel private")
ErrChannelTitleInvalid = errors.New("channel title invalid") ErrChannelTitleInvalid = errors.New("channel title invalid")
ErrChannelUserBanned = errors.New("user banned in channel") ErrChannelUserBanned = errors.New("user banned in channel")
ErrChannelWriteForbidden = errors.New("chat write forbidden") ErrChannelWriteForbidden = errors.New("chat write forbidden")
ErrChannelAdminRequired = errors.New("chat admin required") ErrChannelAdminRequired = errors.New("chat admin required")
ErrChannelNotModified = errors.New("chat not modified") ErrChannelNotModified = errors.New("chat not modified")
ErrChannelForumMissing = errors.New("channel forum missing") ErrChannelForumMissing = errors.New("channel forum missing")
ErrLinkNotModified = errors.New("discussion link not modified") ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported")
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed") ErrLinkNotModified = errors.New("discussion link not modified")
ErrBroadcastIDInvalid = errors.New("broadcast id invalid") ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
ErrMegagroupIDInvalid = errors.New("megagroup id invalid") ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden") ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
ErrChatPublicRequired = errors.New("chat public required") ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
ErrChannelUserCreator = errors.New("channel user creator") ErrChatPublicRequired = errors.New("chat public required")
ErrChannelRightForbidden = errors.New("channel right forbidden") ErrChannelUserCreator = errors.New("channel user creator")
ErrPersistentTimestamp = errors.New("persistent timestamp invalid") ErrChannelRightForbidden = errors.New("channel right forbidden")
ErrInviteHashEmpty = errors.New("invite hash empty") ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
ErrInviteHashInvalid = errors.New("invite hash invalid") ErrInviteHashEmpty = errors.New("invite hash empty")
ErrInviteHashExpired = errors.New("invite hash expired") ErrInviteHashInvalid = errors.New("invite hash invalid")
ErrInvitePermanent = errors.New("chat invite permanent") ErrInviteHashExpired = errors.New("invite hash expired")
ErrInviteRevokedMissing = errors.New("invite revoked missing") ErrInvitePermanent = errors.New("chat invite permanent")
ErrInviteRequestSent = errors.New("invite request sent") ErrInviteRevokedMissing = errors.New("invite revoked missing")
ErrHideRequesterMissing = errors.New("hide requester missing") ErrInviteRequestSent = errors.New("invite request sent")
ErrUsersTooMuch = errors.New("users too much") ErrHideRequesterMissing = errors.New("hide requester missing")
ErrUserAlreadyParticipant = errors.New("user already participant") ErrUsersTooMuch = errors.New("users too much")
ErrUserKicked = errors.New("user kicked") ErrUserAlreadyParticipant = errors.New("user already participant")
ErrUserNotParticipant = errors.New("user not participant") ErrUserKicked = errors.New("user kicked")
ErrBotGroupsBlocked = errors.New("bot groups blocked") ErrUserNotParticipant = errors.New("user not participant")
ErrReactionInvalid = errors.New("reaction invalid") ErrBotGroupsBlocked = errors.New("bot groups blocked")
ErrReactionsTooMany = errors.New("reactions too many") ErrReactionInvalid = errors.New("reaction invalid")
ErrReactionsTooMany = errors.New("reactions too many")
) )
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation. // SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.

View file

@ -101,19 +101,42 @@ type DialogDraftWebPage struct {
Optional bool 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. // DialogDraft is a cloud draft for one peer/topic, expressed only in domain types.
type DialogDraft struct { type DialogDraft struct {
Peer Peer Peer Peer
TopMessageID int TopMessageID int
Date int Date int
NoWebpage bool NoWebpage bool
InvertMedia bool InvertMedia bool
Message string Message string
Entities []MessageEntity Entities []MessageEntity
ReplyTo *MessageReply ReplyTo *MessageReply
WebPage *DialogDraftWebPage WebPage *DialogDraftWebPage
Effect int64 Effect int64
RichMessage *MessageRichMessage SuggestedPost *SuggestedPost
RichMessage *MessageRichMessage
} }
// Empty reports whether this draft should clear the cloud draft slot. // Empty reports whether this draft should clear the cloud draft slot.
@ -126,6 +149,7 @@ func (d DialogDraft) Empty() bool {
(d.ReplyTo == nil || replyOnlyTopic) && (d.ReplyTo == nil || replyOnlyTopic) &&
d.WebPage == nil && d.WebPage == nil &&
d.Effect == 0 && d.Effect == 0 &&
d.SuggestedPost == nil &&
d.RichMessage.IsZero() d.RichMessage.IsZero()
} }

View file

@ -238,6 +238,29 @@ type UniqueStarGift struct {
CreatedAt time.Time 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 type StarGiftCurrency string
const ( const (

View file

@ -3,6 +3,7 @@ package domain
import ( import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"strconv" "strconv"
) )
@ -31,8 +32,9 @@ const (
StarsReasonGiftAuction StarsTransactionReason = "gift_auction" StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade" StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details" StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
) )
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。 // StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
@ -98,6 +100,17 @@ var (
ErrStarsInvalidAmount = errors.New("stars: invalid amount") 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)编码为客户端不透明字符串。 // EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。
func EncodeStarsCursor(id int64) string { func EncodeStarsCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))

View file

@ -29,8 +29,11 @@ const (
UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked" UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked"
// UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL // UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL
// 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。 // 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。
UpdateEventUserPhone UpdateEventType = "user_phone" UpdateEventUserPhone UpdateEventType = "user_phone"
UpdateEventDeleteMessages UpdateEventType = "delete_messages" // 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(私聊置顶/取消 // UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消
// 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。 // 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。
// TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。 // TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。
@ -81,6 +84,7 @@ type UpdateEvent struct {
Peers []Peer Peers []Peer
Bool bool Bool bool
Phone string Phone string
EmojiStatus UserEmojiStatus
Settings PeerSettings Settings PeerSettings
MessageIDs []int MessageIDs []int
MaxID int MaxID int
@ -127,6 +131,7 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventPeerSettings, UpdateEventPeerSettings,
UpdateEventPeerStoryBlocked, UpdateEventPeerStoryBlocked,
UpdateEventUserPhone, UpdateEventUserPhone,
UpdateEventUserEmojiStatus,
UpdateEventDialogFilter, UpdateEventDialogFilter,
UpdateEventDialogFilterOrder, UpdateEventDialogFilterOrder,
UpdateEventDialogFilters, UpdateEventDialogFilters,

View file

@ -16,6 +16,73 @@ type PeerColor struct {
BackgroundEmojiID int64 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. // Empty reports whether no explicit color/profile color state is set.
func (c PeerColor) Empty() bool { func (c PeerColor) Empty() bool {
return !c.HasColor && c.BackgroundEmojiID == 0 return !c.HasColor && c.BackgroundEmojiID == 0
@ -47,9 +114,11 @@ type User struct {
PremiumUntil int PremiumUntil int
// EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status // EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status
//(premium 专属,account.updateEmojiStatus)。DocumentID==0 表示未设置; //(premium 专属,account.updateEmojiStatus)。DocumentID==0 表示未设置;
// Until==0 表示永久。 // Until==0 表示永久。EmojiStatusCollectible 非零时 DocumentID 必须等于
EmojiStatusDocumentID int64 // collectible 的 model document id。
EmojiStatusUntil int EmojiStatusDocumentID int64
EmojiStatusUntil int
EmojiStatusCollectible EmojiStatusCollectible
// Birthday 是用户公开生日(account.updateBirthday)。零值表示未设置。 // Birthday 是用户公开生日(account.updateBirthday)。零值表示未设置。
Birthday Birthday Birthday Birthday
// PersonalChannelID 是资料页展示的「个人频道」(account.updatePersonalChannel); // PersonalChannelID 是资料页展示的「个人频道」(account.updatePersonalChannel);
@ -86,12 +155,21 @@ func (u User) PremiumActiveAt(now int64) bool {
// (已设置且未过期;Until==0 表示永久)。emoji status 是 premium 专属,到期 // (已设置且未过期;Until==0 表示永久)。emoji status 是 premium 专属,到期
// 降级后即便列仍有残值也不再下发。 // 降级后即便列仍有残值也不再下发。
func (u User) EmojiStatusActiveAt(now int64) bool { 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 false
} }
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now 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 // DeletedTombstone strips every viewer-dependent or personally identifying
// field while preserving the immutable id and lifecycle audit facts. // field while preserving the immutable id and lifecycle audit facts.
func (u User) DeletedTombstone() User { func (u User) DeletedTombstone() User {

View file

@ -119,11 +119,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
Hash) Hash)
}) })
registerRPC[*tg.AccountGetCollectibleEmojiStatusesRequest](d, tlprofile.SemanticMethodAccountGetCollectibleEmojiStatuses, func(ctx context.Context, layerRequest *tg.AccountGetCollectibleEmojiStatusesRequest) (any, error) { registerRPC[*tg.AccountGetCollectibleEmojiStatusesRequest](d, tlprofile.SemanticMethodAccountGetCollectibleEmojiStatuses, func(ctx context.Context, layerRequest *tg.AccountGetCollectibleEmojiStatusesRequest) (any, error) {
hash := layerRequest. return r.onAccountGetCollectibleEmojiStatuses(ctx, layerRequest.Hash)
Hash
_ = hash
return tdesktop.CollectibleEmojiStatuses(), nil
}) })
registerRPC[*tg.AccountGetDefaultGroupPhotoEmojisRequest](d, tlprofile.SemanticMethodAccountGetDefaultGroupPhotoEmojis, func(ctx context.Context, layerRequest *tg.AccountGetDefaultGroupPhotoEmojisRequest) (any, error) { registerRPC[*tg.AccountGetDefaultGroupPhotoEmojisRequest](d, tlprofile.SemanticMethodAccountGetDefaultGroupPhotoEmojis, func(ctx context.Context, layerRequest *tg.AccountGetDefaultGroupPhotoEmojisRequest) (any, error) {
hash := layerRequest. hash := layerRequest.
@ -1611,10 +1607,10 @@ func (r *Router) onAccountUpdatePersonalChannel(ctx context.Context, channel tg.
return true, nil return true, nil
} }
// onAccountUpdateEmojiStatus 持久化用户自定义 emoji status(premium 专属)。 // onAccountUpdateEmojiStatus persists either a normal custom emoji or a
// emojiStatusEmpty 与未支持的 collectible 类型按清除处理(collectible 依赖 // complete collectible snapshot. Collectibles must still be locally owned by
// Stars 礼物模型,范围外,记兼容矩阵);变更经 updateUserEmojiStatus 推给 // the actor; unsupported constructors are rejected instead of being mistaken
// 本人全部在线 session(self user 对象同时携带最新 emoji_status 字段)。 // for a clear operation.
func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.EmojiStatusClass) (bool, error) { func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.EmojiStatusClass) (bool, error) {
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
@ -1624,33 +1620,105 @@ func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.Emoji
if !ok { if !ok {
return true, nil // 服务未接通(精简测试装配)时保持旧 stub 语义 return true, nil // 服务未接通(精简测试装配)时保持旧 stub 语义
} }
var documentID int64 value, err := r.domainUserEmojiStatus(ctx, userID, status)
var until int if err != nil {
if s, ok := status.(*tg.EmojiStatus); ok { return false, err
documentID = s.DocumentID }
if v, ok := s.GetUntil(); ok { var (
until = v 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 err != nil {
if errors.Is(err, domain.ErrPremiumRequired) { if errors.Is(err, domain.ErrPremiumRequired) {
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED") return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")
} }
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
return false, tgerr400("COLLECTIBLE_INVALID")
}
return false, internalErr() return false, internalErr()
} }
r.invalidateRPCProjectionForUser(u.ID) r.invalidateRPCProjectionForUser(u.ID)
r.pushUserUpdates(ctx, u.ID, &tg.Updates{ update := &tg.UpdateUserEmojiStatus{UserID: u.ID, EmojiStatus: tgUserEmojiStatusValue(value)}
Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{ if durableWrite {
UserID: u.ID, if sessionID != 0 {
EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()), r.bookkeepAuxPtsForCurrentSession(ctx, event)
}}, }
Users: []tg.UserClass{r.tgSelfUser(u)}, r.pushUserUpdatesIfNoReliableDispatch(ctx, u.ID, &tg.Updates{
Date: int(r.clock.Now().Unix()), 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 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 或资料页背景色。 // onAccountUpdateColor 持久化当前用户的消息 accent 或资料页背景色。
// 普通 peerColor 可清除(color flag absent)、可显式设置 color=0;collectible // 普通 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 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) { func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) {
if u.ID == 0 { if u.ID == 0 {
return return

View 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)
}
}

View file

@ -422,7 +422,7 @@ func (r *Router) onBotsUpdateUserEmojiStatus(ctx context.Context, req *tg.BotsUp
if !ok { if !ok {
return false, userPermissionDeniedErr() 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 err != nil {
if errors.Is(err, domain.ErrPremiumRequired) { if errors.Is(err, domain.ErrPremiumRequired) {
return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED") return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED")

View file

@ -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 表示无排除)。 // skipDeliverySet 把 SkipDeliveryUserIDs 切片转成查找集合(nil 表示无排除)。
func skipDeliverySet(ids []int64) map[int64]struct{} { func skipDeliverySet(ids []int64) map[int64]struct{} {
if len(ids) == 0 { if len(ids) == 0 {

View file

@ -669,6 +669,8 @@ func channelInvalidErr(err error) error {
return tgerr400("CHAT_WRITE_FORBIDDEN") return tgerr400("CHAT_WRITE_FORBIDDEN")
case errors.Is(err, domain.ErrChannelAdminRequired): case errors.Is(err, domain.ErrChannelAdminRequired):
return tgerr400("CHAT_ADMIN_REQUIRED") return tgerr400("CHAT_ADMIN_REQUIRED")
case errors.Is(err, domain.ErrChannelMonoforumUnsupported):
return tgerr400("CHANNEL_MONOFORUM_UNSUPPORTED")
case errors.Is(err, domain.ErrUserAlreadyParticipant): case errors.Is(err, domain.ErrUserAlreadyParticipant):
return tgerr400("USER_ALREADY_PARTICIPANT") return tgerr400("USER_ALREADY_PARTICIPANT")
case errors.Is(err, domain.ErrReplyMessageIDInvalid): case errors.Is(err, domain.ErrReplyMessageIDInvalid):

View file

@ -82,7 +82,7 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
return nil return nil
} }
peer := &tg.PeerChannel{ChannelID: m.ChannelID} 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) from := tg.PeerClass(nil)
if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 { if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 {
from = tgPeer(*m.SendAs) from = tgPeer(*m.SendAs)
@ -139,6 +139,12 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
// 频道私信(monoforum):saved_peer_id 让客户端把消息归入对应订阅者子会话。 // 频道私信(monoforum):saved_peer_id 让客户端把消息归入对应订阅者子会话。
msg.SetSavedPeerID(tgPeer(m.SavedPeer)) 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 { if m.Pinned {
msg.SetPinned(true) msg.SetPinned(true)
} }

View file

@ -165,6 +165,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass {
if rich := mustTGRichMessage(d.RichMessage); rich != nil { if rich := mustTGRichMessage(d.RichMessage); rich != nil {
out.SetRichMessage(*rich) out.SetRichMessage(*rich)
} }
if suggested, ok := tgSuggestedPost(d.SuggestedPost); ok {
out.SetSuggestedPost(suggested)
}
return out return out
} }

View file

@ -237,6 +237,11 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
return nil return nil
} }
return &tg.UpdateUserPhone{UserID: event.UserID, Phone: event.Phone} 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: case domain.UpdateEventChannelState:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil return nil

View file

@ -85,9 +85,35 @@ func tgUserEmojiStatus(u domain.User, now int64) tg.EmojiStatusClass {
if !u.EmojiStatusActiveAt(now) { if !u.EmojiStatusActiveAt(now) {
return &tg.EmojiStatusEmpty{} return &tg.EmojiStatusEmpty{}
} }
status := &tg.EmojiStatus{DocumentID: u.EmojiStatusDocumentID} return tgUserEmojiStatusValue(u.EmojiStatus())
if u.EmojiStatusUntil > 0 { }
status.SetUntil(u.EmojiStatusUntil)
// 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 return status
} }

View file

@ -298,7 +298,14 @@ type UserIdentityService interface {
type UserPremiumService interface { type UserPremiumService interface {
GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error) GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error)
SweepExpiredPremium(ctx context.Context, now int64, limit 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 // 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) 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 抽象通讯录查询。 // ContactsService 抽象通讯录查询。
type ContactsService interface { type ContactsService interface {
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) 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) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
UniqueByID(ctx context.Context, uniqueGiftID int64) (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) 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) Upgrade(ctx context.Context, req domain.StarGiftUpgradeRequest) (domain.StarGiftUpgradeResult, error)
UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error) UpgradeReceipt(ctx context.Context, userID int64, commandKey string) (domain.StarGiftUpgradeReceipt, bool, error)
RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error)

View file

@ -129,6 +129,10 @@ func effectIDInvalidErr() error { return tgerr.New(400, "EFFECT_ID_INVALID") }
func paymentUnsupportedErr() error { return tgerr.New(406, "PAYMENT_UNSUPPORTED") } 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 balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") }
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") } func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }

View file

@ -3,10 +3,12 @@ package rpc
import ( import (
"context" "context"
"errors" "errors"
"unicode/utf8"
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"go.uber.org/zap" "go.uber.org/zap"
"telesrv/internal/domain" "telesrv/internal/domain"
"unicode/utf8"
) )
func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDraftRequest) (bool, error) { func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDraftRequest) (bool, error) {
@ -159,8 +161,18 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
if len(req.Entities) > maxMessageEntityCount { if len(req.Entities) > maxMessageEntityCount {
return domain.DialogDraft{}, limitInvalidErr() return domain.DialogDraft{}, limitInvalidErr()
} }
if !req.SuggestedPost.Zero() { suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
return domain.DialogDraft{}, suggestedPostPeerInvalidErr() 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) replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo)
if err != nil { if err != nil {
@ -182,17 +194,18 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
topMessageID = replyTo.TopMessageID topMessageID = replyTo.TopMessageID
} }
return domain.DialogDraft{ return domain.DialogDraft{
Peer: peer, Peer: peer,
TopMessageID: topMessageID, TopMessageID: topMessageID,
Date: date, Date: date,
NoWebpage: req.NoWebpage, NoWebpage: req.NoWebpage,
InvertMedia: req.InvertMedia, InvertMedia: req.InvertMedia,
Message: req.Message, Message: req.Message,
Entities: domainMessageEntities(req.Entities), Entities: domainMessageEntities(req.Entities),
ReplyTo: replyTo, ReplyTo: replyTo,
WebPage: webpage, WebPage: webpage,
Effect: req.Effect, Effect: req.Effect,
RichMessage: richMessage, SuggestedPost: suggestedPost,
RichMessage: richMessage,
}, nil }, nil
} }

View file

@ -103,8 +103,8 @@ func (r *Router) monoforumSavedHistory(ctx context.Context, userID int64, mono d
}, nil }, nil
} }
// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身(直接投影,管理员 // monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身直接投影,
// 非其成员故不能走可见性受限的 GetChannels)+ 母广播频道(管理员是其成员)。 // 再按 viewer 补母广播频道。订阅者没有 monoforum member row,管理员身份也只来自母频道。
func (r *Router) monoforumChats(ctx context.Context, userID int64, mono domain.Channel) []tg.ChatClass { func (r *Router) monoforumChats(ctx context.Context, userID int64, mono domain.Channel) []tg.ChatClass {
chats := []tg.ChatClass{tgChannelChatForView(userID, domain.ChannelView{Channel: mono})} chats := []tg.ChatClass{tgChannelChatForView(userID, domain.ChannelView{Channel: mono})}
if mono.LinkedMonoforumID != 0 && r.deps.Channels != nil { 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) return r.tgUsers(found)
} }
// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否带 monoforum_peer_id(频道私信发送的唯一标志)。 // monoforumReplyPresent 判断 sendMessage 的 reply_to 是否显式携带 monoforum_peer_id。
// 普通发送恒不带,故据此 gate monoforum 分支,普通发送热路径零额外成本。 // 管理员回复必须带目标订阅者;普通订阅者按官方 TDesktop 行为不携带 reply_to,目标由调用者推导。
func monoforumReplyPresent(input tg.InputReplyToClass) bool { func monoforumReplyPresent(input tg.InputReplyToClass) bool {
switch v := input.(type) { switch v := input.(type) {
case *tg.InputReplyToMonoForum: case *tg.InputReplyToMonoForum:
@ -189,46 +189,75 @@ func (r *Router) monoforumReplyTargetPeer(userID int64, input tg.InputReplyToCla
return r.domainPeerFromInputPeer(userID, inputPeer) 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)发送:订阅者发到自己的子会话,管理员回复到目标订阅者。 // sendMonoforumMessage 处理向频道私信(monoforum)发送:订阅者发到自己的子会话,管理员回复到目标订阅者。
// saved_peer 来自 reply_to 的 monoforum_peer_id;管理员可写任意订阅者子会话,普通订阅者只能写自己的。 // saved_peer 对订阅者由调用者推导、对管理员来自 reply_to;管理员可写任意订阅者子会话,订阅者只能写自己的。
func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSendMessageRequest, fingerprint []byte, preflighted bool) (tg.UpdatesClass, error) { 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 { if r.deps.Channels == nil {
return nil, notImplementedErr() return nil, notImplementedErr()
} }
if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
return nil, peerIDInvalidErr() return nil, peerIDInvalidErr()
} }
mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID) if mono.ID != peer.ID || !mono.Monoforum || req.SavedPeer.Type != domain.PeerTypeUser || req.SavedPeer.ID == 0 {
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 {
return nil, replyToMonoforumPeerInvalidErr() return nil, replyToMonoforumPeerInvalidErr()
} }
if !isAdmin && savedPeer.ID != userID { if !isAdmin && req.SavedPeer.ID != userID {
// 普通订阅者只能写自己的子会话,不能写他人的。 // 普通订阅者只能写自己的子会话,不能写他人的。
return nil, replyToMonoforumPeerInvalidErr() return nil, replyToMonoforumPeerInvalidErr()
} }
res, err := r.deps.Channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ req.MonoforumID = mono.ID
MonoforumID: mono.ID, req.SenderUserID = userID
SenderUserID: userID, if req.Date == 0 {
SavedPeer: savedPeer, req.Date = int(r.clock.Now().Unix())
RandomID: req.RandomID, }
IdempotencyFingerprint: fingerprint, res, err := r.deps.Channels.SendMonoforumMessage(ctx, req)
IdempotencyPreflighted: preflighted,
Message: req.Message,
Entities: domainMessageEntities(req.Entities),
Date: int(r.clock.Now().Unix()),
})
if err != nil { if err != nil {
return nil, messageSendErr(err) 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 // 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} newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID}
} }
updates = append(updates, newMsg) 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()) date := int(r.clock.Now().Unix())
if res.Duplicate && res.ReplayDeleteEvent != nil { if res.Duplicate && res.ReplayDeleteEvent != nil {
if deleted := tgChannelUpdate(userID, *res.ReplayDeleteEvent); deleted != 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, 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
}

View file

@ -2,6 +2,7 @@ package rpc
import ( import (
"context" "context"
"strings"
"testing" "testing"
"github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/bin"
@ -10,6 +11,7 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels" appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users" appusers "telesrv/internal/app/users"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store/memory" "telesrv/internal/store/memory"
@ -17,7 +19,7 @@ import (
// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经 // TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经
// getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史 // getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史
// (消息带 saved_peer_id);非管理员被拒。 // (消息带 saved_peer_id);订阅者经普通 getHistory 只看自己的子会话。
func TestMonoforumSavedDialogsAndHistory(t *testing.T) { func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userStore := memory.NewUserStore() userStore := memory.NewUserStore()
@ -98,12 +100,31 @@ func TestMonoforumSavedDialogsAndHistory(t *testing.T) {
if !seenChats[monoID] || !seenChats[created.Channel.ID] { if !seenChats[monoID] || !seenChats[created.Channel.ID] {
t.Fatalf("main monoforum chats = %+v, want monoforum %d and parent %d", seenChats, monoID, created.Channel.ID) t.Fatalf("main monoforum chats = %+v, want monoforum %d and parent %d", seenChats, monoID, created.Channel.ID)
} }
var deniedRaw bin.Buffer var subscriberRaw bin.Buffer
if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&deniedRaw); err != nil { if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&subscriberRaw); err != nil {
t.Fatalf("encode non-admin getHistory(monoforum): %v", err) t.Fatalf("encode non-admin getHistory(monoforum): %v", err)
} }
if _, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &deniedRaw); err == nil { subscriberEnc, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &subscriberRaw)
t.Fatalf("non-admin getHistory(monoforum) = nil err, want denied") 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, // TestMonoforumSendMessageWritePath 验证写侧:订阅者按 TDesktop 实际请求仅以
// reply_to=InputReplyToMonoForum{自己}) 发私信;管理员回复到目标订阅者;订阅者不能写他人子会话; // peer=monoforum 发到自己的子会话;管理员必须显式指定目标订阅者;suggested_post 被持久化返回;
// 普通发送(无 monoforum_peer_id)不受影响。 // 订阅者不能写他人子会话。
func TestMonoforumSendMessageWritePath(t *testing.T) { func TestMonoforumSendMessageWritePath(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userStore := memory.NewUserStore() userStore := memory.NewUserStore()
@ -196,39 +217,160 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
channelStore := memory.NewChannelStore() channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore) channelSvc := appchannels.NewService(channelStore)
dialogSvc := appdialogs.NewService(memory.NewDialogStore(), channelStore)
r := New(Config{}, Deps{ r := New(Config{}, Deps{
Users: appusers.NewService(userStore), Users: appusers.NewService(userStore),
Channels: channelSvc, Channels: channelSvc,
Dialogs: dialogSvc,
}, zaptest.NewLogger(t), clock.System) }, zaptest.NewLogger(t), clock.System)
created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000}) created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000})
if err != nil { if err != nil {
t.Fatalf("create channel: %v", err) 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 { if err != nil {
t.Fatalf("enable DM: %v", err) t.Fatalf("enable DM: %v", err)
} }
monoID := enabled.Channel.LinkedMonoforumID 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 := &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) subUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq)
if err != nil { if err != nil {
t.Fatalf("subscriber sendMessage(monoforum): %v", err) 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) 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 := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "admin reply", RandomID: 556}
adminReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}}) adminReply := &tg.InputReplyToMessage{ReplyToMsgID: subMessageID}
if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq); err != nil { 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) 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)的子会话。 // 订阅者不能写他人(owner)的子会话。
sneaky := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "sneaky", RandomID: 557} 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") 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 := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}}
hreq.SetParentPeer(monoInput) hreq.SetParentPeer(monoInput)
hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq) hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq)
@ -248,11 +390,54 @@ func TestMonoforumSendMessageWritePath(t *testing.T) {
if !ok { if !ok {
t.Fatalf("getSavedHistory = %T, want *tg.MessagesMessagesSlice", hres) t.Fatalf("getSavedHistory = %T, want *tg.MessagesMessagesSlice", hres)
} }
if len(slice.Messages) != 2 { if len(slice.Messages) != 3 {
t.Fatalf("history = %d msgs, want 2 (sub + admin)", len(slice.Messages)) t.Fatalf("history = %d msgs, want 3 (sub text + admin + sub media)", len(slice.Messages))
} }
top, ok := slice.Messages[0].(*tg.Message) top, ok := slice.Messages[0].(*tg.Message)
if !ok || top.Message != "admin reply" { if !ok || top.Message != "media suggestion" {
t.Fatalf("history[0] = %#v, want newest 'admin reply'", slice.Messages[0]) 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)
} }
} }

View file

@ -3,10 +3,12 @@ package rpc
import ( import (
"context" "context"
"errors" "errors"
"github.com/iamxvbaba/td/tg"
"strings" "strings"
"telesrv/internal/domain"
"unicode/utf8" "unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
) )
func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) { 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() sendErr = internalErr()
return nil, sendErr return nil, sendErr
} }
// 频道私信(monoforum):仅当 reply_to 带 monoforum_peer_id 时走专用发送路径(普通发送恒不带, suggestedInput, hasSuggestedPost := req.GetSuggestedPost()
// 故此 gate 对普通发送零额外成本)。peer 解析为 monoforum 频道时按订阅者子会话发送。 // monoforum 普通用户发送不带 reply_to,saved_peer 必须由服务端推导为自己;管理员回复才必须
if monoforumReplyPresent(req.ReplyTo) { // 显式携带 monoforum_peer_id。仅凭 reply_to 判路由会把用户请求误送进普通 megagroup 路径。
savedPeer, valid := r.monoforumReplyTargetPeer(userID, req.ReplyTo) var mono domain.Channel
if !valid || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 { var monoforum, monoforumAdmin bool
sendErr = replyToMonoforumPeerInvalidErr() 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 return nil, sendErr
} }
replay, err := r.lookupChannelSendReplay(ctx, userID, peer.ID, savedPeer, req.RandomID, idempotencyFingerprint) 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 { if replay.found {
duplicate = true duplicate = true
if req.ClearDraft {
r.clearDraftAfterSend(ctx, userID, peer, replyTo)
}
return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil return r.monoforumSendUpdates(ctx, userID, replay.channel.Channel, savedPeer, replay.channel), nil
} }
if err := r.checkSendRateLimit(ctx, userID, 1); err != 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 sendErr = err
return nil, sendErr 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 { if err != nil {
sendErr = err sendErr = err
return nil, sendErr return nil, sendErr
} }
return updates, nil return updates, nil
} }
if req.AllowPaidStars > 0 {
sendErr = paymentUnsupportedErr()
return nil, sendErr
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint) replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil { if err != nil {
sendErr = err sendErr = err
@ -201,7 +250,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
} }
func messageSendErr(err error) error { func messageSendErr(err error) error {
var paymentRequired *domain.StarsPaymentRequiredError
switch { 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): case errors.Is(err, domain.ErrUserFrozen):
return frozenMethodInvalidErr() return frozenMethodInvalidErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid): case errors.Is(err, domain.ErrReplyMessageIDInvalid):
@ -314,10 +368,8 @@ func sendMessageUnsupportedOptionErr(req *tg.MessagesSendMessageRequest) error {
// req.Effect 不再一律拒绝:消息特效已实现,合法性在 messageEffectInvalid 单独校验。 // req.Effect 不再一律拒绝:消息特效已实现,合法性在 messageEffectInvalid 单独校验。
case req.AllowPaidStars < 0: case req.AllowPaidStars < 0:
return starsAmountInvalidErr() return starsAmountInvalidErr()
case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: case req.AllowPaidFloodskip:
return paymentUnsupportedErr() return paymentUnsupportedErr()
case !req.SuggestedPost.Zero():
return suggestedPostPeerInvalidErr()
default: default:
return nil return nil
} }

View 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
}

View file

@ -453,6 +453,8 @@ func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction {
switch t.Reason { switch t.Reason {
case domain.StarsReasonReaction: case domain.StarsReasonReaction:
item.Reaction = true item.Reaction = true
case domain.StarsReasonPaidMessage:
item.SetPaidMessages(1)
case domain.StarsReasonGift: case domain.StarsReasonGift:
item.Gift = true item.Gift = true
case domain.StarsReasonGiftUpgrade: case domain.StarsReasonGiftUpgrade:

View file

@ -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),不崩。 // deps.Stars==nil 兜底:返回合法的空 starsStatus(余额 0),不崩。
func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) { func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)

View file

@ -274,6 +274,89 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
if !ok || peer.ID == 0 { if !ok || peer.ID == 0 {
return nil, peerIDInvalidErr() 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) replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -290,6 +290,12 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, af
} }
out = append(out, channelID) 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] }) sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
if len(out) > limit { if len(out) > limit {
out = 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}) 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 }) sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
if len(out) > limit { if len(out) > limit {
out = out[:limit] out = out[:limit]
@ -331,6 +355,34 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
return out, nil 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 { func (s *ChannelStore) nextChannelIDLocked() int64 {
id := s.nextID id := s.nextID
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) { if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) {
return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil 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) { if !publicPreviewableChannel(channel) {
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate

View file

@ -17,6 +17,9 @@ func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUser
if err != nil { if err != nil {
return domain.CreateChannelResult{}, err return domain.CreateChannelResult{}, err
} }
if channel.Monoforum {
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
}
inviter := s.members[channelID][inviterUserID] inviter := s.members[channelID][inviterUserID]
if !canInviteToChannel(channel, inviter) { if !canInviteToChannel(channel, inviter) {
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired

View file

@ -109,6 +109,9 @@ func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, d
if !ok || channel.Deleted { if !ok || channel.Deleted {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid return domain.CreateChannelResult{}, domain.ErrChannelInvalid
} }
if channel.Monoforum {
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
}
preJoinTopID := channel.TopMessageID preJoinTopID := channel.TopMessageID
if existing, ok := s.members[channelID][userID]; ok { if existing, ok := s.members[channelID][userID]; ok {
if existing.Status == domain.ChannelMemberActive { if existing.Status == domain.ChannelMemberActive {
@ -1050,6 +1053,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
return member 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) { func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) {
if !publicSearchableChannel(channel) { if !publicSearchableChannel(channel) {
return 0, false return 0, false

View file

@ -34,6 +34,14 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage {
in.Discussion = cloneChannelDiscussionRef(in.Discussion) in.Discussion = cloneChannelDiscussionRef(in.Discussion)
in.Replies = cloneChannelMessageReplies(in.Replies) in.Replies = cloneChannelMessageReplies(in.Replies)
in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) 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 { if in.SendAs != nil {
p := *in.SendAs p := *in.SendAs
in.SendAs = &p in.SendAs = &p

View file

@ -24,12 +24,18 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
// 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。 // 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。
query := strings.ToLower(strings.TrimSpace(filter.Query)) query := strings.ToLower(strings.TrimSpace(filter.Query))
matched := make([]domain.ChannelMessage, 0, len(items)) matched := make([]domain.ChannelMessage, 0, len(items))
monoforumUserView := channel.Monoforum && !isChannelAdmin(member)
for _, msg := range items { for _, msg := range items {
if msg.Deleted { if msg.Deleted {
continue continue
} }
if channel.Monoforum && msg.SavedPeer.ID != 0 { if channel.Monoforum {
continue if monoforumUserView && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) {
continue
}
if !monoforumUserView && msg.SavedPeer.ID != 0 {
continue
}
} }
if msg.ID <= member.AvailableMinID { if msg.ID <= member.AvailableMinID {
continue continue

View file

@ -316,13 +316,21 @@ func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendRepla
Message: cloneChannelMessage(replay), Message: cloneChannelMessage(replay),
SenderUserID: first.SenderUserID, SenderUserID: first.SenderUserID,
} }
return domain.SendChannelMessageResult{ result := domain.SendChannelMessageResult{
Channel: cloneChannel(channel), Channel: cloneChannel(channel),
Message: cloneChannelMessage(replay), Message: cloneChannelMessage(replay),
Event: event, Event: event,
Duplicate: true, Duplicate: true,
ReplayDeleteEvent: replayDelete, 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{} { func channelDeliverySkipSet(ids []int64) map[int64]struct{} {

View file

@ -10,13 +10,19 @@ import (
"telesrv/internal/store" "telesrv/internal/store"
) )
const paidMessageChannelCommissionPermille int64 = 850
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 // 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) { func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || 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 return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
if req.AllowPaidStars < 0 {
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
}
var fingerprint []byte var fingerprint []byte
var err error var err error
if req.RandomID != 0 { if req.RandomID != 0 {
@ -43,23 +49,81 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
if !ok || channel.Deleted || !channel.Monoforum { if !ok || channel.Deleted || !channel.Monoforum {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid 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 { if req.Date == 0 {
req.Date = int(time.Now().Unix()) req.Date = int(time.Now().Unix())
} }
pts := s.nextChannelPtsLocked(req.MonoforumID) pts := s.nextChannelPtsLocked(req.MonoforumID)
msgID := s.nextChannelMessageIDLocked(req.MonoforumID) msgID := s.nextChannelMessageIDLocked(req.MonoforumID)
msg := domain.ChannelMessage{ msg := domain.ChannelMessage{
ChannelID: req.MonoforumID, ChannelID: req.MonoforumID,
ID: msgID, ID: msgID,
RandomID: req.RandomID, RandomID: req.RandomID,
SenderUserID: req.SenderUserID, SenderUserID: req.SenderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, From: from,
SavedPeer: req.SavedPeer, SavedPeer: req.SavedPeer,
Date: req.Date, SuggestedPost: req.SuggestedPost,
Body: req.Message, PaidMessageStars: paidMessageStars,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Date: req.Date,
Pts: pts, 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 var sendSnapshot []byte
if req.RandomID != 0 { if req.RandomID != 0 {
var snapshotErr error var snapshotErr error
@ -78,6 +142,10 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
SenderUserID: req.SenderUserID, SenderUserID: req.SenderUserID,
} }
s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg) 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 { if req.RandomID != 0 {
replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID} replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID}
s.sendSnapshots[replayKey] = sendSnapshot s.sendSnapshots[replayKey] = sendSnapshot
@ -87,7 +155,13 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
channel.TopMessageID = msgID channel.TopMessageID = msgID
channel.Pts = pts channel.Pts = pts
s.channels[req.MonoforumID] = channel 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 子会话内的重发消息。 // findMonoforumDuplicateLocked 按 (sender, saved_peer, random_id) 查 monoforum 子会话内的重发消息。

View file

@ -35,11 +35,14 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if m1.Message.SavedPeer != sub || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 { 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) 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 { 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) t.Fatalf("subscriber send 2: %v", err)
} }
// 管理员回复:发件人是 creator,saved_peer 仍是该订阅者(同一子会话)。 // 管理员回复:发件人是 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) 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 { 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) 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 { subscriberHist, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
t.Fatalf("subscriber main monoforum history = nil err, want denied") 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 返回原消息、不重复。 // 幂等:相同 randomID 返回原消息、不重复。
@ -84,6 +96,12 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if hist.Messages[0].Body != "reply" { if hist.Messages[0].Body != "reply" {
t.Fatalf("history[0] = %q, want newest 'reply'", hist.Messages[0].Body) 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 { for _, m := range hist.Messages {
if m.SavedPeer != sub { if m.SavedPeer != sub {
t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer) t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer)
@ -99,6 +117,40 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if subHist.Count != 3 { if subHist.Count != 3 {
t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count) 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 向两个不同订阅者发,不得互相去重。 // 去重按订阅者子会话维度:同一发件人(此处管理员)用相同 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}) 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 { if err != nil {
t.Fatalf("delete monoforum message: %v", err) 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]) 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}) 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 { 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) 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])
}
}

View file

@ -73,27 +73,29 @@ type ChannelStore struct {
messages map[int64][]domain.ChannelMessage messages map[int64][]domain.ChannelMessage
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。 // paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
paidReactions map[int64]map[int]map[int64]memoryPaidReaction paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{} msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint retention map[int64]domain.ChannelUpdateRetentionCheckpoint
adminLogs map[int64][]domain.ChannelAdminLogEvent adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter importers map[int64]map[int64]domain.ChannelInviteImporter
msgSeq map[int64]int msgSeq map[int64]int
ptsSeq map[int64]int ptsSeq map[int64]int
logSeq map[int64]int64 logSeq map[int64]int64
randomToID map[channelRandomKey]int randomToID map[channelRandomKey]int
sendSnapshots map[channelMessageReplayKey][]byte sendSnapshots map[channelMessageReplayKey][]byte
sendFingerprints map[channelMessageReplayKey][]byte sendFingerprints map[channelMessageReplayKey][]byte
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
boostSlots map[boostSlotKey]domain.PremiumBoostSlot starsBalances map[int64]int64
readMarks map[int64]channelReadWatermark channelStarsBalances map[int64]int64
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
readMarks map[int64]channelReadWatermark
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。 // topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
topicReads map[int64]map[int64]map[int]memoryTopicRead topicReads map[int64]map[int64]map[int]memoryTopicRead
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。 // polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
@ -108,35 +110,37 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
// NewChannelStore creates an in-memory ChannelStore. // NewChannelStore creates an in-memory ChannelStore.
func NewChannelStore() *ChannelStore { func NewChannelStore() *ChannelStore {
return &ChannelStore{ return &ChannelStore{
nextID: firstMemoryChannelID, nextID: firstMemoryChannelID,
nextHash: 900000000000, nextHash: 900000000000,
channels: make(map[int64]domain.Channel), channels: make(map[int64]domain.Channel),
members: make(map[int64]map[int64]domain.ChannelMember), members: make(map[int64]map[int64]domain.ChannelMember),
dialogs: make(map[int64]map[int64]domain.ChannelDialog), dialogs: make(map[int64]map[int64]domain.ChannelDialog),
topics: make(map[int64]map[int]domain.ChannelForumTopic), topics: make(map[int64]map[int]domain.ChannelForumTopic),
messages: make(map[int64][]domain.ChannelMessage), messages: make(map[int64][]domain.ChannelMessage),
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction), reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction), paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction), top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction), recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag), savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention), mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int), msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}), msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent), events: make(map[int64][]domain.ChannelUpdateEvent),
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint), retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent), adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite), invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter), importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
msgSeq: make(map[int64]int), msgSeq: make(map[int64]int),
ptsSeq: make(map[int64]int), ptsSeq: make(map[int64]int),
logSeq: make(map[int64]int64), logSeq: make(map[int64]int64),
randomToID: make(map[channelRandomKey]int), randomToID: make(map[channelRandomKey]int),
sendSnapshots: make(map[channelMessageReplayKey][]byte), sendSnapshots: make(map[channelMessageReplayKey][]byte),
sendFingerprints: make(map[channelMessageReplayKey][]byte), sendFingerprints: make(map[channelMessageReplayKey][]byte),
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent), deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot), starsBalances: make(map[int64]int64),
readMarks: make(map[int64]channelReadWatermark), channelStarsBalances: make(map[int64]int64),
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead), boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
readMarks: make(map[int64]channelReadWatermark),
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
} }
} }

View file

@ -49,6 +49,9 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
if msg.Deleted { if msg.Deleted {
continue continue
} }
if channel.Monoforum && !isChannelAdmin(member) && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
continue
}
if msg.ID <= member.AvailableMinID { if msg.ID <= member.AvailableMinID {
continue continue
} }
@ -68,6 +71,16 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
} }
events := make([]domain.ChannelUpdateEvent, 0, limit) events := make([]domain.ChannelUpdateEvent, 0, limit)
lastPts := req.Pts 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] { for _, event := range s.events[req.ChannelID] {
if event.Pts <= req.Pts { if event.Pts <= req.Pts {
continue continue
@ -77,6 +90,12 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
if !ok { if !ok {
continue continue
} }
if channel.Monoforum && !isChannelAdmin(member) {
visible, ok = filterMonoforumEventForUser(visible, req.UserID, visibleMonoforumMessageIDs)
if !ok {
continue
}
}
if preview && visible.Type == domain.ChannelUpdateParticipant { if preview && visible.Type == domain.ChannelUpdateParticipant {
continue continue
} }
@ -121,6 +140,27 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
return diff, nil 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) { func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()

View file

@ -868,6 +868,14 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft {
draft.WebPage = &webpage draft.WebPage = &webpage
} }
draft.RichMessage = cloneRichMessage(draft.RichMessage) 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 return draft
} }

View file

@ -325,6 +325,25 @@ func (s *StarGiftStore) UniqueByIDs(_ context.Context, uniqueGiftIDs []int64) (m
return out, nil 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) { func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) {
if !validSavedStarGift(gift) { if !validSavedStarGift(gift) {
return 0, domain.ErrStarGiftInvalid return 0, domain.ErrStarGiftInvalid

View file

@ -306,19 +306,20 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int)
return out, nil return out, nil
} }
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。 // UpdateEmojiStatus 更新用户自定义 emoji status(零值表示清除)。
func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentID int64, until int) (domain.User, error) { func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok || u.Deleted { if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound return domain.User{}, domain.ErrUserNotFound
} }
if documentID == 0 { if !status.Valid() {
until = 0 return domain.User{}, domain.ErrStarGiftCollectibleInvalid
} }
u.EmojiStatusDocumentID = documentID u.EmojiStatusDocumentID = status.DocumentID
u.EmojiStatusUntil = until u.EmojiStatusUntil = status.Until
u.EmojiStatusCollectible = status.Collectible
s.byID[userID] = u s.byID[userID] = u
return u, nil return u, nil
} }

View file

@ -180,6 +180,7 @@ UPDATE users SET
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '', phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, last_seen_at = 0, verified = false, support = false, last_seen_at = 0,
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 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, color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_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, birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,

View file

@ -377,6 +377,20 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
if err != nil { if err != nil {
return nil, err 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) linkedGuests, err := s.listLinkedDiscussionGuests(ctx, s.db, viewerUserID, remaining)
if err != nil { if err != nil {
return nil, err return nil, err
@ -402,6 +416,17 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
} }
continue 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) { if !publicPreviewableChannel(channel) {
continue continue
} }

View file

@ -298,12 +298,26 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID,
limit = domain.MaxSynchronousChannelDialogFanout limit = domain.MaxSynchronousChannelDialogFanout
} }
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT channel_id WITH visible_channels AS (
FROM user_channel_member_index SELECT channel_id
WHERE user_id = $1 FROM user_channel_member_index
AND status = 'active' WHERE user_id = $1 AND status = 'active' AND NOT deleted
AND NOT deleted UNION
AND channel_id > $2 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 ORDER BY channel_id
LIMIT $3`, userID, afterChannelID, limit) LIMIT $3`, userID, afterChannelID, limit)
if err != nil { if err != nil {
@ -329,16 +343,31 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userI
limit = domain.MaxChannelDifferenceLimit limit = domain.MaxChannelDifferenceLimit
} }
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT i.channel_id, c.pts WITH visible_channels AS (
FROM user_channel_member_index i SELECT channel_id
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted FROM user_channel_member_index
JOIN channel_update_checkpoints cp ON cp.channel_id = i.channel_id WHERE user_id = $1 AND status = 'active' AND NOT deleted
WHERE i.user_id = $1 UNION
AND i.status = 'active' SELECT mono.id
AND NOT i.deleted FROM channels mono
AND i.channel_id > $3 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 AND cp.latest_event_date > $2
ORDER BY i.channel_id ASC ORDER BY visible.channel_id ASC
LIMIT $4`, userID, sinceDate, afterChannelID, limit) LIMIT $4`, userID, sinceDate, afterChannelID, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("list dirty active channels for user: %w", err) 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 { } else if ok {
return ch, member, true, nil 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) { if !publicPreviewableChannel(ch) {
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
} }

View file

@ -29,6 +29,9 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
if err != nil { if err != nil {
return domain.CreateChannelResult{}, err return domain.CreateChannelResult{}, err
} }
if channel.Monoforum {
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
}
if !canInviteToChannel(channel, inviter) { if !canInviteToChannel(channel, inviter) {
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
} }

View file

@ -478,6 +478,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
return member 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 { func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool {
return rights == domain.ChannelAdminRights{} return rights == domain.ChannelAdminRights{}
} }

View file

@ -33,6 +33,9 @@ func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64,
if err != nil { if err != nil {
return domain.CreateChannelResult{}, err return domain.CreateChannelResult{}, err
} }
if channel.Monoforum {
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
}
existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID) existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID)
if existingErr == nil { if existingErr == nil {
switch { switch {

View file

@ -33,17 +33,19 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
var richMessageJSON string var richMessageJSON string
var savedPeerType string var savedPeerType string
var savedPeerID int64 var savedPeerID int64
var suggestedPostJSON string
if err := row.Scan( if err := row.Scan(
&msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID,
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &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 { ); err != nil {
return domain.ChannelMessage{}, err return domain.ChannelMessage{}, err
} }
msg.From.Type = domain.PeerType(fromType) msg.From.Type = domain.PeerType(fromType)
msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID}
msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON)
if sendAsType.Valid && sendAsID.Valid { if sendAsType.Valid && sendAsID.Valid {
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} 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 richMessageJSON string
var savedPeerType string var savedPeerType string
var savedPeerID int64 var savedPeerID int64
var suggestedPostJSON string
if err := row.Scan( if err := row.Scan(
&msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID,
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &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 { ); err != nil {
return domain.ChannelMessage{}, 0, err return domain.ChannelMessage{}, 0, err
} }
msg.From.Type = domain.PeerType(fromType) msg.From.Type = domain.PeerType(fromType)
msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID}
msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON)
if sendAsType.Valid && sendAsID.Valid { if sendAsType.Valid && sendAsID.Valid {
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64}
} }

View file

@ -25,7 +25,12 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6
base := "channel_id = $1 AND NOT deleted" base := "channel_id = $1 AND NOT deleted"
extraChannels := []domain.Channel(nil) extraChannels := []domain.Channel(nil)
if channel.Monoforum { if channel.Monoforum {
base += " AND saved_peer_id = 0" if isChannelAdmin(member) {
base += " AND saved_peer_id = 0"
} else {
baseArgs = append(baseArgs, viewerUserID)
base += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(baseArgs))
}
if channel.LinkedMonoforumID != 0 { if channel.LinkedMonoforumID != 0 {
if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil { if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil {
extraChannels = append(extraChannels, parent) extraChannels = append(extraChannels, parent)

View file

@ -470,7 +470,16 @@ WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan(
Message: replay, Message: replay,
SenderUserID: first.SenderUserID, 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) { 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 { if err != nil {
return err return err
} }
suggestedPost, err := marshalJSON(msg.SuggestedPost, "{}")
if err != nil {
return err
}
sendSnapshot := []byte("{}") sendSnapshot := []byte("{}")
if msg.RandomID != 0 { if msg.RandomID != 0 {
sendSnapshot, err = store.EncodeChannelSendSnapshot(msg) 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, 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, 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, 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 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::jsonb,$39::bytea)`, ) 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, 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, sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards,
msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, 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) return fmt.Errorf("insert channel message: %w", err)
} }
// 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。 // 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。

View file

@ -12,14 +12,19 @@ import (
"telesrv/internal/store" "telesrv/internal/store"
) )
const paidMessageChannelCommissionPermille int64 = 850
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 // SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);发件权限(订阅者身份/管理员) // 私信消息存进 channel_messages(复用 channel pts/事件/difference);store 在写边界再次强制:订阅者
// 由 RPC 层校验,store 只校验 monoforum 频道存在,不要求发件人是成员(订阅者不是 monoforum 成员)。 // 无需成员记录但只能写自己的 saved_peer,母频道管理员可以回复任意订阅者。
func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || 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 return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
if req.AllowPaidStars < 0 {
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
}
requestFingerprint, err := store.MonoforumSendFingerprint(req) requestFingerprint, err := store.MonoforumSendFingerprint(req)
if err != nil { if err != nil {
return domain.SendChannelMessageResult{}, err return domain.SendChannelMessageResult{}, err
@ -65,6 +70,101 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
if channel.Deleted || !channel.Monoforum { if channel.Deleted || !channel.Monoforum {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid 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) msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.MonoforumID)
if err != nil { if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err) return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err)
@ -74,16 +174,22 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err) return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err)
} }
msg := domain.ChannelMessage{ msg := domain.ChannelMessage{
ChannelID: req.MonoforumID, ChannelID: req.MonoforumID,
ID: msgID, ID: msgID,
RandomID: req.RandomID, RandomID: req.RandomID,
SenderUserID: req.SenderUserID, SenderUserID: req.SenderUserID,
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, From: from,
SavedPeer: req.SavedPeer, SavedPeer: req.SavedPeer,
Date: req.Date, SuggestedPost: req.SuggestedPost,
Body: req.Message, PaidMessageStars: paidMessageStars,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Date: req.Date,
Pts: pts, 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{ event := domain.ChannelUpdateEvent{
ChannelID: req.MonoforumID, ChannelID: req.MonoforumID,
@ -130,13 +236,31 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.MonoforumID, msgID, pts); err != nil { 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) 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 { if err := tx.Commit(ctx); err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err) return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err)
} }
committed = true committed = true
channel.TopMessageID = msgID channel.TopMessageID = msgID
channel.Pts = pts 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 倒序分页。 // ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。
@ -205,9 +329,11 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
return domain.Channel{}, false, domain.ErrChannelInvalid return domain.Channel{}, false, domain.ErrChannelInvalid
} }
isAdmin := false 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 && isAdmin = member.Status == domain.ChannelMemberActive &&
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin) (member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
} else if !errors.Is(memberErr, domain.ErrChannelPrivate) {
return domain.Channel{}, false, memberErr
} }
return mono, isAdmin, nil return mono, isAdmin, nil
} }

View file

@ -3,6 +3,7 @@ package postgres
import ( import (
"context" "context"
"errors" "errors"
"slices"
"testing" "testing"
"telesrv/internal/domain" "telesrv/internal/domain"
@ -55,18 +56,46 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
channelIDs = append(channelIDs, monoID) channelIDs = append(channelIDs, monoID)
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID} 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 { if err != nil {
t.Fatalf("subscriber send 1: %v", err) t.Fatalf("subscriber send 1: %v", err)
} }
if m1.Message.SavedPeer != subPeer || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 { 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) 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 { 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) 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) 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 { 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) 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 { subscriberHist, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
t.Fatalf("subscriber main monoforum history = nil err, want denied") 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 { if err != nil {
t.Fatalf("dup send: %v", err) 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) 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} otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
@ -134,6 +182,31 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if subHist.Count != 3 { if subHist.Count != 3 {
t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count) 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 向两个不同 // 去重按订阅者子会话维度(迁移 0022 唯一索引含 saved_peer_id):管理员用相同 random_id 向两个不同
// 订阅者发,不得互相去重(与 memory 行为一致)。 // 订阅者发,不得互相去重(与 memory 行为一致)。
@ -202,6 +275,13 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit monoforum delete: %v", err) 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 var ptsBeforeReplay, eventsBeforeReplay int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsBeforeReplay); err != nil { if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsBeforeReplay); err != nil {
t.Fatalf("load monoforum pts: %v", err) 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) 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)
}
}

View file

@ -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, 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, 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, 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, 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, title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id,

View file

@ -48,6 +48,10 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
args = append(args, member.AvailableMinID) args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args)) 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) args = append(args, domain.MaxChannelDifferenceTooLongMessages)
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+` SELECT `+channelMessageColumns+`
@ -100,11 +104,15 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
if err != nil { if err != nil {
return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err) 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} diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30}
userRefs := make(map[int64]struct{}) userRefs := make(map[int64]struct{})
channelRefs := make(map[int64]struct{}) channelRefs := make(map[int64]struct{})
lastPts := req.Pts lastPts := req.Pts
type differenceEventRow struct {
event domain.ChannelUpdateEvent
messageID int
}
eventRows := make([]differenceEventRow, 0, limit)
for rows.Next() { for rows.Next() {
event, messageID, err := scanChannelEvent(rows) event, messageID, err := scanChannelEvent(rows)
if err != nil { if err != nil {
@ -131,6 +139,27 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
break break
} }
lastPts = event.Pts 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 { if messageID != 0 && event.Message.ID == 0 {
msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID) msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID)
if err != nil { if err != nil {
@ -143,6 +172,12 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
continue continue
} }
event = visibleEvent event = visibleEvent
if channel.Monoforum && !isChannelAdmin(member) {
event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
if !ok {
continue
}
}
if preview && event.Type == domain.ChannelUpdateParticipant { if preview && event.Type == domain.ChannelUpdateParticipant {
continue continue
} }
@ -156,9 +191,6 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
diff.OtherUpdates = append(diff.OtherUpdates, event) diff.OtherUpdates = append(diff.OtherUpdates, event)
} }
} }
if err := rows.Err(); err != nil {
return domain.ChannelDifference{}, err
}
if len(diff.Events) == 0 { if len(diff.Events) == 0 {
diff.Pts = lastPts diff.Pts = lastPts
} else if lastPts > diff.Pts { } else if lastPts > diff.Pts {
@ -208,6 +240,55 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
return diff, nil 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) { func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) {
var pts int var pts int
err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts) err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts)

View file

@ -87,6 +87,8 @@ SELECT
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at u.last_seen_at
FROM contacts c FROM contacts c
JOIN users u ON u.id = c.contact_user_id 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, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at u.last_seen_at
FROM contacts c FROM contacts c
JOIN users u ON u.id = c.contact_user_id 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, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at, u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed
FROM upserted c FROM upserted c
@ -342,6 +348,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
premiumUntil int64 premiumUntil int64
emojiStatusDocID int64 emojiStatusDocID int64
emojiStatusUntil int64 emojiStatusUntil int64
emojiCollectibleID *int64
emojiCollectibleJSON []byte
lastSeenAt int64 lastSeenAt int64
reverseMutualChanged bool reverseMutualChanged bool
) )
@ -366,6 +374,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
&premiumUntil, &premiumUntil,
&emojiStatusDocID, &emojiStatusDocID,
&emojiStatusUntil, &emojiStatusUntil,
&emojiCollectibleID,
&emojiCollectibleJSON,
&lastSeenAt, &lastSeenAt,
&reverseMutualChanged, &reverseMutualChanged,
); err != nil { ); err != nil {
@ -376,7 +386,7 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
if err != nil { if err != nil {
return nil, fmt.Errorf("decode contact note entities: %w", err) 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 { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate upsert contacts many: %w", err) 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 { if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) 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) { func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
@ -564,7 +574,7 @@ func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
if err != nil { if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) 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) { func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) {
@ -572,7 +582,7 @@ func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error)
if err != nil { if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) 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) { func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) {
@ -580,7 +590,7 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact,
if err != nil { if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) 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 // contactFromFields 组装 domain.Contact。getContacts 主路径(List/Get/Upsert/UpdateNote
@ -588,27 +598,28 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact,
// raw-scan 调用传 false/0——bot 无 phone 不经手机号导入,bot 加联系人走 username // raw-scan 调用传 false/0——bot 无 phone 不经手机号导入,bot 加联系人走 username
// 的单条 UpsertContact 路径(已带真实 bot 列)。premium/emoji status 列所有路径必须 // 的单条 UpsertContact 路径(已带真实 bot 列)。premium/emoji status 列所有路径必须
// 传真实值:TDesktop 对任何缺 emoji_status 字段的 user TL 一律清空本地状态。 // 传真实值: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{ return domain.Contact{
User: domain.User{ User: domain.User{
ID: id, ID: id,
AccessHash: accessHash, AccessHash: accessHash,
Phone: phone, Phone: phone,
FirstName: firstName, FirstName: firstName,
LastName: lastName, LastName: lastName,
Username: username, Username: username,
CountryCode: countryCode, CountryCode: countryCode,
Verified: verified, Verified: verified,
Support: support, Support: support,
Bot: isBot, Bot: isBot,
BotInfoVersion: botInfoVersion, BotInfoVersion: botInfoVersion,
PremiumUntil: premiumUntil, PremiumUntil: premiumUntil,
EmojiStatusDocumentID: emojiStatusDocumentID, EmojiStatusDocumentID: emojiStatusDocumentID,
EmojiStatusUntil: emojiStatusUntil, EmojiStatusUntil: emojiStatusUntil,
LastSeenAt: lastSeenAt, EmojiStatusCollectible: mustDecodeEmojiStatusCollectible(emojiCollectibleID, emojiCollectibleJSON),
Contact: true, LastSeenAt: lastSeenAt,
Mutual: mutual, Contact: true,
CloseFriend: closeFriend, Mutual: mutual,
CloseFriend: closeFriend,
}, },
FirstName: contactFirstName, FirstName: contactFirstName,
LastName: contactLastName, LastName: contactLastName,
@ -626,27 +637,29 @@ type contactScanner interface {
func scanContactRows(row contactScanner) (domain.Contact, error) { func scanContactRows(row contactScanner) (domain.Contact, error) {
var ( var (
contactUserID int64 contactUserID int64
mutual bool mutual bool
closeFriend bool closeFriend bool
contactPhone string contactPhone string
contactFirstName string contactFirstName string
contactLastName string contactLastName string
note string note string
noteEntitiesJSON string noteEntitiesJSON string
id int64 id int64
accessHash int64 accessHash int64
phone string phone string
firstName string firstName string
lastName string lastName string
username string username string
countryCode string countryCode string
verified bool verified bool
support bool support bool
premiumUntil int64 premiumUntil int64
emojiStatusDocID int64 emojiStatusDocID int64
emojiStatusUntil int64 emojiStatusUntil int64
lastSeenAt int32 emojiCollectibleID *int64
emojiCollectibleJSON []byte
lastSeenAt int32
) )
if err := row.Scan( if err := row.Scan(
&contactUserID, &contactUserID,
@ -669,6 +682,8 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
&premiumUntil, &premiumUntil,
&emojiStatusDocID, &emojiStatusDocID,
&emojiStatusUntil, &emojiStatusUntil,
&emojiCollectibleID,
&emojiCollectibleJSON,
&lastSeenAt, &lastSeenAt,
); err != nil { ); err != nil {
return domain.Contact{}, err return domain.Contact{}, err
@ -677,32 +692,34 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
if err != nil { if err != nil {
return domain.Contact{}, err 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) { func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
var ( var (
ownerUserID int64 ownerUserID int64
mutual bool mutual bool
closeFriend bool closeFriend bool
contactPhone string contactPhone string
contactFirstName string contactFirstName string
contactLastName string contactLastName string
note string note string
noteEntitiesJSON string noteEntitiesJSON string
id int64 id int64
accessHash int64 accessHash int64
phone string phone string
firstName string firstName string
lastName string lastName string
username string username string
countryCode string countryCode string
verified bool verified bool
support bool support bool
premiumUntil int64 premiumUntil int64
emojiStatusDocID int64 emojiStatusDocID int64
emojiStatusUntil int64 emojiStatusUntil int64
lastSeenAt int32 emojiCollectibleID *int64
emojiCollectibleJSON []byte
lastSeenAt int32
) )
if err := row.Scan( if err := row.Scan(
&ownerUserID, &ownerUserID,
@ -725,6 +742,8 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
&premiumUntil, &premiumUntil,
&emojiStatusDocID, &emojiStatusDocID,
&emojiStatusUntil, &emojiStatusUntil,
&emojiCollectibleID,
&emojiCollectibleJSON,
&lastSeenAt, &lastSeenAt,
); err != nil { ); err != nil {
return 0, domain.Contact{}, err return 0, domain.Contact{}, err
@ -733,7 +752,7 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
if err != nil { if err != nil {
return 0, domain.Contact{}, err 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 return ownerUserID, contact, nil
} }

View 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")
}
}

View file

@ -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)
}
}

View file

@ -331,19 +331,20 @@ func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event do
event.PtsCount = 1 event.PtsCount = 1
} }
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
UserID: event.UserID, UserID: event.UserID,
Pts: int32(event.Pts), Pts: int32(event.Pts),
PtsCount: int32(event.PtsCount), PtsCount: int32(event.PtsCount),
Date: int32(event.Date), Date: int32(event.Date),
EventType: string(domain.UpdateEventDeleteMessages), EventType: string(domain.UpdateEventDeleteMessages),
EventPeers: []byte("[]"), EventPeers: []byte("[]"),
PeerSettings: []byte("{}"), PeerSettings: []byte("{}"),
MessageIds: messageIDs, MessageIds: messageIDs,
DialogFilter: []byte("{}"), DialogFilter: []byte("{}"),
FilterOrder: []byte("[]"), FilterOrder: []byte("[]"),
FolderPeers: []byte("[]"), FolderPeers: []byte("[]"),
StoryPayload: []byte("{}"), StoryPayload: []byte("{}"),
ReactionPayload: []byte("{}"), ReactionPayload: []byte("{}"),
EmojiStatusPayload: []byte("{}"),
}); err != nil { }); err != nil {
return fmt.Errorf("append delete messages event: %w", err) return fmt.Errorf("append delete messages event: %w", err)
} }

View file

@ -656,22 +656,23 @@ func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.M
peerType := string(msg.Peer.Type) peerType := string(msg.Peer.Type)
peerID := msg.Peer.ID peerID := msg.Peer.ID
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
UserID: msg.OwnerUserID, UserID: msg.OwnerUserID,
Pts: int32(msg.Pts), Pts: int32(msg.Pts),
PtsCount: 1, PtsCount: 1,
Date: int32(msg.Date), Date: int32(msg.Date),
EventType: string(domain.UpdateEventNewMessage), EventType: string(domain.UpdateEventNewMessage),
EventPeers: []byte("[]"), EventPeers: []byte("[]"),
PeerSettings: []byte("{}"), PeerSettings: []byte("{}"),
MessageIds: []byte("[]"), MessageIds: []byte("[]"),
DialogFilter: []byte("{}"), DialogFilter: []byte("{}"),
FilterOrder: []byte("[]"), FilterOrder: []byte("[]"),
FolderPeers: []byte("[]"), FolderPeers: []byte("[]"),
StoryPayload: []byte("{}"), StoryPayload: []byte("{}"),
ReactionPayload: []byte("{}"), ReactionPayload: []byte("{}"),
MessageBoxID: &boxID, EmojiStatusPayload: []byte("{}"),
PeerType: &peerType, MessageBoxID: &boxID,
PeerID: &peerID, PeerType: &peerType,
PeerID: &peerID,
}); err != nil { }); err != nil {
return fmt.Errorf("append new message event: %w", err) return fmt.Errorf("append new message event: %w", err)
} }

View file

@ -22,6 +22,8 @@ SELECT
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at u.last_seen_at
FROM contacts c FROM contacts c
JOIN users u ON u.id = c.contact_user_id JOIN users u ON u.id = c.contact_user_id
@ -52,6 +54,8 @@ SELECT
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at u.last_seen_at
FROM contacts c FROM contacts c
JOIN users u ON u.id = c.contact_user_id JOIN users u ON u.id = c.contact_user_id
@ -130,6 +134,8 @@ SELECT
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at, u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
FROM upserted c FROM upserted c
@ -168,6 +174,8 @@ SELECT
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at u.last_seen_at
FROM updated c FROM updated c
JOIN users u ON u.id = c.contact_user_id; JOIN users u ON u.id = c.contact_user_id;

View file

@ -37,6 +37,8 @@ WITH matched AS (
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.color_set, u.color_set,
u.color, u.color,
u.color_background_emoji_id, u.color_background_emoji_id,
@ -86,6 +88,8 @@ SELECT
premium_expires_at, premium_expires_at,
emoji_status_document_id, emoji_status_document_id,
emoji_status_until, emoji_status_until,
emoji_status_collectible_id,
emoji_status_collectible,
color_set, color_set,
color, color,
color_background_emoji_id, color_background_emoji_id,
@ -165,6 +169,8 @@ RETURNING *;
UPDATE users UPDATE users
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint, SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
emoji_status_until = sqlc.arg(emoji_status_until)::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() updated_at = now()
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;

View file

@ -15,6 +15,7 @@ INSERT INTO user_update_events (
folder_peers, folder_peers,
story_payload, story_payload,
reaction_payload, reaction_payload,
emoji_status_payload,
message_box_id, message_box_id,
peer_type, peer_type,
peer_id, peer_id,
@ -40,6 +41,7 @@ INSERT INTO user_update_events (
sqlc.arg(folder_peers)::jsonb, sqlc.arg(folder_peers)::jsonb,
sqlc.arg(story_payload)::jsonb, sqlc.arg(story_payload)::jsonb,
sqlc.arg(reaction_payload)::jsonb, sqlc.arg(reaction_payload)::jsonb,
sqlc.arg(emoji_status_payload)::jsonb,
sqlc.narg(message_box_id), sqlc.narg(message_box_id),
sqlc.narg(peer_type)::text, sqlc.narg(peer_type)::text,
sqlc.narg(peer_id)::bigint, sqlc.narg(peer_id)::bigint,
@ -68,6 +70,7 @@ SELECT
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_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_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id, COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id, e.filter_id,
@ -341,6 +344,7 @@ SELECT
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_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_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id, COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id, e.filter_id,

View file

@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
const insertBotUser = `-- name: InsertBotUser :one const insertBotUser = `-- name: InsertBotUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
VALUES ($1, '', $2, '', $3, '', TRUE, 1) 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 { type InsertBotUserParams struct {
@ -213,6 +213,8 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }

View file

@ -67,6 +67,8 @@ SELECT
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at u.last_seen_at
FROM contacts c FROM contacts c
JOIN users u ON u.id = c.contact_user_id JOIN users u ON u.id = c.contact_user_id
@ -80,29 +82,31 @@ type GetContactParams struct {
} }
type GetContactRow struct { type GetContactRow struct {
ContactUserID int64 ContactUserID int64
Mutual bool Mutual bool
CloseFriend bool CloseFriend bool
ContactPhone string ContactPhone string
ContactFirstName string ContactFirstName string
ContactLastName string ContactLastName string
Note string Note string
NoteEntitiesJson string NoteEntitiesJson string
ID int64 ID int64
AccessHash int64 AccessHash int64
Phone string Phone string
FirstName string FirstName string
LastName string LastName string
Username string Username string
CountryCode string CountryCode string
Verified bool Verified bool
Support bool Support bool
IsBot bool IsBot bool
BotInfoVersion int32 BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64 EmojiStatusDocumentID int64
EmojiStatusUntil int64 EmojiStatusUntil int64
LastSeenAt int64 EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LastSeenAt int64
} }
func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) { func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) {
@ -131,6 +135,8 @@ func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetCont
&i.PremiumExpiresAt, &i.PremiumExpiresAt,
&i.EmojiStatusDocumentID, &i.EmojiStatusDocumentID,
&i.EmojiStatusUntil, &i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LastSeenAt, &i.LastSeenAt,
) )
return i, err return i, err
@ -160,6 +166,8 @@ SELECT
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at u.last_seen_at
FROM contacts c FROM contacts c
JOIN users u ON u.id = c.contact_user_id JOIN users u ON u.id = c.contact_user_id
@ -168,29 +176,31 @@ ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u
` `
type ListContactsByUserRow struct { type ListContactsByUserRow struct {
ContactUserID int64 ContactUserID int64
Mutual bool Mutual bool
CloseFriend bool CloseFriend bool
ContactPhone string ContactPhone string
ContactFirstName string ContactFirstName string
ContactLastName string ContactLastName string
Note string Note string
NoteEntitiesJson string NoteEntitiesJson string
ID int64 ID int64
AccessHash int64 AccessHash int64
Phone string Phone string
FirstName string FirstName string
LastName string LastName string
Username string Username string
CountryCode string CountryCode string
Verified bool Verified bool
Support bool Support bool
IsBot bool IsBot bool
BotInfoVersion int32 BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64 EmojiStatusDocumentID int64
EmojiStatusUntil int64 EmojiStatusUntil int64
LastSeenAt int64 EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LastSeenAt int64
} }
func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) { func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) {
@ -225,6 +235,8 @@ func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListC
&i.PremiumExpiresAt, &i.PremiumExpiresAt,
&i.EmojiStatusDocumentID, &i.EmojiStatusDocumentID,
&i.EmojiStatusUntil, &i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LastSeenAt, &i.LastSeenAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@ -270,6 +282,8 @@ SELECT
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at u.last_seen_at
FROM updated c FROM updated c
JOIN users u ON u.id = c.contact_user_id JOIN users u ON u.id = c.contact_user_id
@ -283,29 +297,31 @@ type UpdateContactNoteParams struct {
} }
type UpdateContactNoteRow struct { type UpdateContactNoteRow struct {
ContactUserID int64 ContactUserID int64
Mutual bool Mutual bool
CloseFriend bool CloseFriend bool
ContactPhone string ContactPhone string
ContactFirstName string ContactFirstName string
ContactLastName string ContactLastName string
Note string Note string
NoteEntitiesJson string NoteEntitiesJson string
ID int64 ID int64
AccessHash int64 AccessHash int64
Phone string Phone string
FirstName string FirstName string
LastName string LastName string
Username string Username string
CountryCode string CountryCode string
Verified bool Verified bool
Support bool Support bool
IsBot bool IsBot bool
BotInfoVersion int32 BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64 EmojiStatusDocumentID int64
EmojiStatusUntil int64 EmojiStatusUntil int64
LastSeenAt int64 EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
LastSeenAt int64
} }
func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) { func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) {
@ -339,6 +355,8 @@ func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNotePa
&i.PremiumExpiresAt, &i.PremiumExpiresAt,
&i.EmojiStatusDocumentID, &i.EmojiStatusDocumentID,
&i.EmojiStatusUntil, &i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LastSeenAt, &i.LastSeenAt,
) )
return i, err return i, err
@ -416,6 +434,8 @@ SELECT
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at, u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
FROM upserted c FROM upserted c
@ -433,30 +453,32 @@ type UpsertContactParams struct {
} }
type UpsertContactRow struct { type UpsertContactRow struct {
ContactUserID int64 ContactUserID int64
Mutual bool Mutual bool
CloseFriend bool CloseFriend bool
ContactPhone string ContactPhone string
ContactFirstName string ContactFirstName string
ContactLastName string ContactLastName string
Note string Note string
NoteEntitiesJson string NoteEntitiesJson string
ID int64 ID int64
AccessHash int64 AccessHash int64
Phone string Phone string
FirstName string FirstName string
LastName string LastName string
Username string Username string
CountryCode string CountryCode string
Verified bool Verified bool
Support bool Support bool
IsBot bool IsBot bool
BotInfoVersion int32 BotInfoVersion int32
PremiumExpiresAt pgtype.Timestamptz PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64 EmojiStatusDocumentID int64
EmojiStatusUntil int64 EmojiStatusUntil int64
LastSeenAt int64 EmojiStatusCollectibleID *int64
ReverseMutualChanged bool EmojiStatusCollectible []byte
LastSeenAt int64
ReverseMutualChanged bool
} }
func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) { func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) {
@ -493,6 +515,8 @@ func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (U
&i.PremiumExpiresAt, &i.PremiumExpiresAt,
&i.EmojiStatusDocumentID, &i.EmojiStatusDocumentID,
&i.EmojiStatusUntil, &i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.LastSeenAt, &i.LastSeenAt,
&i.ReverseMutualChanged, &i.ReverseMutualChanged,
) )

View file

@ -2144,6 +2144,8 @@ type User struct {
DeletionSource string DeletionSource string
DeletionReason string DeletionReason string
AccountDeleteAt pgtype.Timestamptz AccountDeleteAt pgtype.Timestamptz
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
} }
type UserBusinessProfile struct { type UserBusinessProfile struct {
@ -2219,33 +2221,34 @@ type UserTopReaction struct {
} }
type UserUpdateEvent struct { type UserUpdateEvent struct {
UserID int64 UserID int64
Pts int32 Pts int32
PtsCount int32 PtsCount int32
Date int32 Date int32
EventType string EventType string
MessageBoxID *int32 MessageBoxID *int32
PeerType *string PeerType *string
PeerID *int64 PeerID *int64
MaxID int32 MaxID int32
StillUnreadCount int32 StillUnreadCount int32
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
EventBool bool EventBool bool
EventPeers []byte EventPeers []byte
PeerSettings []byte PeerSettings []byte
MessageIds []byte MessageIds []byte
DialogFilter []byte DialogFilter []byte
FilterOrder []byte FilterOrder []byte
FolderPeers []byte FolderPeers []byte
FilterID int32 FilterID int32
TagsEnabled bool TagsEnabled bool
ChannelPts int32 ChannelPts int32
FolderID int32 FolderID int32
QuickReplies []byte QuickReplies []byte
QuickReplyMessage []byte QuickReplyMessage []byte
StoryPayload []byte StoryPayload []byte
ReactionPayload []byte ReactionPayload []byte
EventPhone string EventPhone string
EmojiStatusPayload []byte
} }
type UserUpdateRetention struct { type UserUpdateRetention struct {

View file

@ -14,7 +14,7 @@ import (
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) 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 { type CreateUserParams struct {
@ -72,12 +72,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one 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) { 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.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
const getUserByPhone = `-- name: GetUserByPhone :one 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) { 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.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
const getUserByUsername = `-- name: GetUserByUsername :one 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) { 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.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
const getUsersByIDs = `-- name: GetUsersByIDs :many 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 FROM users
WHERE id = ANY($1::bigint[]) WHERE id = ANY($1::bigint[])
ORDER BY id ORDER BY id
@ -261,6 +269,8 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -273,7 +283,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
} }
const getUsersByPhones = `-- name: GetUsersByPhones :many 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 FROM users
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
ORDER BY id ORDER BY id
@ -322,6 +332,8 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -351,6 +363,8 @@ WITH matched AS (
u.premium_expires_at, u.premium_expires_at,
u.emoji_status_document_id, u.emoji_status_document_id,
u.emoji_status_until, u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.color_set, u.color_set,
u.color, u.color,
u.color_background_emoji_id, u.color_background_emoji_id,
@ -400,6 +414,8 @@ SELECT
premium_expires_at, premium_expires_at,
emoji_status_document_id, emoji_status_document_id,
emoji_status_until, emoji_status_until,
emoji_status_collectible_id,
emoji_status_collectible,
color_set, color_set,
color, color,
color_background_emoji_id, color_background_emoji_id,
@ -438,6 +454,8 @@ type SearchUsersRow struct {
PremiumExpiresAt pgtype.Timestamptz PremiumExpiresAt pgtype.Timestamptz
EmojiStatusDocumentID int64 EmojiStatusDocumentID int64
EmojiStatusUntil int64 EmojiStatusUntil int64
EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
ColorSet bool ColorSet bool
Color int32 Color int32
ColorBackgroundEmojiID int64 ColorBackgroundEmojiID int64
@ -480,6 +498,8 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea
&i.PremiumExpiresAt, &i.PremiumExpiresAt,
&i.EmojiStatusDocumentID, &i.EmojiStatusDocumentID,
&i.EmojiStatusUntil, &i.EmojiStatusUntil,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
&i.ColorSet, &i.ColorSet,
&i.Color, &i.Color,
&i.ColorBackgroundEmojiID, &i.ColorBackgroundEmojiID,
@ -505,7 +525,7 @@ UPDATE users
SET premium_expires_at = $1::timestamptz, SET premium_expires_at = $1::timestamptz,
updated_at = now() updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL 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 { type SetUserPremiumUntilParams struct {
@ -550,6 +570,8 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -559,7 +581,7 @@ UPDATE users
SET verified = $1::boolean, SET verified = $1::boolean,
updated_at = now() updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL 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 { type SetUserVerifiedParams struct {
@ -604,6 +626,8 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -620,7 +644,7 @@ WHERE id IN (
ORDER BY premium_expires_at ORDER BY premium_expires_at
LIMIT $2::int 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 { type SweepExpiredPremiumParams struct {
@ -671,6 +695,8 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -689,7 +715,7 @@ SET birthday_day = $1::int,
birthday_year = $3::int, birthday_year = $3::int,
updated_at = now() updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL 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 { type UpdateUserBirthdayParams struct {
@ -741,6 +767,8 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -752,7 +780,7 @@ SET color_set = $1::boolean,
color_background_emoji_id = $3::bigint, color_background_emoji_id = $3::bigint,
updated_at = now() updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL 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 { type UpdateUserColorParams struct {
@ -804,6 +832,8 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -812,19 +842,29 @@ const updateUserEmojiStatus = `-- name: UpdateUserEmojiStatus :one
UPDATE users UPDATE users
SET emoji_status_document_id = $1::bigint, SET emoji_status_document_id = $1::bigint,
emoji_status_until = $2::bigint, emoji_status_until = $2::bigint,
emoji_status_collectible_id = $3::bigint,
emoji_status_collectible = $4::jsonb,
updated_at = now() updated_at = now()
WHERE id = $3::bigint AND deleted_at IS NULL 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 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 { type UpdateUserEmojiStatusParams struct {
EmojiStatusDocumentID int64 EmojiStatusDocumentID int64
EmojiStatusUntil int64 EmojiStatusUntil int64
ID int64 EmojiStatusCollectibleID *int64
EmojiStatusCollectible []byte
ID int64
} }
func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmojiStatusParams) (User, error) { 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 var i User
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
@ -860,6 +900,8 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -886,7 +928,7 @@ UPDATE users
SET personal_channel_id = $1::bigint, SET personal_channel_id = $1::bigint,
updated_at = now() updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL 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 { type UpdateUserPersonalChannelParams struct {
@ -931,6 +973,8 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -940,7 +984,7 @@ UPDATE users
SET phone = $1::text, SET phone = $1::text,
updated_at = now() updated_at = now()
WHERE id = $2::bigint AND deleted_at IS NULL 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 { type UpdateUserPhoneParams struct {
@ -985,6 +1029,8 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -996,7 +1042,7 @@ SET first_name = $2,
about = $4, about = $4,
updated_at = now() updated_at = now()
WHERE id = $1 AND deleted_at IS NULL 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 { type UpdateUserProfileParams struct {
@ -1048,6 +1094,8 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -1059,7 +1107,7 @@ SET profile_color_set = $1::boolean,
profile_color_background_emoji_id = $3::bigint, profile_color_background_emoji_id = $3::bigint,
updated_at = now() updated_at = now()
WHERE id = $4::bigint AND deleted_at IS NULL 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 { type UpdateUserProfileColorParams struct {
@ -1111,6 +1159,8 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }
@ -1120,7 +1170,7 @@ UPDATE users
SET username = $2, SET username = $2,
updated_at = now() updated_at = now()
WHERE id = $1 AND deleted_at IS NULL 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 { type UpdateUserUsernameParams struct {
@ -1165,6 +1215,8 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.DeletionSource, &i.DeletionSource,
&i.DeletionReason, &i.DeletionReason,
&i.AccountDeleteAt, &i.AccountDeleteAt,
&i.EmojiStatusCollectibleID,
&i.EmojiStatusCollectible,
) )
return i, err return i, err
} }

View file

@ -26,6 +26,7 @@ INSERT INTO user_update_events (
folder_peers, folder_peers,
story_payload, story_payload,
reaction_payload, reaction_payload,
emoji_status_payload,
message_box_id, message_box_id,
peer_type, peer_type,
peer_id, peer_id,
@ -51,43 +52,45 @@ INSERT INTO user_update_events (
$13::jsonb, $13::jsonb,
$14::jsonb, $14::jsonb,
$15::jsonb, $15::jsonb,
$16, $16::jsonb,
$17::text, $17,
$18::bigint, $18::text,
$19::int, $19::bigint,
$20::int, $20::int,
$21::int, $21::int,
$22::int, $22::int,
$23::boolean, $23::int,
$24::int $24::boolean,
$25::int
) )
` `
type AppendUserUpdateEventParams struct { type AppendUserUpdateEventParams struct {
UserID int64 UserID int64
Pts int32 Pts int32
PtsCount int32 PtsCount int32
Date int32 Date int32
EventType string EventType string
EventBool bool EventBool bool
EventPhone string EventPhone string
EventPeers []byte EventPeers []byte
PeerSettings []byte PeerSettings []byte
MessageIds []byte MessageIds []byte
DialogFilter []byte DialogFilter []byte
FilterOrder []byte FilterOrder []byte
FolderPeers []byte FolderPeers []byte
StoryPayload []byte StoryPayload []byte
ReactionPayload []byte ReactionPayload []byte
MessageBoxID *int32 EmojiStatusPayload []byte
PeerType *string MessageBoxID *int32
PeerID *int64 PeerType *string
FilterID int32 PeerID *int64
MaxID int32 FilterID int32
StillUnreadCount int32 MaxID int32
ChannelPts int32 StillUnreadCount int32
TagsEnabled bool ChannelPts int32
FolderID int32 TagsEnabled bool
FolderID int32
} }
func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error { func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error {
@ -107,6 +110,7 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat
arg.FolderPeers, arg.FolderPeers,
arg.StoryPayload, arg.StoryPayload,
arg.ReactionPayload, arg.ReactionPayload,
arg.EmojiStatusPayload,
arg.MessageBoxID, arg.MessageBoxID,
arg.PeerType, arg.PeerType,
arg.PeerID, arg.PeerID,
@ -137,6 +141,7 @@ SELECT
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_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_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id, COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id, e.filter_id,
@ -274,6 +279,7 @@ type BatchListDispatchEventsRow struct {
FolderPeersJson string FolderPeersJson string
StoryPayloadJson string StoryPayloadJson string
ReactionPayloadJson string ReactionPayloadJson string
EmojiStatusPayloadJson string
EventPeerType string EventPeerType string
EventPeerID int64 EventPeerID int64
FilterID int32 FilterID int32
@ -409,6 +415,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
&i.FolderPeersJson, &i.FolderPeersJson,
&i.StoryPayloadJson, &i.StoryPayloadJson,
&i.ReactionPayloadJson, &i.ReactionPayloadJson,
&i.EmojiStatusPayloadJson,
&i.EventPeerType, &i.EventPeerType,
&i.EventPeerID, &i.EventPeerID,
&i.FilterID, &i.FilterID,
@ -788,6 +795,7 @@ SELECT
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_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_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id, COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id, e.filter_id,
@ -928,6 +936,7 @@ type ListUserUpdateEventsAfterRow struct {
FolderPeersJson string FolderPeersJson string
StoryPayloadJson string StoryPayloadJson string
ReactionPayloadJson string ReactionPayloadJson string
EmojiStatusPayloadJson string
EventPeerType string EventPeerType string
EventPeerID int64 EventPeerID int64
FilterID int32 FilterID int32
@ -1061,6 +1070,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
&i.FolderPeersJson, &i.FolderPeersJson,
&i.StoryPayloadJson, &i.StoryPayloadJson,
&i.ReactionPayloadJson, &i.ReactionPayloadJson,
&i.EmojiStatusPayloadJson,
&i.EventPeerType, &i.EventPeerType,
&i.EventPeerID, &i.EventPeerID,
&i.FilterID, &i.FilterID,

View file

@ -349,6 +349,37 @@ func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64)
return out, nil 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) { func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) {
row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value) row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value)
unique, err := scanUniqueStarGift(row) unique, err := scanUniqueStarGift(row)

View file

@ -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 { 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) 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 { 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) 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 { if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
t.Fatalf("paid transfer = %+v err %v", transferred, err) 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 // A second prepaid collectible makes craft chance exactly 1000‰. Success
// preserves the first aggregate as crafted and burns the other input. The // preserves the first aggregate as crafted and burns the other input. The

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err) t.Fatalf("migrate star gift lifecycle schema: %v", err)
} }
if status.Dirty || status.Empty || status.Version != 107 { if status.Dirty || status.Empty || status.Version != 118 {
t.Fatalf("migration status = %+v, want clean version 107", status) t.Fatalf("migration status = %+v, want clean version 118", status)
} }
} }

View file

@ -211,31 +211,36 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
if err != nil { if err != nil {
return err return err
} }
emojiStatusPayload, err := encodeEventEmojiStatus(event.EmojiStatus)
if err != nil {
return err
}
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
UserID: userID, UserID: userID,
Pts: int32(event.Pts), Pts: int32(event.Pts),
PtsCount: int32(event.PtsCount), PtsCount: int32(event.PtsCount),
Date: int32(event.Date), Date: int32(event.Date),
EventType: string(event.Type), EventType: string(event.Type),
EventBool: event.Bool, EventBool: event.Bool,
EventPhone: event.Phone, EventPhone: event.Phone,
EventPeers: peers, EventPeers: peers,
PeerSettings: settings, PeerSettings: settings,
MessageIds: messageIDs, MessageIds: messageIDs,
DialogFilter: dialogFilter, DialogFilter: dialogFilter,
FilterOrder: filterOrder, FilterOrder: filterOrder,
FolderPeers: folderPeers, FolderPeers: folderPeers,
StoryPayload: storyPayload, StoryPayload: storyPayload,
ReactionPayload: reactionPayload, ReactionPayload: reactionPayload,
MaxID: pgInt32NonNegative(event.MaxID), EmojiStatusPayload: emojiStatusPayload,
StillUnreadCount: int32(event.StillUnreadCount), MaxID: pgInt32NonNegative(event.MaxID),
ChannelPts: int32(event.ChannelPts), StillUnreadCount: int32(event.StillUnreadCount),
FilterID: pgInt32NonNegative(event.FilterID), ChannelPts: int32(event.ChannelPts),
TagsEnabled: event.TagsEnabled, FilterID: pgInt32NonNegative(event.FilterID),
FolderID: pgInt32NonNegative(event.FolderID), TagsEnabled: event.TagsEnabled,
MessageBoxID: messageID, FolderID: pgInt32NonNegative(event.FolderID),
PeerType: peerType, MessageBoxID: messageID,
PeerID: peerID, PeerType: peerType,
PeerID: peerID,
}); err != nil { }); err != nil {
return err return err
} }
@ -385,6 +390,10 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
if err != nil { if err != nil {
return nil, fmt.Errorf("decode reaction payload: %w", err) 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) media, err := decodeMessageMedia(row.MediaJson)
if err != nil { if err != nil {
return nil, fmt.Errorf("decode message media: %w", err) 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, TagsEnabled: row.TagsEnabled,
FolderID: int(row.FolderID), FolderID: int(row.FolderID),
Reaction: reaction, Reaction: reaction,
EmojiStatus: emojiStatus,
Message: domain.Message{ Message: domain.Message{
ID: int(row.MessageID), ID: int(row.MessageID),
UID: row.PrivateMessageID, UID: row.PrivateMessageID,
@ -579,6 +589,10 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
if err != nil { if err != nil {
return nil, fmt.Errorf("decode reaction payload: %w", err) 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) media, err := decodeMessageMedia(row.MediaJson)
if err != nil { if err != nil {
return nil, fmt.Errorf("decode message media: %w", err) 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, TagsEnabled: row.TagsEnabled,
FolderID: int(row.FolderID), FolderID: int(row.FolderID),
Reaction: reaction, Reaction: reaction,
EmojiStatus: emojiStatus,
Message: domain.Message{ Message: domain.Message{
ID: int(row.MessageID), ID: int(row.MessageID),
UID: row.PrivateMessageID, UID: row.PrivateMessageID,
@ -979,6 +994,31 @@ func decodeEventReaction(raw string) (*domain.MessageReaction, error) {
return decodeStoryReaction(raw) 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 { type peerSettingsJSON struct {
AddContact bool `json:"add_contact,omitempty"` AddContact bool `json:"add_contact,omitempty"`
BlockContact bool `json:"block_contact,omitempty"` BlockContact bool `json:"block_contact,omitempty"`

View file

@ -2,6 +2,7 @@ package postgres
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@ -134,27 +135,29 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon
Results: make([]domain.User, 0, len(rows)), Results: make([]domain.User, 0, len(rows)),
} }
for _, row := range rows { for _, row := range rows {
collectible := mustDecodeEmojiStatusCollectible(row.EmojiStatusCollectibleID, row.EmojiStatusCollectible)
u := domain.User{ u := domain.User{
ID: row.ID, ID: row.ID,
AccessHash: row.AccessHash, AccessHash: row.AccessHash,
Phone: row.Phone, Phone: row.Phone,
FirstName: row.FirstName, FirstName: row.FirstName,
LastName: row.LastName, LastName: row.LastName,
About: row.About, About: row.About,
Username: row.Username, Username: row.Username,
CountryCode: row.CountryCode, CountryCode: row.CountryCode,
Verified: row.Verified, Verified: row.Verified,
Support: row.Support, Support: row.Support,
Bot: row.IsBot, Bot: row.IsBot,
BotInfoVersion: int(row.BotInfoVersion), BotInfoVersion: int(row.BotInfoVersion),
PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt), PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt),
EmojiStatusDocumentID: row.EmojiStatusDocumentID, EmojiStatusDocumentID: row.EmojiStatusDocumentID,
EmojiStatusUntil: int(row.EmojiStatusUntil), EmojiStatusUntil: int(row.EmojiStatusUntil),
Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID), EmojiStatusCollectible: collectible,
ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID), Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID),
LastSeenAt: int(row.LastSeenAt), ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID),
Contact: row.Contact, LastSeenAt: int(row.LastSeenAt),
Mutual: row.Mutual, Contact: row.Contact,
Mutual: row.Mutual,
} }
if row.Contact { if row.Contact {
out.MyResults = append(out.MyResults, u) out.MyResults = append(out.MyResults, u)
@ -336,22 +339,110 @@ func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit in
return out, nil return out, nil
} }
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。 // UpdateEmojiStatus atomically replaces the complete emoji-status snapshot.
func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) { func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
row, err := s.q.UpdateUserEmojiStatus(ctx, sqlcgen.UpdateUserEmojiStatusParams{ collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status)
ID: userID, if err != nil {
EmojiStatusDocumentID: documentID, return domain.User{}, err
EmojiStatusUntil: int64(until), }
}) params := sqlcgen.UpdateUserEmojiStatusParams{
ID: userID,
EmojiStatusDocumentID: status.DocumentID,
EmojiStatusUntil: int64(status.Until),
EmojiStatusCollectibleID: collectibleID,
EmojiStatusCollectible: collectibleJSON,
}
var row sqlcgen.User
if status.Collectible.Empty() {
row, err = updateEmojiStatusRow(ctx, s.db, s.q, userID, status, params)
} else {
// Serialize selection against transfer/export/burn. RPC-level ownership
// checks are advisory; this lock is the write-boundary invariant that
// prevents a concurrent lifecycle commit from leaving a non-owned gift
// installed after its invalidation trigger already ran.
err = withTx(ctx, s.db, "update collectible emoji status", func(tx pgx.Tx) error {
row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params)
return err
})
}
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound 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 domain.User{}, fmt.Errorf("update user emoji status: %w", err)
} }
return userFromModel(row), nil 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 表示清除)。 // UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。
func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{ row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{
@ -444,32 +535,34 @@ func escapeLike(s string) string {
} }
func userFromModel(r sqlcgen.User) domain.User { func userFromModel(r sqlcgen.User) domain.User {
collectible := mustDecodeEmojiStatusCollectible(r.EmojiStatusCollectibleID, r.EmojiStatusCollectible)
u := domain.User{ u := domain.User{
ID: r.ID, ID: r.ID,
AccessHash: r.AccessHash, AccessHash: r.AccessHash,
Phone: r.Phone, Phone: r.Phone,
FirstName: r.FirstName, FirstName: r.FirstName,
LastName: r.LastName, LastName: r.LastName,
About: r.About, About: r.About,
Username: r.Username, Username: r.Username,
CountryCode: r.CountryCode, CountryCode: r.CountryCode,
Verified: r.Verified, Verified: r.Verified,
Support: r.Support, Support: r.Support,
Bot: r.IsBot, Bot: r.IsBot,
BotInfoVersion: int(r.BotInfoVersion), BotInfoVersion: int(r.BotInfoVersion),
PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt), PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt),
EmojiStatusDocumentID: r.EmojiStatusDocumentID, EmojiStatusDocumentID: r.EmojiStatusDocumentID,
EmojiStatusUntil: int(r.EmojiStatusUntil), EmojiStatusUntil: int(r.EmojiStatusUntil),
Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)}, EmojiStatusCollectible: collectible,
PersonalChannelID: r.PersonalChannelID, Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)},
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), PersonalChannelID: r.PersonalChannelID,
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
LastSeenAt: int(r.LastSeenAt), ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
Deleted: r.DeletedAt.Valid, LastSeenAt: int(r.LastSeenAt),
DeletionSource: domain.AccountDeletionSource(r.DeletionSource), Deleted: r.DeletedAt.Valid,
DeletionReason: r.DeletionReason, DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
CreatedAt: r.CreatedAt.Time, DeletionReason: r.DeletionReason,
AccountDeleteAt: r.AccountDeleteAt.Time, CreatedAt: r.CreatedAt.Time,
AccountDeleteAt: r.AccountDeleteAt.Time,
} }
if r.DeletedAt.Valid { if r.DeletedAt.Valid {
u.DeletedAt = r.DeletedAt.Time.Unix() u.DeletedAt = r.DeletedAt.Time.Unix()
@ -478,6 +571,38 @@ func userFromModel(r sqlcgen.User) domain.User {
return u 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 { func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {
return domain.PeerColor{ return domain.PeerColor{
HasColor: hasColor, HasColor: hasColor,

View file

@ -69,11 +69,17 @@ type channelSendFingerprintPayload struct {
} }
type monoforumSendFingerprintPayload struct { type monoforumSendFingerprintPayload struct {
Version int `json:"version"` Version int `json:"version"`
ChannelID int64 `json:"channel_id"` ChannelID int64 `json:"channel_id"`
SavedPeer domain.Peer `json:"saved_peer"` SavedPeer domain.Peer `json:"saved_peer"`
Message string `json:"message"` Message string `json:"message"`
Entities []domain.MessageEntity `json:"entities"` 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 // PrivateSendFingerprint returns a SHA-256 fingerprint of the original send
@ -155,11 +161,17 @@ func MonoforumSendFingerprint(req domain.SendMonoforumMessageRequest) ([]byte, e
return append([]byte(nil), req.IdempotencyFingerprint...), nil return append([]byte(nil), req.IdempotencyFingerprint...), nil
} }
payload, err := json.Marshal(monoforumSendFingerprintPayload{ payload, err := json.Marshal(monoforumSendFingerprintPayload{
Version: channelSendFingerprintVersion, Version: channelSendFingerprintVersion,
ChannelID: req.MonoforumID, ChannelID: req.MonoforumID,
SavedPeer: req.SavedPeer, SavedPeer: req.SavedPeer,
Message: req.Message, Message: req.Message,
Entities: req.Entities, Entities: req.Entities,
Media: req.Media,
ReplyTo: req.ReplyTo,
Silent: req.Silent,
NoForwards: req.NoForwards,
SuggestedPost: req.SuggestedPost,
AllowPaidStars: req.AllowPaidStars,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal monoforum send fingerprint: %w", err) return nil, fmt.Errorf("marshal monoforum send fingerprint: %w", err)

View file

@ -47,9 +47,10 @@ type userBaseValue struct {
BotInfoVersion int `json:"bot_info_version,omitempty"` BotInfoVersion int `json:"bot_info_version,omitempty"`
// premium / emoji status 同理必须随缓存往返:丢失会让缓存命中路径把 // premium / emoji status 同理必须随缓存往返:丢失会让缓存命中路径把
// 会员输出成非会员,跨路径状态漂移(与 bot 列同一坑位)。 // 会员输出成非会员,跨路径状态漂移(与 bot 列同一坑位)。
PremiumUntil int `json:"premium_until,omitempty"` PremiumUntil int `json:"premium_until,omitempty"`
EmojiStatusDocumentID int64 `json:"emoji_status_document_id,omitempty"` EmojiStatusDocumentID int64 `json:"emoji_status_document_id,omitempty"`
EmojiStatusUntil int `json:"emoji_status_until,omitempty"` EmojiStatusUntil int `json:"emoji_status_until,omitempty"`
EmojiStatusCollectible domain.EmojiStatusCollectible `json:"emoji_status_collectible,omitempty"`
// birthday / personal channel 同理必须随缓存往返:缓存命中路径丢失会让刚保存的 // birthday / personal channel 同理必须随缓存往返:缓存命中路径丢失会让刚保存的
// 生日 / 个人频道在重新打开资料时归零(与 bot/premium 列同一坑位)。 // 生日 / 个人频道在重新打开资料时归零(与 bot/premium 列同一坑位)。
BirthdayDay int `json:"birthday_day,omitempty"` BirthdayDay int `json:"birthday_day,omitempty"`
@ -82,6 +83,7 @@ func baseValueFromUser(u domain.User) userBaseValue {
PremiumUntil: u.PremiumUntil, PremiumUntil: u.PremiumUntil,
EmojiStatusDocumentID: u.EmojiStatusDocumentID, EmojiStatusDocumentID: u.EmojiStatusDocumentID,
EmojiStatusUntil: u.EmojiStatusUntil, EmojiStatusUntil: u.EmojiStatusUntil,
EmojiStatusCollectible: u.EmojiStatusCollectible,
BirthdayDay: u.Birthday.Day, BirthdayDay: u.Birthday.Day,
BirthdayMonth: u.Birthday.Month, BirthdayMonth: u.Birthday.Month,
BirthdayYear: u.Birthday.Year, BirthdayYear: u.Birthday.Year,
@ -98,23 +100,24 @@ func baseValueFromUser(u domain.User) userBaseValue {
func (v userBaseValue) user() domain.User { func (v userBaseValue) user() domain.User {
return domain.User{ return domain.User{
ID: v.ID, ID: v.ID,
AccessHash: v.AccessHash, AccessHash: v.AccessHash,
Phone: v.Phone, Phone: v.Phone,
FirstName: v.FirstName, FirstName: v.FirstName,
LastName: v.LastName, LastName: v.LastName,
About: v.About, About: v.About,
Username: v.Username, Username: v.Username,
CountryCode: v.CountryCode, CountryCode: v.CountryCode,
Verified: v.Verified, Verified: v.Verified,
Support: v.Support, Support: v.Support,
Bot: v.Bot, Bot: v.Bot,
BotInfoVersion: v.BotInfoVersion, BotInfoVersion: v.BotInfoVersion,
PremiumUntil: v.PremiumUntil, PremiumUntil: v.PremiumUntil,
EmojiStatusDocumentID: v.EmojiStatusDocumentID, EmojiStatusDocumentID: v.EmojiStatusDocumentID,
EmojiStatusUntil: v.EmojiStatusUntil, EmojiStatusUntil: v.EmojiStatusUntil,
Birthday: domain.Birthday{Day: v.BirthdayDay, Month: v.BirthdayMonth, Year: v.BirthdayYear}, EmojiStatusCollectible: v.EmojiStatusCollectible,
PersonalChannelID: v.PersonalChannelID, Birthday: domain.Birthday{Day: v.BirthdayDay, Month: v.BirthdayMonth, Year: v.BirthdayYear},
PersonalChannelID: v.PersonalChannelID,
Color: domain.PeerColor{ Color: domain.PeerColor{
HasColor: v.ColorSet, HasColor: v.ColorSet,
Color: v.Color, Color: v.Color,

View file

@ -31,6 +31,9 @@ type StarGiftStore interface {
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error) UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
UniqueByID(ctx context.Context, uniqueGiftID int64) (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) 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 写一条收到的礼物实例,返回行 id;频道礼物未显式给 saved_id 时以该行 id 作为 saved_id。
Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) Create(ctx context.Context, gift domain.SavedStarGift) (int64, error)

View file

@ -27,9 +27,9 @@ type UserStore interface {
// SweepExpiredPremium 把到期(premium_expires_at <= now)的会员行清空并 // SweepExpiredPremium 把到期(premium_expires_at <= now)的会员行清空并
// 返回清理后的用户(供推送 updateUser);单次最多处理 limit 行。 // 返回清理后的用户(供推送 updateUser);单次最多处理 limit 行。
SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error)
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除, // UpdateEmojiStatus 更新用户自定义 emoji status。零值清除;collectible
// until=0 表示永久)。 // 必须是完整且与 DocumentID 一致的不可变快照。
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)
UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error)
// UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。 // UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。
UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) 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) 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 表基础资料。 // UserCache 缓存 viewer 无关的 users 表基础资料。
// 联系人备注、隐私裁剪、头像选择和 presence 不应写入该缓存。 // 联系人备注、隐私裁剪、头像选择和 presence 不应写入该缓存。
type UserCache interface { type UserCache interface {