Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9

This commit is contained in:
onysd 2026-07-24 17:15:53 +03:00
commit b443ff0c73
277 changed files with 30747 additions and 1551 deletions

View file

@ -0,0 +1,4 @@
-- This migration emits durable per-user edit_message events. Reverting the
-- repaired ids or rewinding pts would reintroduce cross-account references and
-- create holes in updates.getDifference, so rollback intentionally preserves
-- both the corrected snapshots and their update facts.

View file

@ -0,0 +1,186 @@
-- Private message box ids are account-local. Repair user-owned Star Gift
-- service actions that copied the owner's msg_id into both participants'
-- message boxes, and publish durable edit_message facts for already-visible
-- incorrect projections.
CREATE TEMP TABLE star_gift_box_media_repairs (
owner_user_id bigint NOT NULL,
box_id integer NOT NULL,
peer_type text NOT NULL,
peer_id bigint NOT NULL,
repaired_media jsonb NOT NULL,
PRIMARY KEY (owner_user_id, box_id)
) ON COMMIT DROP;
-- An upgrade action points back to the original ordinary gift. user saved_id
-- is a management identity, not a conversation message link: only the current
-- gift owner's box may carry it. The other participant must omit the field.
INSERT INTO star_gift_box_media_repairs (
owner_user_id, box_id, peer_type, peer_id, repaired_media
)
SELECT unique_box.owner_user_id,
unique_box.box_id,
unique_box.peer_type,
unique_box.peer_id,
CASE
WHEN unique_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
unique_box.media,
'{service_action,star_gift_unique,saved_id}',
to_jsonb(gift.msg_id::bigint),
true
)
ELSE unique_box.media #- '{service_action,star_gift_unique,saved_id}'
END
FROM peer_star_gifts gift
JOIN message_boxes upgrade_owner
ON upgrade_owner.owner_user_id = gift.owner_peer_id
AND upgrade_owner.box_id = gift.upgrade_msg_id
JOIN message_boxes unique_box
ON unique_box.message_sender_id = upgrade_owner.message_sender_id
AND unique_box.private_message_id = upgrade_owner.private_message_id
WHERE gift.owner_peer_type = 'user'
AND gift.unique_gift_id IS NOT NULL
AND gift.msg_id > 0
AND gift.upgrade_msg_id > 0
AND NOT unique_box.deleted
AND unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND unique_box.media #>> '{service_action,star_gift_unique,upgrade}' = 'true'
AND unique_box.media IS DISTINCT FROM CASE
WHEN unique_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
unique_box.media,
'{service_action,star_gift_unique,saved_id}',
to_jsonb(gift.msg_id::bigint),
true
)
ELSE unique_box.media #- '{service_action,star_gift_unique,saved_id}'
END
ON CONFLICT (owner_user_id, box_id) DO UPDATE
SET repaired_media = EXCLUDED.repaired_media;
-- For every other user-target unique action (transfer, resale, offer accept,
-- craft), the action message itself is the new user saved-gift identity.
-- saved_id is a channel-only field there and must be absent from every box.
INSERT INTO star_gift_box_media_repairs (
owner_user_id, box_id, peer_type, peer_id, repaired_media
)
SELECT box.owner_user_id,
box.box_id,
box.peer_type,
box.peer_id,
box.media #- '{service_action,star_gift_unique,saved_id}'
FROM message_boxes box
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user'
AND COALESCE((box.media #>> '{service_action,star_gift_unique,upgrade}')::boolean, false) = false
AND box.media #> '{service_action,star_gift_unique,saved_id}' IS NOT NULL
ON CONFLICT (owner_user_id, box_id) DO UPDATE
SET repaired_media = EXCLUDED.repaired_media;
-- A separate prepaid-upgrade action points to the same ordinary gift.
-- Telegram defines gift_msg_id as receiver-only, so retain it only in the
-- owner's service-message box and remove it from the payer's outgoing copy.
INSERT INTO star_gift_box_media_repairs (
owner_user_id, box_id, peer_type, peer_id, repaired_media
)
SELECT prepay_box.owner_user_id,
prepay_box.box_id,
prepay_box.peer_type,
prepay_box.peer_id,
CASE
WHEN prepay_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
prepay_box.media,
'{service_action,star_gift,gift_msg_id}',
to_jsonb(gift.msg_id::bigint),
true
)
ELSE prepay_box.media #- '{service_action,star_gift,gift_msg_id}'
END
FROM peer_star_gifts gift
JOIN message_boxes prepay_owner
ON prepay_owner.owner_user_id = gift.owner_peer_id
AND prepay_owner.media #>> '{service_action,kind}' = 'star_gift'
AND prepay_owner.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
AND prepay_owner.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
AND (prepay_owner.media #>> '{service_action,star_gift,gift_msg_id}')::integer = gift.msg_id
AND (prepay_owner.media #>> '{service_action,star_gift,gift_id}')::bigint = gift.gift_id
JOIN message_boxes prepay_box
ON prepay_box.message_sender_id = prepay_owner.message_sender_id
AND prepay_box.private_message_id = prepay_owner.private_message_id
WHERE gift.owner_peer_type = 'user'
AND gift.msg_id > 0
AND NOT prepay_box.deleted
AND prepay_box.media IS DISTINCT FROM CASE
WHEN prepay_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
prepay_box.media,
'{service_action,star_gift,gift_msg_id}',
to_jsonb(gift.msg_id::bigint),
true
)
ELSE prepay_box.media #- '{service_action,star_gift,gift_msg_id}'
END
ON CONFLICT (owner_user_id, box_id) DO UPDATE
SET repaired_media = EXCLUDED.repaired_media;
DO $$
DECLARE
repair record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
BEGIN
FOR repair IN
SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media
FROM star_gift_box_media_repairs
ORDER BY owner_user_id, box_id
LOOP
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
VALUES (repair.owner_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
UPDATE user_update_watermarks
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
WHERE user_id = repair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE message_boxes
SET media = repair.repaired_media,
pts = next_pts
WHERE owner_user_id = repair.owner_user_id
AND box_id = repair.box_id
AND NOT deleted;
INSERT INTO user_update_events (
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
repair.box_id, repair.peer_type, repair.peer_id
);
INSERT INTO dispatch_outbox (
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES (repair.owner_user_id, next_pts, 'edit_message', 0, 0);
END LOOP;
END
$$;
-- private_messages is the logical shared envelope and cannot contain either
-- participant's local message id. User-visible history/difference always reads
-- the per-owner message_boxes snapshots repaired above.
UPDATE private_messages
SET media = media
#- '{service_action,star_gift,saved_id}'
#- '{service_action,star_gift,gift_msg_id}'
#- '{service_action,star_gift,upgrade_msg_id}'
WHERE media #>> '{service_action,kind}' = 'star_gift'
AND (
media #> '{service_action,star_gift,peer_user_id}' IS NOT NULL
OR media #>> '{service_action,star_gift,to,Type}' = 'user'
);
UPDATE private_messages
SET media = media #- '{service_action,star_gift_unique,saved_id}'
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';

View file

@ -0,0 +1,14 @@
DROP TABLE IF EXISTS public.telegram_login_codes;
DROP TABLE IF EXISTS public.web_authorizations;
DROP TABLE IF EXISTS public.telegram_login_requests;
DROP TABLE IF EXISTS public.bot_login_native_apps;
DROP TABLE IF EXISTS public.bot_login_allowed_urls;
DROP TABLE IF EXISTS public.bot_login_clients;
UPDATE public.bots
SET commands = COALESCE((
SELECT jsonb_agg(command ORDER BY ordinal)
FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal)
WHERE command->>'command' NOT IN ('setlogin','logininfo','resetloginsecret')
), '[]'::jsonb),
updated_at = now()
WHERE bot_user_id = 93372553;

View file

@ -0,0 +1,253 @@
-- Telegram Login / OIDC is one durable authorization aggregate shared by the
-- public HTTP provider and MTProto URL-auth RPCs. PostgreSQL is authoritative;
-- Redis/NOTIFY may wake waiters but may not own any transition below.
UPDATE public.bots
SET commands = commands || '[
{"command":"setlogin","description":"configure Telegram Login"},
{"command":"logininfo","description":"show Telegram Login configuration"},
{"command":"resetloginsecret","description":"rotate an OIDC Client Secret"}
]'::jsonb,
updated_at = now()
WHERE bot_user_id = 93372553;
CREATE TABLE public.bot_login_clients (
bot_user_id bigint PRIMARY KEY REFERENCES public.bots(bot_user_id) ON DELETE CASCADE,
client_id text NOT NULL UNIQUE,
client_secret_hash bytea NOT NULL,
secret_version bigint DEFAULT 1 NOT NULL,
signing_algorithm text DEFAULT 'RS256'::text NOT NULL,
enabled boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT bot_login_clients_client_id_check
CHECK (client_id = bot_user_id::text AND length(client_id) BETWEEN 1 AND 64),
CONSTRAINT bot_login_clients_secret_hash_check CHECK (octet_length(client_secret_hash) = 32),
CONSTRAINT bot_login_clients_secret_version_check CHECK (secret_version > 0),
CONSTRAINT bot_login_clients_signing_algorithm_check
CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K'))
);
CREATE TABLE public.bot_login_allowed_urls (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
kind text NOT NULL,
normalized_url text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT bot_login_allowed_urls_kind_check CHECK (kind IN ('web_origin','redirect_uri')),
CONSTRAINT bot_login_allowed_urls_value_check CHECK (length(normalized_url) BETWEEN 1 AND 4096),
UNIQUE (bot_user_id, kind, normalized_url)
);
CREATE INDEX bot_login_allowed_urls_bot_page_idx
ON public.bot_login_allowed_urls(bot_user_id, kind, id);
CREATE TABLE public.bot_login_native_apps (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
platform text NOT NULL,
application_id text NOT NULL,
verification_id text NOT NULL,
callback_uri text NOT NULL,
verified_display_name text NOT NULL,
enabled boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT bot_login_native_apps_platform_check CHECK (platform IN ('ios','android')),
CONSTRAINT bot_login_native_apps_app_id_check
CHECK (length(application_id) BETWEEN 3 AND 255 AND application_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'),
CONSTRAINT bot_login_native_apps_verification_check CHECK (
(platform = 'ios' AND verification_id ~ '^[A-Z0-9]{10}$')
OR (platform = 'android' AND verification_id ~ '^[0-9A-F]{64}$')
),
CONSTRAINT bot_login_native_apps_callback_check CHECK (length(callback_uri) BETWEEN 1 AND 4096),
CONSTRAINT bot_login_native_apps_name_check CHECK (length(btrim(verified_display_name)) BETWEEN 1 AND 128),
UNIQUE (bot_user_id, platform, application_id, verification_id),
UNIQUE (bot_user_id, callback_uri)
);
CREATE INDEX bot_login_native_apps_bot_page_idx
ON public.bot_login_native_apps(bot_user_id, id);
CREATE INDEX bot_login_native_apps_callback_idx
ON public.bot_login_native_apps(bot_user_id, callback_uri) WHERE enabled;
CREATE TABLE public.telegram_login_requests (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
request_token_hash bytea NOT NULL UNIQUE,
browser_token_hash bytea NOT NULL UNIQUE,
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
client_id text NOT NULL,
signing_algorithm text NOT NULL,
source text NOT NULL,
response_type text NOT NULL,
redirect_uri text NOT NULL,
origin text DEFAULT ''::text NOT NULL,
domain text NOT NULL,
requested_scopes text[] NOT NULL,
oauth_state text DEFAULT ''::text NOT NULL,
nonce text DEFAULT ''::text NOT NULL,
code_challenge text NOT NULL,
code_challenge_method text NOT NULL,
browser text NOT NULL,
platform text NOT NULL,
ip text NOT NULL,
region text NOT NULL,
in_app_origin text DEFAULT ''::text NOT NULL,
is_app boolean DEFAULT false NOT NULL,
verified_app_name text DEFAULT ''::text NOT NULL,
match_codes text[] DEFAULT '{}'::text[] NOT NULL,
match_code text DEFAULT ''::text NOT NULL,
match_codes_first boolean DEFAULT false NOT NULL,
user_id_hint bigint DEFAULT 0 NOT NULL,
peer_type text DEFAULT ''::text NOT NULL,
peer_id bigint DEFAULT 0 NOT NULL,
message_id integer DEFAULT 0 NOT NULL,
button_id integer DEFAULT 0 NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
authorized_user_id bigint REFERENCES public.users(id),
profile_name text DEFAULT ''::text NOT NULL,
given_name text DEFAULT ''::text NOT NULL,
family_name text DEFAULT ''::text NOT NULL,
preferred_username text DEFAULT ''::text NOT NULL,
picture text DEFAULT ''::text NOT NULL,
phone_number text DEFAULT ''::text NOT NULL,
write_allowed boolean DEFAULT false NOT NULL,
phone_shared boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone NOT NULL,
approved_at timestamp with time zone,
declined_at timestamp with time zone,
CONSTRAINT telegram_login_requests_hashes_check
CHECK (octet_length(request_token_hash) = 32 AND octet_length(browser_token_hash) = 32),
CONSTRAINT telegram_login_requests_signing_algorithm_check
CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K')),
CONSTRAINT telegram_login_requests_source_check
CHECK (source IN ('web','javascript','native','mini_app','message_button')),
CONSTRAINT telegram_login_requests_response_type_check CHECK (response_type IN ('code','post_message','legacy_url')),
CONSTRAINT telegram_login_requests_source_response_check CHECK (
(source = 'web' AND response_type = 'code')
OR (source = 'javascript' AND response_type = 'post_message')
OR (source = 'native' AND response_type = 'code')
OR (source = 'mini_app' AND response_type = 'post_message')
OR (source = 'message_button' AND response_type = 'legacy_url')
),
CONSTRAINT telegram_login_requests_url_check
CHECK (length(redirect_uri) BETWEEN 1 AND 4096 AND length(origin) <= 4096
AND length(domain) BETWEEN 1 AND 255 AND length(in_app_origin) <= 4096),
CONSTRAINT telegram_login_requests_scope_check
CHECK (cardinality(requested_scopes) BETWEEN 1 AND 4 AND requested_scopes @> ARRAY['openid']::text[]),
CONSTRAINT telegram_login_requests_oauth_value_check
CHECK (length(oauth_state) <= 2048 AND length(nonce) <= 1024),
CONSTRAINT telegram_login_requests_pkce_check CHECK (
(response_type = 'code' AND code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128)
OR (response_type = 'post_message' AND (
(code_challenge = '' AND code_challenge_method = '')
OR (code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128)))
OR (response_type = 'legacy_url' AND code_challenge = '' AND code_challenge_method = '')
),
CONSTRAINT telegram_login_requests_device_check
CHECK (length(browser) BETWEEN 1 AND 255 AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128 AND length(region) BETWEEN 1 AND 255),
CONSTRAINT telegram_login_requests_match_codes_check
CHECK (cardinality(match_codes) <= 8
AND (cardinality(match_codes) = 0 OR (match_code <> '' AND match_code = ANY(match_codes)))
AND (NOT match_codes_first OR cardinality(match_codes) > 0)),
CONSTRAINT telegram_login_requests_context_check
CHECK (user_id_hint >= 0 AND peer_id >= 0 AND message_id >= 0 AND button_id >= 0),
CONSTRAINT telegram_login_requests_app_shape_check CHECK (
(source = 'native' AND is_app AND verified_app_name <> '' AND origin = '')
OR (source <> 'native' AND NOT is_app AND verified_app_name = '' AND origin <> '')
),
CONSTRAINT telegram_login_requests_in_app_shape_check CHECK (
(source = 'mini_app' AND response_type = 'post_message'
AND in_app_origin <> '' AND origin = in_app_origin)
OR (source <> 'mini_app' AND in_app_origin = '')
),
CONSTRAINT telegram_login_requests_consent_scope_check CHECK (
(NOT write_allowed OR 'telegram:bot_access' = ANY(requested_scopes))
AND (NOT phone_shared OR 'phone' = ANY(requested_scopes))
AND ((phone_shared AND phone_number <> '') OR (NOT phone_shared AND phone_number = ''))
),
CONSTRAINT telegram_login_requests_status_check CHECK (status IN ('pending','approved','declined','expired')),
CONSTRAINT telegram_login_requests_claims_check CHECK (
length(profile_name) <= 255 AND length(given_name) <= 255 AND length(family_name) <= 255
AND length(preferred_username) <= 64 AND length(picture) <= 4096 AND length(phone_number) <= 32
),
CONSTRAINT telegram_login_requests_time_check CHECK (expires_at > created_at),
CONSTRAINT telegram_login_requests_terminal_shape_check CHECK (
(status = 'pending' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL)
OR (status = 'approved' AND authorized_user_id IS NOT NULL AND approved_at IS NOT NULL AND declined_at IS NULL
AND (('profile' = ANY(requested_scopes) AND profile_name <> '' AND given_name <> '')
OR (NOT ('profile' = ANY(requested_scopes)) AND profile_name = '' AND given_name = ''
AND family_name = '' AND preferred_username = '' AND picture = '')))
OR (status = 'declined' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NOT NULL)
OR (status = 'expired' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL)
)
);
CREATE INDEX telegram_login_requests_expiry_idx
ON public.telegram_login_requests(expires_at, id) WHERE status = 'pending';
CREATE INDEX telegram_login_requests_user_active_idx
ON public.telegram_login_requests(authorized_user_id, approved_at DESC, id DESC)
WHERE status = 'approved';
CREATE TABLE public.web_authorizations (
hash bigint PRIMARY KEY,
request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE,
user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
bot_user_id bigint NOT NULL REFERENCES public.bots(bot_user_id) ON DELETE CASCADE,
domain text NOT NULL,
browser text NOT NULL,
platform text NOT NULL,
ip text NOT NULL,
region text NOT NULL,
granted_scopes text[] NOT NULL,
phone_shared boolean DEFAULT false NOT NULL,
bot_access_granted boolean DEFAULT false NOT NULL,
created_at timestamp with time zone NOT NULL,
last_active_at timestamp with time zone NOT NULL,
revoked_at timestamp with time zone,
CONSTRAINT web_authorizations_hash_check CHECK (hash <> 0),
CONSTRAINT web_authorizations_identity_check CHECK (user_id > 0 AND bot_user_id > 0),
CONSTRAINT web_authorizations_text_check
CHECK (length(domain) BETWEEN 1 AND 255 AND length(browser) BETWEEN 1 AND 255
AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128
AND length(region) BETWEEN 1 AND 255),
CONSTRAINT web_authorizations_scope_check
CHECK (cardinality(granted_scopes) BETWEEN 1 AND 4 AND granted_scopes @> ARRAY['openid']::text[]
AND (NOT phone_shared OR 'phone' = ANY(granted_scopes))
AND (NOT bot_access_granted OR 'telegram:bot_access' = ANY(granted_scopes))),
CONSTRAINT web_authorizations_time_check
CHECK (last_active_at >= created_at AND (revoked_at IS NULL OR revoked_at >= created_at))
);
CREATE INDEX web_authorizations_user_active_page_idx
ON public.web_authorizations(user_id, last_active_at DESC, hash DESC)
WHERE revoked_at IS NULL;
CREATE INDEX web_authorizations_bot_active_idx
ON public.web_authorizations(bot_user_id, user_id, hash)
WHERE revoked_at IS NULL;
CREATE TABLE public.telegram_login_codes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE,
code_hash bytea NOT NULL UNIQUE,
sealed_code bytea NOT NULL,
seal_nonce bytea NOT NULL,
seal_key_id text NOT NULL,
issued_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone NOT NULL,
consumed_at timestamp with time zone,
CONSTRAINT telegram_login_codes_hash_check CHECK (octet_length(code_hash) = 32),
CONSTRAINT telegram_login_codes_sealed_check
CHECK (octet_length(sealed_code) >= 32 AND octet_length(seal_nonce) >= 12 AND length(seal_key_id) BETWEEN 1 AND 128),
CONSTRAINT telegram_login_codes_time_check
CHECK (expires_at > issued_at AND (consumed_at IS NULL OR (consumed_at >= issued_at AND consumed_at < expires_at)))
);
CREATE INDEX telegram_login_codes_expiry_idx
ON public.telegram_login_codes(expires_at, id) WHERE consumed_at IS NULL;

View file

@ -0,0 +1,6 @@
-- Durable edit events and repaired user gift projections are intentionally not
-- rewound. Dropping the new lookup/index constraints is sufficient rollback.
ALTER TABLE peer_star_gifts
DROP CONSTRAINT IF EXISTS peer_star_gifts_hidden_unpinned_check;
DROP TABLE IF EXISTS star_gift_user_message_refs;

View file

@ -0,0 +1,127 @@
-- Official clients may continue lifecycle actions from a freshly emitted
-- messageActionStarGiftUnique. Keep those user-local message ids as explicit
-- durable references to the same saved gift aggregate.
CREATE TABLE star_gift_user_message_refs (
owner_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
msg_id integer NOT NULL,
saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (owner_user_id, msg_id),
CONSTRAINT star_gift_user_message_refs_msg_check CHECK (owner_user_id > 0 AND msg_id > 0)
);
CREATE INDEX star_gift_user_message_refs_saved_idx
ON star_gift_user_message_refs(saved_gift_id, owner_user_id, msg_id);
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT box.owner_user_id, box.box_id, gift.id
FROM message_boxes box
JOIN unique_star_gifts unique_gift
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
JOIN peer_star_gifts gift
ON gift.id = unique_gift.source_saved_gift_id
AND gift.owner_peer_type = 'user'
AND gift.owner_peer_id = box.owner_user_id
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.box_id <> gift.msg_id;
-- Hidden gifts cannot remain pinned. Compacting the whole owner vector here
-- also repairs historical gaps before the invariant is constrained.
ALTER TABLE peer_star_gifts
ADD CONSTRAINT peer_star_gifts_hidden_unpinned_check
CHECK (pinned_order<=6 AND (NOT unsaved OR pinned_order=0)) NOT VALID;
CREATE TEMP TABLE star_gift_pin_repairs ON COMMIT DROP AS
SELECT id,new_order
FROM (
SELECT id,
row_number() OVER (PARTITION BY owner_peer_type,owner_peer_id
ORDER BY pinned_order,id)::integer AS new_order
FROM peer_star_gifts
WHERE lifecycle_status='active' AND NOT unsaved AND pinned_order>0
) ranked
WHERE new_order<=6;
UPDATE peer_star_gifts SET pinned_order=0 WHERE pinned_order<>0;
UPDATE peer_star_gifts gift
SET pinned_order=repair.new_order
FROM star_gift_pin_repairs repair
WHERE gift.id=repair.id;
-- peer and saved_id share one TL flag and are channel-only. Earlier user gift
-- projections set peer=user (and sometimes a user box id in saved_id), which
-- made TDesktop select zero/stale ids instead of the emitted service message.
CREATE TEMP TABLE star_gift_user_unique_media_repairs (
owner_user_id bigint NOT NULL,
box_id integer NOT NULL,
peer_type text NOT NULL,
peer_id bigint NOT NULL,
repaired_media jsonb NOT NULL,
PRIMARY KEY(owner_user_id,box_id)
) ON COMMIT DROP;
INSERT INTO star_gift_user_unique_media_repairs(owner_user_id,box_id,peer_type,peer_id,repaired_media)
SELECT box.owner_user_id,
box.box_id,
box.peer_type,
box.peer_id,
jsonb_set(
box.media #- '{service_action,star_gift_unique,saved_id}',
'{service_action,star_gift_unique,peer}',
'{"ID":0,"Type":""}'::jsonb,
true
)
FROM message_boxes box
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
DO $$
DECLARE
repair record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
BEGIN
FOR repair IN
SELECT owner_user_id,box_id,peer_type,peer_id,repaired_media
FROM star_gift_user_unique_media_repairs
ORDER BY owner_user_id,box_id
LOOP
INSERT INTO user_update_watermarks(user_id,contiguous_pts)
VALUES(repair.owner_user_id,0)
ON CONFLICT(user_id) DO NOTHING;
UPDATE user_update_watermarks
SET contiguous_pts=contiguous_pts+1,updated_at=now()
WHERE user_id=repair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE message_boxes
SET media=repair.repaired_media,pts=next_pts
WHERE owner_user_id=repair.owner_user_id AND box_id=repair.box_id AND NOT deleted;
INSERT INTO user_update_events(
user_id,pts,pts_count,date,event_type,message_box_id,peer_type,peer_id
) VALUES(
repair.owner_user_id,next_pts,1,event_date,'edit_message',repair.box_id,repair.peer_type,repair.peer_id
);
INSERT INTO dispatch_outbox(
target_user_id,pts,event_type,exclude_auth_key_id,exclude_session_id
) VALUES(repair.owner_user_id,next_pts,'edit_message',0,0);
END LOOP;
END
$$;
UPDATE private_messages
SET media=jsonb_set(
media #- '{service_action,star_gift_unique,saved_id}',
'{service_action,star_gift_unique,peer}',
'{"ID":0,"Type":""}'::jsonb,
true
)
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';

View file

@ -0,0 +1 @@
-- Validation changes no data and the constraint belongs to migration 0126.

View file

@ -0,0 +1,5 @@
-- 0126 repaired historical rows and installed the constraint as NOT VALID so
-- it could coexist with the deferrable unique-gift owner trigger in one
-- migration transaction. Validate after that transaction has committed.
ALTER TABLE peer_star_gifts
VALIDATE CONSTRAINT peer_star_gifts_hidden_unpinned_check;

View file

@ -0,0 +1,38 @@
-- Aggregate/message repairs and emitted edit events are authoritative business
-- history and are intentionally not reversed. Restore only the pre-0128
-- deferred owner guard shape.
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
unique_id bigint;
gift_owner_type text;
gift_owner_id bigint;
gift_owner_address text;
gift_burned boolean;
saved_status text;
saved_owner_type text;
saved_owner_id bigint;
BEGIN
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
unique_id := COALESCE(NEW.id, OLD.id);
ELSE
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
END IF;
IF unique_id IS NULL THEN RETURN NULL; END IF;
SELECT owner_peer_type, owner_peer_id, owner_address, burned
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned
FROM public.unique_star_gifts WHERE id=unique_id;
IF NOT FOUND THEN RETURN NULL; END IF;
SELECT lifecycle_status, owner_peer_type, owner_peer_id
INTO saved_status, saved_owner_type, saved_owner_id
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
IF gift_burned THEN
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
ELSIF gift_owner_address <> '' THEN
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
RAISE EXCEPTION 'unique star gift owner mismatch';
END IF;
RETURN NULL;
END;
$$;

View file

@ -0,0 +1,498 @@
-- Official Android clients use a positive can_craft_at both as the Craft
-- capability marker and as the readiness boundary. Earlier zero-delay
-- upgrades persisted 0 while retaining a positive craft chance, so TDesktop
-- could Craft the gift but Android hid the entry entirely.
--
-- Block concurrent lifecycle/message writers while aggregate facts, message
-- snapshots and durable edit edges are repaired in this migration transaction.
LOCK TABLE public.peer_star_gifts, public.unique_star_gifts,
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
-- Craft capability remains an intrinsic collectible fact while ownership
-- moves between users and channels. Terminal/external states cannot Craft.
UPDATE public.unique_star_gifts unique_gift
SET craft_chance_permille = 0,
updated_at = now()
FROM public.peer_star_gifts saved_gift
WHERE saved_gift.unique_gift_id = unique_gift.id
AND unique_gift.craft_chance_permille > 0
AND (
saved_gift.lifecycle_status <> 'active'
OR unique_gift.owner_address <> ''
OR unique_gift.burned
OR unique_gift.crafted
);
UPDATE public.peer_star_gifts saved_gift
SET can_craft_at = 0
FROM public.unique_star_gifts unique_gift
WHERE unique_gift.id = saved_gift.unique_gift_id
AND unique_gift.craft_chance_permille = 0
AND saved_gift.can_craft_at <> 0;
IF EXISTS (
SELECT 1
FROM public.unique_star_gifts unique_gift
JOIN public.peer_star_gifts saved_gift
ON saved_gift.unique_gift_id = unique_gift.id
WHERE unique_gift.craft_chance_permille > 0
AND (
saved_gift.owner_peer_type NOT IN ('user', 'channel')
OR saved_gift.lifecycle_status <> 'active'
OR unique_gift.owner_address <> ''
OR unique_gift.burned
OR unique_gift.crafted
OR NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
AND model.crafted
)
)
) THEN
RAISE EXCEPTION 'positive star gift craft chance has no valid owned aggregate';
END IF;
-- created_at is the stable persisted proxy for the original upgrade
-- transaction date on legacy rows. New writes use the exact request date.
UPDATE public.peer_star_gifts saved_gift
SET can_craft_at = GREATEST(
1,
LEAST(2147483647, FLOOR(EXTRACT(EPOCH FROM unique_gift.created_at))::bigint)::integer
)
FROM public.unique_star_gifts unique_gift
WHERE unique_gift.id = saved_gift.unique_gift_id
AND unique_gift.craft_chance_permille > 0
AND saved_gift.can_craft_at = 0;
IF EXISTS (
SELECT 1
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
WHERE (unique_gift.craft_chance_permille > 0)
IS DISTINCT FROM (saved_gift.can_craft_at > 0)
) THEN
RAISE EXCEPTION 'star gift craft chance/readiness repair did not converge';
END IF;
END
$$;
CREATE TEMP TABLE star_gift_craft_message_repairs (
owner_user_id bigint NOT NULL,
box_id integer NOT NULL,
unique_gift_id bigint NOT NULL,
desired_craft_chance integer NOT NULL,
desired_can_craft_at integer NOT NULL,
PRIMARY KEY (owner_user_id, box_id)
) ON COMMIT DROP;
-- Adding capability is owner-scoped: repair only the current owner's
-- authoritative unique action (upgrade_msg_id) and the other visible box of
-- that same logical private message. Never add Craft back to an old owner's
-- historical transfer/resale action.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND NOT EXISTS (
SELECT 1
FROM public.message_boxes owner_box
WHERE owner_box.owner_user_id = saved_gift.owner_peer_id
AND owner_box.box_id = saved_gift.upgrade_msg_id
AND NOT owner_box.deleted
AND owner_box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
)
) THEN
RAISE EXCEPTION 'craftable star gift is missing its current owner action';
END IF;
IF EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
AND (
jsonb_typeof(box.media #> '{service_action,star_gift_unique,can_craft_at}') <> 'number'
OR COALESCE(box.media #>> '{service_action,star_gift_unique,can_craft_at}', '') !~ '^[0-9]+$'
OR (box.media #>> '{service_action,star_gift_unique,can_craft_at}')::numeric > 2147483647
)
) THEN
RAISE EXCEPTION 'star gift message has malformed can_craft_at';
END IF;
IF EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
AND (
jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') <> 'number'
OR COALESCE(box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '') !~ '^[0-9]+$'
OR (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::numeric > 1000
)
) THEN
RAISE EXCEPTION 'star gift message has malformed craft chance';
END IF;
END
$$;
INSERT INTO star_gift_craft_message_repairs(
owner_user_id, box_id, unique_gift_id,
desired_craft_chance, desired_can_craft_at
)
SELECT visible_box.owner_user_id,
visible_box.box_id,
unique_gift.id,
unique_gift.craft_chance_permille,
saved_gift.can_craft_at
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
JOIN public.message_boxes owner_box
ON owner_box.owner_user_id = saved_gift.owner_peer_id
AND owner_box.box_id = saved_gift.upgrade_msg_id
AND NOT owner_box.deleted
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
JOIN public.message_boxes visible_box
ON visible_box.message_sender_id = owner_box.message_sender_id
AND visible_box.private_message_id = owner_box.private_message_id
AND NOT visible_box.deleted
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND (
COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,can_craft_at}', '')::integer, 0)
IS DISTINCT FROM saved_gift.can_craft_at
OR COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '')::integer, 0)
IS DISTINCT FROM unique_gift.craft_chance_permille
);
-- Only the current owner's authoritative logical message may expose Craft.
-- Terminal gifts, channel-owned gifts (until channel Craft is implemented),
-- and old-owner historical actions must have both wire markers removed.
INSERT INTO star_gift_craft_message_repairs(
owner_user_id, box_id, unique_gift_id,
desired_craft_chance, desired_can_craft_at
)
SELECT box.owner_user_id, box.box_id, unique_gift.id, 0, 0
FROM public.message_boxes box
JOIN public.unique_star_gifts unique_gift
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
JOIN public.peer_star_gifts saved_gift
ON saved_gift.unique_gift_id = unique_gift.id
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND (
box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
OR box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
)
AND NOT EXISTS (
SELECT 1
FROM public.message_boxes authority
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND unique_gift.craft_chance_permille > 0
AND authority.owner_user_id = saved_gift.owner_peer_id
AND authority.box_id = saved_gift.upgrade_msg_id
AND NOT authority.deleted
AND authority.media #>> '{service_action,kind}' = 'star_gift_unique'
AND authority.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
AND authority.message_sender_id = box.message_sender_id
AND authority.private_message_id = box.private_message_id
)
ON CONFLICT (owner_user_id, box_id) DO UPDATE
SET unique_gift_id = EXCLUDED.unique_gift_id,
desired_craft_chance = 0,
desired_can_craft_at = 0;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
WHERE box.deleted
OR box.media #>> '{service_action,kind}' <> 'star_gift_unique'
OR box.media #>> '{service_action,star_gift_unique,gift,ID}' <> target.unique_gift_id::text
) THEN
RAISE EXCEPTION 'craft readiness repair target is not the expected unique gift action';
END IF;
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
WHERE NOT EXISTS (
SELECT 1
FROM public.private_messages private_message
WHERE private_message.sender_user_id = box.message_sender_id
AND private_message.id = box.private_message_id
AND private_message.media #>> '{service_action,kind}' = 'star_gift_unique'
AND private_message.media #>> '{service_action,star_gift_unique,gift,ID}' = target.unique_gift_id::text
)
) THEN
RAISE EXCEPTION 'craft readiness repair target has no matching private message';
END IF;
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
GROUP BY box.message_sender_id, box.private_message_id
HAVING COUNT(DISTINCT (
target.unique_gift_id,
target.desired_craft_chance,
target.desired_can_craft_at
)) <> 1
) THEN
RAISE EXCEPTION 'craft readiness repair has conflicting logical message targets';
END IF;
END
$$;
DO $$
DECLARE
repair record;
next_pts integer;
event_date integer := LEAST(2147483647, EXTRACT(EPOCH FROM clock_timestamp())::bigint)::integer;
repaired_media jsonb;
repaired_private_media jsonb;
affected_rows bigint;
BEGIN
FOR repair IN
SELECT target.owner_user_id,
target.box_id,
target.unique_gift_id,
target.desired_craft_chance,
target.desired_can_craft_at,
box.peer_type,
box.peer_id,
box.message_sender_id,
box.private_message_id,
box.media
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
AND NOT box.deleted
ORDER BY target.owner_user_id, target.box_id
FOR UPDATE OF box
LOOP
IF repair.desired_craft_chance > 0 THEN
repaired_media := jsonb_set(
jsonb_set(
repair.media,
'{service_action,star_gift_unique,gift,CraftChancePermille}',
to_jsonb(repair.desired_craft_chance),
true
),
'{service_action,star_gift_unique,can_craft_at}',
to_jsonb(repair.desired_can_craft_at),
true
);
ELSE
repaired_media := repair.media
#- '{service_action,star_gift_unique,can_craft_at}'
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
END IF;
IF repaired_media #>> '{service_action,kind}' <> 'star_gift_unique'
OR repaired_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text
OR (
repair.desired_craft_chance > 0
AND (
repaired_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
IS DISTINCT FROM repair.desired_craft_chance::text
OR repaired_media #>> '{service_action,star_gift_unique,can_craft_at}'
IS DISTINCT FROM repair.desired_can_craft_at::text
)
)
OR (
repair.desired_craft_chance = 0
AND (
repaired_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
OR repaired_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
)
) THEN
RAISE EXCEPTION 'craft readiness repair cannot project message box for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (repair.owner_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 = repair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET media = repaired_media,
pts = next_pts
WHERE owner_user_id = repair.owner_user_id
AND box_id = repair.box_id
AND NOT deleted;
GET DIAGNOSTICS affected_rows = ROW_COUNT;
IF affected_rows <> 1 THEN
RAISE EXCEPTION 'craft readiness repair lost user %, box %', repair.owner_user_id, repair.box_id;
END IF;
SELECT media
INTO repaired_private_media
FROM public.private_messages
WHERE sender_user_id = repair.message_sender_id
AND id = repair.private_message_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'craft readiness repair missing private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
IF repaired_private_media #>> '{service_action,kind}' <> 'star_gift_unique'
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text THEN
RAISE EXCEPTION 'craft readiness repair found mismatched private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
IF repair.desired_craft_chance > 0 THEN
repaired_private_media := jsonb_set(
jsonb_set(
repaired_private_media,
'{service_action,star_gift_unique,gift,CraftChancePermille}',
to_jsonb(repair.desired_craft_chance),
true
),
'{service_action,star_gift_unique,can_craft_at}',
to_jsonb(repair.desired_can_craft_at),
true
);
IF repaired_private_media #>> '{service_action,star_gift_unique,can_craft_at}'
IS DISTINCT FROM repair.desired_can_craft_at::text
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
IS DISTINCT FROM repair.desired_craft_chance::text THEN
RAISE EXCEPTION 'craft readiness repair cannot project private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
ELSE
repaired_private_media := repaired_private_media
#- '{service_action,star_gift_unique,can_craft_at}'
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
IF repaired_private_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
OR repaired_private_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL THEN
RAISE EXCEPTION 'craft readiness repair cannot clear private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
END IF;
UPDATE public.private_messages
SET media = repaired_private_media
WHERE sender_user_id = repair.message_sender_id
AND id = repair.private_message_id;
GET DIAGNOSTICS affected_rows = ROW_COUNT;
IF affected_rows <> 1 THEN
RAISE EXCEPTION 'craft readiness repair lost private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
repair.box_id, repair.peer_type, repair.peer_id
);
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES (
repair.owner_user_id, next_pts, 'edit_message', 0, 0
);
END LOOP;
END
$$;
-- Extend the existing deferred unique/saved aggregate guard. Upgrade, Craft
-- and export update the two tables in separate statements, so commit-time
-- validation observes the final atomic state without a read fallback.
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
unique_id bigint;
gift_owner_type text;
gift_owner_id bigint;
gift_owner_address text;
gift_burned boolean;
gift_crafted boolean;
gift_craft_chance integer;
gift_revision_id bigint;
saved_status text;
saved_owner_type text;
saved_owner_id bigint;
saved_can_craft_at integer;
BEGIN
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
unique_id := COALESCE(NEW.id, OLD.id);
ELSE
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
END IF;
IF unique_id IS NULL THEN RETURN NULL; END IF;
SELECT owner_peer_type, owner_peer_id, owner_address, burned, crafted,
craft_chance_permille, collectible_revision_id
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned, gift_crafted,
gift_craft_chance, gift_revision_id
FROM public.unique_star_gifts WHERE id=unique_id;
IF NOT FOUND THEN RETURN NULL; END IF;
SELECT lifecycle_status, owner_peer_type, owner_peer_id, can_craft_at
INTO saved_status, saved_owner_type, saved_owner_id, saved_can_craft_at
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
IF gift_burned THEN
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
ELSIF gift_owner_address <> '' THEN
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
RAISE EXCEPTION 'unique star gift owner mismatch';
END IF;
IF gift_craft_chance > 0 THEN
IF saved_can_craft_at <= 0
OR saved_status <> 'active'
OR saved_owner_type NOT IN ('user', 'channel')
OR gift_owner_address <> ''
OR gift_burned
OR gift_crafted THEN
RAISE EXCEPTION 'unique star gift craft capability has invalid aggregate state';
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = gift_revision_id
AND model.crafted
) THEN
RAISE EXCEPTION 'unique star gift craft chance has no crafted model';
END IF;
ELSIF saved_can_craft_at <> 0 THEN
RAISE EXCEPTION 'unique star gift readiness exists without craft chance';
END IF;
RETURN NULL;
END;
$$;

View file

@ -0,0 +1,4 @@
ALTER TABLE public.star_gift_craft_commands
DROP CONSTRAINT IF EXISTS star_gift_craft_output_receipt_check,
DROP COLUMN IF EXISTS output_fingerprint,
DROP COLUMN IF EXISTS output_media;

View file

@ -0,0 +1,65 @@
-- A successful Craft outcome and its self-service message are separated by a
-- process boundary. Freeze the exact output intent in the outcome receipt so
-- retries never rebuild a different message from mutable gift/profile state.
ALTER TABLE public.star_gift_craft_commands
ADD COLUMN output_media jsonb,
ADD COLUMN output_fingerprint bytea;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.star_gift_craft_commands command
WHERE command.success
AND 1 <> (
SELECT COUNT(*)
FROM public.private_messages message
WHERE message.sender_user_id = command.user_id
AND message.recipient_user_id = command.user_id
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
AND octet_length(message.request_fingerprint) = 32
)
) THEN
RAISE EXCEPTION 'successful craft command is missing its exact immutable output receipt';
END IF;
END
$$;
WITH outputs AS (
SELECT command.user_id,
command.command_key,
message.sender_snapshot #> '{message,Media}' AS media,
message.request_fingerprint
FROM public.star_gift_craft_commands command
JOIN public.private_messages message
ON message.sender_user_id = command.user_id
AND message.recipient_user_id = command.user_id
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
WHERE command.success
)
UPDATE public.star_gift_craft_commands command
SET output_media = output.media,
output_fingerprint = output.request_fingerprint
FROM outputs output
WHERE command.user_id = output.user_id
AND command.command_key = output.command_key;
ALTER TABLE public.star_gift_craft_commands
ADD CONSTRAINT star_gift_craft_output_receipt_check CHECK (
(success
AND result_unique_gift_id IS NOT NULL
AND output_media IS NOT NULL
AND output_media #>> '{service_action,kind}' = 'star_gift_unique'
AND COALESCE((output_media #>> '{service_action,star_gift_unique,craft}')::boolean, false)
AND output_media #>> '{service_action,star_gift_unique,gift,ID}' = result_unique_gift_id::text
AND octet_length(output_fingerprint) = 32)
OR
(NOT success
AND result_unique_gift_id IS NULL
AND output_media IS NULL
AND output_fingerprint IS NULL)
);

View file

@ -0,0 +1,13 @@
UPDATE public.bots
SET commands = COALESCE((
SELECT jsonb_agg(command ORDER BY ordinal)
FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal)
WHERE command->>'command' <> 'done'
), '[]'::jsonb),
updated_at = now()
WHERE bot_user_id = 93372553;
UPDATE public.users
SET bot_info_version = bot_info_version + 1,
updated_at = now()
WHERE id = 93372553;

View file

@ -0,0 +1,22 @@
-- /setlogin remains active across multiple configuration messages. Publish
-- /done in BotFather's command menu so clients can discover the explicit
-- finish action without reopening /help.
UPDATE public.bots
SET commands = commands || '[
{"command":"done","description":"finish Telegram Login configuration"}
]'::jsonb,
updated_at = now()
WHERE bot_user_id = 93372553
AND NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(commands) AS item(command)
WHERE item.command->>'command' = 'done'
);
-- Bot command menus are cached by bot_info_version. Bump it even when an
-- operator already added /done manually, making the migration convergent and
-- forcing connected clients to refresh the authoritative command list.
UPDATE public.users
SET bot_info_version = bot_info_version + 1,
updated_at = now()
WHERE id = 93372553;

View file

@ -0,0 +1,5 @@
DROP TABLE IF EXISTS public.account_freeze_notifications;
ALTER TABLE public.account_restrictions
DROP CONSTRAINT IF EXISTS account_restrictions_version_check,
DROP COLUMN IF EXISTS version;

View file

@ -0,0 +1,37 @@
-- A freeze/unfreeze is a viewer-visible user projection change. Version the
-- durable fact so a claimed old nudge can never acknowledge a newer state.
ALTER TABLE public.account_restrictions
ADD COLUMN version bigint DEFAULT 1 NOT NULL,
ADD CONSTRAINT account_restrictions_version_check CHECK (version > 0);
-- updateUser has no pts. This coalesced queue is only a crash-safe online
-- nudge; offline clients reconstruct the current restriction from the
-- authoritative account_restrictions row during normal user hydration.
CREATE TABLE public.account_freeze_notifications (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
target_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
frozen_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
version bigint NOT NULL,
frozen boolean NOT NULL,
status text DEFAULT 'pending' NOT NULL,
attempts integer DEFAULT 0 NOT NULL,
next_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
lease_until timestamp with time zone,
last_error text DEFAULT '' NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_freeze_notifications_status_check
CHECK (status IN ('pending', 'dispatching', 'delivered')),
CONSTRAINT account_freeze_notifications_attempts_check CHECK (attempts >= 0),
CONSTRAINT account_freeze_notifications_version_check CHECK (version > 0),
CONSTRAINT account_freeze_notifications_not_self_check CHECK (target_user_id <> frozen_user_id),
UNIQUE (target_user_id, frozen_user_id)
);
CREATE INDEX account_freeze_notifications_ready_idx
ON public.account_freeze_notifications(next_attempt_at, id)
WHERE status = 'pending';
CREATE INDEX account_freeze_notifications_lease_idx
ON public.account_freeze_notifications(lease_until, id)
WHERE status = 'dispatching';

View file

@ -0,0 +1,10 @@
DROP TRIGGER IF EXISTS star_gift_catalog_collectible_preview_activation ON public.star_gift_catalog;
DROP FUNCTION IF EXISTS public.telesrv_validate_collectible_preview_activation();
UPDATE public.star_gift_catalog c
SET collectible_revision_id = repair.collectible_revision_id, updated_at = now()
FROM public.star_gift_collectible_preview_repairs repair
WHERE c.gift_id = repair.gift_id
AND c.collectible_revision_id IS NULL;
DROP TABLE public.star_gift_collectible_preview_repairs;

View file

@ -0,0 +1,88 @@
-- TDesktop deduplicates upgrade-preview models/patterns by document identity and can only
-- finish each attribute spinner after it has a non-target item. Detach previously published
-- pools that cannot satisfy that client contract; the immutable revisions remain available
-- for audit and for already-issued unique gifts.
CREATE TABLE public.star_gift_collectible_preview_repairs (
gift_id bigint PRIMARY KEY REFERENCES public.star_gift_catalog(gift_id) ON DELETE CASCADE,
collectible_revision_id bigint UNIQUE NOT NULL
REFERENCES public.star_gift_collectible_revisions(id) ON DELETE RESTRICT,
reason text DEFAULT 'insufficient distinct upgrade preview attributes' NOT NULL,
repaired_at timestamp with time zone DEFAULT now() NOT NULL
);
INSERT INTO public.star_gift_collectible_preview_repairs (gift_id, collectible_revision_id)
SELECT c.gift_id, c.collectible_revision_id
FROM public.star_gift_catalog c
JOIN public.star_gift_collectible_revisions r ON r.id = c.collectible_revision_id
WHERE c.collectible_revision_id IS NOT NULL
AND (
r.status <> 'published' OR r.gift_id <> c.gift_id OR
(SELECT count(DISTINCT m.document_id)
FROM public.star_gift_collectible_models m
WHERE m.collectible_revision_id = r.id
AND m.rarity_kind = 'permille' AND NOT m.crafted) < 2 OR
(SELECT count(DISTINCT p.document_id)
FROM public.star_gift_collectible_patterns p
WHERE p.collectible_revision_id = r.id
AND p.rarity_kind = 'permille') < 2 OR
(SELECT count(DISTINCT b.backdrop_id)
FROM public.star_gift_collectible_backdrops b
WHERE b.collectible_revision_id = r.id
AND b.rarity_kind = 'permille') < 2
);
UPDATE public.star_gift_catalog c
SET collectible_revision_id = NULL, updated_at = now()
FROM public.star_gift_collectible_preview_repairs repair
WHERE c.gift_id = repair.gift_id
AND c.collectible_revision_id = repair.collectible_revision_id;
-- Keep the same invariant at the final activation boundary. Application validation gives the
-- operator a precise error first; this trigger also protects imports or maintenance SQL that
-- attempts to expose a malformed published revision directly.
CREATE FUNCTION public.telesrv_validate_collectible_preview_activation() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
revision_gift_id bigint;
revision_status text;
BEGIN
IF NEW.collectible_revision_id IS NULL THEN
RETURN NEW;
END IF;
SELECT gift_id, status INTO revision_gift_id, revision_status
FROM public.star_gift_collectible_revisions
WHERE id = NEW.collectible_revision_id;
IF NOT FOUND OR revision_gift_id <> NEW.gift_id OR revision_status <> 'published' THEN
RAISE EXCEPTION 'collectible preview revision must be published for the same gift'
USING ERRCODE = '23514';
END IF;
IF (SELECT count(DISTINCT document_id)
FROM public.star_gift_collectible_models
WHERE collectible_revision_id = NEW.collectible_revision_id
AND rarity_kind = 'permille' AND NOT crafted) < 2 THEN
RAISE EXCEPTION 'collectible model preview requires two distinct documents'
USING ERRCODE = '23514';
END IF;
IF (SELECT count(DISTINCT document_id)
FROM public.star_gift_collectible_patterns
WHERE collectible_revision_id = NEW.collectible_revision_id
AND rarity_kind = 'permille') < 2 THEN
RAISE EXCEPTION 'collectible pattern preview requires two distinct documents'
USING ERRCODE = '23514';
END IF;
IF (SELECT count(DISTINCT backdrop_id)
FROM public.star_gift_collectible_backdrops
WHERE collectible_revision_id = NEW.collectible_revision_id
AND rarity_kind = 'permille') < 2 THEN
RAISE EXCEPTION 'collectible backdrop preview requires two distinct IDs'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER star_gift_catalog_collectible_preview_activation
BEFORE INSERT OR UPDATE OF collectible_revision_id ON public.star_gift_catalog
FOR EACH ROW EXECUTE FUNCTION public.telesrv_validate_collectible_preview_activation();

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.suggested_post_approvals;

View file

@ -0,0 +1,39 @@
-- Durable suggested-post approval/payment/publication state. The row is the
-- idempotency key for a monoforum suggestion; message/update rows remain the
-- client-visible source of truth and are written in the same transaction.
CREATE TABLE public.suggested_post_approvals (
monoforum_id bigint NOT NULL,
suggestion_message_id integer NOT NULL,
parent_channel_id bigint NOT NULL,
actor_user_id bigint NOT NULL,
payer_user_id bigint NOT NULL,
state text NOT NULL,
price_kind text NOT NULL DEFAULT '',
price_amount bigint NOT NULL DEFAULT 0,
price_nanos integer NOT NULL DEFAULT 0,
schedule_date integer NOT NULL DEFAULT 0,
approval_service_message_id integer NOT NULL DEFAULT 0,
published_message_id integer NOT NULL DEFAULT 0,
settlement_due integer NOT NULL DEFAULT 0,
final_service_message_id integer NOT NULL DEFAULT 0,
created_at integer NOT NULL,
updated_at integer NOT NULL,
PRIMARY KEY (monoforum_id, suggestion_message_id),
CONSTRAINT suggested_post_approvals_shape_check CHECK (
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
(price_kind='stars' AND price_amount>0) OR
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
schedule_date>=0 AND approval_service_message_id>=0 AND published_message_id>=0 AND
settlement_due>=0 AND final_service_message_id>=0)
);
CREATE INDEX suggested_post_approvals_schedule_idx
ON public.suggested_post_approvals(schedule_date,monoforum_id,suggestion_message_id)
WHERE state='scheduled';
CREATE INDEX suggested_post_approvals_settlement_idx
ON public.suggested_post_approvals(settlement_due,monoforum_id,suggestion_message_id)
WHERE state='published';

View file

@ -0,0 +1,17 @@
-- The data backfill is intentionally retained on rollback. Restore only the
-- pre-0134 shape constraint, which allowed zero schedule_date in every state.
ALTER TABLE suggested_post_approvals
DROP CONSTRAINT suggested_post_approvals_shape_check;
ALTER TABLE suggested_post_approvals
ADD CONSTRAINT suggested_post_approvals_shape_check CHECK (
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
(price_kind='stars' AND price_amount>0) OR
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
schedule_date>=0 AND approval_service_message_id>=0 AND published_message_id>=0 AND
settlement_due>=0 AND final_service_message_id>=0
);

View file

@ -0,0 +1,93 @@
-- TDesktop omits schedule_date for "Publish Now", while the approval action
-- renderer always formats an absolute publication date. Backfill rows written
-- by the initial lifecycle implementation and keep current/history/difference
-- projections on the same effective timestamp.
UPDATE channel_messages m
SET suggested_post = jsonb_set(
m.suggested_post,
'{ScheduleDate}',
to_jsonb(a.created_at),
true
)
FROM suggested_post_approvals a
WHERE a.schedule_date = 0
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
AND m.channel_id = a.monoforum_id
AND m.id = a.suggestion_message_id
AND COALESCE((m.suggested_post->>'Accepted')::boolean, false)
AND COALESCE((m.suggested_post->>'ScheduleDate')::integer, 0) = 0;
UPDATE channel_messages m
SET action = jsonb_set(
m.action,
'{SuggestedPostScheduleDate}',
to_jsonb(a.created_at),
true
)
FROM suggested_post_approvals a
WHERE a.schedule_date = 0
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
AND m.channel_id = a.monoforum_id
AND m.id = a.approval_service_message_id
AND m.action->>'Type' = 'suggested_post_approval'
AND NOT COALESCE((m.action->>'SuggestedPostRejected')::boolean, false)
AND NOT COALESCE((m.action->>'SuggestedPostBalanceTooLow')::boolean, false)
AND COALESCE((m.action->>'SuggestedPostScheduleDate')::integer, 0) = 0;
UPDATE channel_update_events e
SET payload = jsonb_set(
e.payload,
'{message,SuggestedPost,ScheduleDate}',
to_jsonb(a.created_at),
true
)
FROM suggested_post_approvals a
WHERE a.schedule_date = 0
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
AND e.channel_id = a.monoforum_id
AND e.message_id = a.suggestion_message_id
AND e.event_type = 'edit_channel_message'
AND COALESCE((e.payload #>> '{message,SuggestedPost,Accepted}')::boolean, false)
AND COALESCE((e.payload #>> '{message,SuggestedPost,ScheduleDate}')::integer, 0) = 0;
UPDATE channel_update_events e
SET payload = jsonb_set(
e.payload,
'{message,Action,SuggestedPostScheduleDate}',
to_jsonb(a.created_at),
true
)
FROM suggested_post_approvals a
WHERE a.schedule_date = 0
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
AND e.channel_id = a.monoforum_id
AND e.message_id = a.approval_service_message_id
AND e.event_type = 'new_channel_message'
AND e.payload #>> '{message,Action,Type}' = 'suggested_post_approval'
AND NOT COALESCE((e.payload #>> '{message,Action,SuggestedPostRejected}')::boolean, false)
AND NOT COALESCE((e.payload #>> '{message,Action,SuggestedPostBalanceTooLow}')::boolean, false)
AND COALESCE((e.payload #>> '{message,Action,SuggestedPostScheduleDate}')::integer, 0) = 0;
UPDATE suggested_post_approvals
SET schedule_date = created_at,
updated_at = GREATEST(updated_at, created_at)
WHERE schedule_date = 0
AND state IN ('scheduled', 'published', 'completed', 'refunded');
ALTER TABLE suggested_post_approvals
DROP CONSTRAINT suggested_post_approvals_shape_check;
ALTER TABLE suggested_post_approvals
ADD CONSTRAINT suggested_post_approvals_shape_check CHECK (
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
(price_kind='stars' AND price_amount>0) OR
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
schedule_date>=0 AND
(state IN ('balance_low','rejected') OR schedule_date>0) AND
approval_service_message_id>=0 AND published_message_id>=0 AND
settlement_due>=0 AND final_service_message_id>=0
);

View file

@ -0,0 +1,4 @@
-- The up migration registers protocol identities and emits durable per-user
-- edit_message events. Removing aliases, reverting snapshots or rewinding pts
-- would invalidate messages already consumed by clients and create holes in
-- updates.getDifference, so rollback intentionally preserves the repair.

View file

@ -0,0 +1,304 @@
-- A separate prepaid-upgrade service message is another owner-local entry to
-- the same saved-gift aggregate. Earlier writes persisted gift_msg_id in the
-- receiver projection but did not register that message id, so clients that
-- submitted the visible card id received STARGIFT_INVALID. If the gift was
-- upgraded through the original id, the prepaid card also remained actionable.
--
-- Repair aliases and already-upgraded projections atomically. Durable edit
-- events make history, online delivery and updates.getDifference converge on
-- the same non-actionable snapshot. Invalid persisted shapes fail the migration
-- instead of being normalized by a read path.
LOCK TABLE public.peer_star_gifts, public.star_gift_user_message_refs,
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift'
AND box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
AND box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
AND (
jsonb_typeof(box.media #> '{service_action,star_gift,gift_id}') IS DISTINCT FROM 'number'
OR COALESCE(box.media #>> '{service_action,star_gift,gift_id}', '') !~ '^[0-9]+$'
OR (box.media #>> '{service_action,star_gift,gift_id}')::numeric <= 0
OR (box.media #>> '{service_action,star_gift,gift_id}')::numeric > 9223372036854775807
)
) THEN
RAISE EXCEPTION 'separate prepaid star gift message has malformed gift_id';
END IF;
-- gift_msg_id is receiver-only, so absence is valid on the payer box. If
-- present it must be a positive protocol int32 message id.
IF EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift'
AND box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
AND box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
AND box.media #> '{service_action,star_gift,gift_msg_id}' IS NOT NULL
AND (
jsonb_typeof(box.media #> '{service_action,star_gift,gift_msg_id}') <> 'number'
OR COALESCE(box.media #>> '{service_action,star_gift,gift_msg_id}', '') !~ '^[0-9]+$'
OR (box.media #>> '{service_action,star_gift,gift_msg_id}')::numeric <= 0
OR (box.media #>> '{service_action,star_gift,gift_msg_id}')::numeric > 2147483647
)
) THEN
RAISE EXCEPTION 'separate prepaid star gift message has malformed gift_msg_id';
END IF;
END
$$;
CREATE TEMP TABLE star_gift_prepaid_message_aliases ON COMMIT DROP AS
SELECT DISTINCT owner_box.owner_user_id,
owner_box.box_id,
gift.id AS saved_gift_id,
owner_box.message_sender_id,
owner_box.private_message_id
FROM public.message_boxes owner_box
JOIN public.peer_star_gifts gift
ON gift.owner_peer_type = 'user'
AND gift.owner_peer_id = owner_box.owner_user_id
AND gift.lifecycle_status = 'active'
AND gift.msg_id = (owner_box.media #>> '{service_action,star_gift,gift_msg_id}')::integer
AND gift.gift_id = (owner_box.media #>> '{service_action,star_gift,gift_id}')::bigint
WHERE NOT owner_box.deleted
AND owner_box.media #>> '{service_action,kind}' = 'star_gift'
AND owner_box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
AND owner_box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
AND owner_box.media #> '{service_action,star_gift,gift_msg_id}' IS NOT NULL;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM star_gift_prepaid_message_aliases
GROUP BY owner_user_id, box_id
HAVING COUNT(DISTINCT saved_gift_id) <> 1
) THEN
RAISE EXCEPTION 'separate prepaid star gift message resolves to multiple aggregates';
END IF;
IF EXISTS (
SELECT 1
FROM star_gift_prepaid_message_aliases alias
JOIN public.star_gift_user_message_refs ref
ON ref.owner_user_id = alias.owner_user_id
AND ref.msg_id = alias.box_id
WHERE ref.saved_gift_id <> alias.saved_gift_id
) THEN
RAISE EXCEPTION 'separate prepaid star gift message collides with another aggregate';
END IF;
-- Both boxes of the logical private message must retain the same prepayment
-- identity. The receiver-only gift_msg_id may differ by design.
IF EXISTS (
SELECT 1
FROM star_gift_prepaid_message_aliases alias
JOIN public.peer_star_gifts gift ON gift.id = alias.saved_gift_id
JOIN public.message_boxes visible_box
ON visible_box.message_sender_id = alias.message_sender_id
AND visible_box.private_message_id = alias.private_message_id
AND NOT visible_box.deleted
WHERE visible_box.media #>> '{service_action,kind}' IS DISTINCT FROM 'star_gift'
OR visible_box.media #>> '{service_action,star_gift,prepaid_upgrade}' IS DISTINCT FROM 'true'
OR visible_box.media #>> '{service_action,star_gift,upgrade_separate}' IS DISTINCT FROM 'true'
OR visible_box.media #>> '{service_action,star_gift,gift_id}' IS DISTINCT FROM gift.gift_id::text
) THEN
RAISE EXCEPTION 'separate prepaid star gift private projections disagree';
END IF;
END
$$;
CREATE UNIQUE INDEX star_gift_prepaid_message_aliases_owner_msg_idx
ON star_gift_prepaid_message_aliases(owner_user_id, box_id);
INSERT INTO public.star_gift_user_message_refs(owner_user_id, msg_id, saved_gift_id)
SELECT owner_user_id, box_id, saved_gift_id
FROM star_gift_prepaid_message_aliases
ON CONFLICT (owner_user_id, msg_id) DO UPDATE
SET saved_gift_id = EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id = EXCLUDED.saved_gift_id;
COMMENT ON TABLE public.star_gift_user_message_refs IS
'Owner-local service-message aliases (unique outputs and separate prepaid-upgrade notifications) for one saved gift aggregate.';
CREATE TEMP TABLE star_gift_prepaid_message_repairs (
owner_user_id bigint NOT NULL,
box_id integer NOT NULL,
peer_type text NOT NULL,
peer_id bigint NOT NULL,
message_sender_id bigint NOT NULL,
private_message_id bigint NOT NULL,
repaired_media jsonb NOT NULL,
PRIMARY KEY (owner_user_id, box_id)
) ON COMMIT DROP;
-- Upgrade every visible copy of an already-consumed prepayment. A viewer gets
-- upgrade_msg_id only when that same viewer owns a box for the emitted unique
-- action. This covers the original sender while avoiding an owner-local link
-- on an unrelated third-party payer's card.
INSERT INTO star_gift_prepaid_message_repairs(
owner_user_id, box_id, peer_type, peer_id,
message_sender_id, private_message_id, repaired_media
)
SELECT visible_box.owner_user_id,
visible_box.box_id,
visible_box.peer_type,
visible_box.peer_id,
visible_box.message_sender_id,
visible_box.private_message_id,
CASE
WHEN unique_box.box_id IS NULL THEN
visible_box.media
#- '{service_action,star_gift,can_upgrade}'
#- '{service_action,star_gift,prepaid_upgrade_hash}'
#- '{service_action,star_gift,upgrade_msg_id}'
ELSE jsonb_set(
visible_box.media
#- '{service_action,star_gift,can_upgrade}'
#- '{service_action,star_gift,prepaid_upgrade_hash}',
'{service_action,star_gift,upgrade_msg_id}',
to_jsonb(unique_box.box_id::bigint),
true
)
END
FROM star_gift_prepaid_message_aliases alias
JOIN public.peer_star_gifts gift
ON gift.id = alias.saved_gift_id
AND gift.lifecycle_status = 'active'
AND gift.unique_gift_id IS NOT NULL
AND gift.upgrade_msg_id > 0
JOIN public.message_boxes owner_unique_box
ON owner_unique_box.owner_user_id = gift.owner_peer_id
AND owner_unique_box.box_id = gift.upgrade_msg_id
AND NOT owner_unique_box.deleted
AND owner_unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND owner_unique_box.media #>> '{service_action,star_gift_unique,gift,ID}' = gift.unique_gift_id::text
JOIN public.message_boxes visible_box
ON visible_box.message_sender_id = alias.message_sender_id
AND visible_box.private_message_id = alias.private_message_id
AND NOT visible_box.deleted
LEFT JOIN public.message_boxes unique_box
ON unique_box.owner_user_id = visible_box.owner_user_id
AND unique_box.message_sender_id = owner_unique_box.message_sender_id
AND unique_box.private_message_id = owner_unique_box.private_message_id
AND NOT unique_box.deleted
AND unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND unique_box.media #>> '{service_action,star_gift_unique,gift,ID}' = gift.unique_gift_id::text;
DO $$
DECLARE
repair_row record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
BEGIN
IF EXISTS (
SELECT 1
FROM star_gift_prepaid_message_aliases alias
JOIN public.peer_star_gifts gift
ON gift.id = alias.saved_gift_id
AND gift.lifecycle_status = 'active'
AND gift.unique_gift_id IS NOT NULL
WHERE NOT EXISTS (
SELECT 1
FROM star_gift_prepaid_message_repairs target_repair
WHERE target_repair.owner_user_id = alias.owner_user_id
AND target_repair.box_id = alias.box_id
)
) THEN
RAISE EXCEPTION 'upgraded star gift is missing its prepaid message repair';
END IF;
FOR repair_row IN
SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media
FROM star_gift_prepaid_message_repairs
ORDER BY owner_user_id, box_id
LOOP
INSERT INTO public.user_update_watermarks(user_id, contiguous_pts)
VALUES(repair_row.owner_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 = repair_row.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET media = repair_row.repaired_media,
pts = next_pts
WHERE owner_user_id = repair_row.owner_user_id
AND box_id = repair_row.box_id
AND NOT deleted;
INSERT INTO public.user_update_events(
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
repair_row.owner_user_id, next_pts, 1, event_date, 'edit_message',
repair_row.box_id, repair_row.peer_type, repair_row.peer_id
);
INSERT INTO public.dispatch_outbox(
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES(repair_row.owner_user_id, next_pts, 'edit_message', 0, 0);
END LOOP;
END
$$;
-- private_messages is a shared logical envelope and cannot retain either
-- participant's box-local gift_msg_id or upgrade_msg_id.
WITH shared_repairs AS (
SELECT DISTINCT ON (repair.message_sender_id, repair.private_message_id)
repair.message_sender_id,
repair.private_message_id,
repair.repaired_media
#- '{service_action,star_gift,saved_id}'
#- '{service_action,star_gift,gift_msg_id}'
#- '{service_action,star_gift,upgrade_msg_id}' AS shared_media
FROM star_gift_prepaid_message_repairs repair
ORDER BY repair.message_sender_id,
repair.private_message_id,
(repair.owner_user_id = repair.message_sender_id) DESC,
repair.owner_user_id
)
UPDATE public.private_messages private_message
SET media = repair.shared_media
FROM shared_repairs repair
WHERE private_message.sender_user_id = repair.message_sender_id
AND private_message.id = repair.private_message_id;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM star_gift_prepaid_message_aliases alias
LEFT JOIN public.star_gift_user_message_refs ref
ON ref.owner_user_id = alias.owner_user_id
AND ref.msg_id = alias.box_id
AND ref.saved_gift_id = alias.saved_gift_id
WHERE ref.saved_gift_id IS NULL
) THEN
RAISE EXCEPTION 'separate prepaid star gift alias repair did not converge';
END IF;
IF EXISTS (
SELECT 1
FROM star_gift_prepaid_message_repairs repair
JOIN public.message_boxes box
ON box.owner_user_id = repair.owner_user_id
AND box.box_id = repair.box_id
WHERE box.media IS DISTINCT FROM repair.repaired_media
OR box.media #> '{service_action,star_gift,can_upgrade}' IS NOT NULL
OR box.media #> '{service_action,star_gift,prepaid_upgrade_hash}' IS NOT NULL
) THEN
RAISE EXCEPTION 'upgraded prepaid star gift projection repair did not converge';
END IF;
END
$$;

View file

@ -0,0 +1,9 @@
ALTER TABLE public.channels
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
DROP COLUMN IF EXISTS scam,
DROP COLUMN IF EXISTS fake;
ALTER TABLE public.users
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
DROP COLUMN IF EXISTS scam,
DROP COLUMN IF EXISTS fake;

View file

@ -0,0 +1,19 @@
-- SCAM / FAKE moderation flags for users (incl. bots) and channels.
-- Mirrors the Layer 228 user.scam/user.fake and channel.scam/channel.fake TL flags.
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
UPDATE public.users SET fake = false WHERE scam AND fake;
ALTER TABLE public.users
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
ADD CONSTRAINT users_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));
ALTER TABLE public.channels
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
UPDATE public.channels SET fake = false WHERE scam AND fake;
ALTER TABLE public.channels
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
ADD CONSTRAINT channels_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));

View file

@ -0,0 +1,2 @@
ALTER TABLE public.channels
DROP COLUMN IF EXISTS gigagroup;

View file

@ -0,0 +1,3 @@
-- gigagroup flag for supergroups (Layer 228 channel.gigagroup).
ALTER TABLE public.channels
ADD COLUMN IF NOT EXISTS gigagroup boolean DEFAULT false NOT NULL;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.star_gift_admin_grant_commands;

View file

@ -0,0 +1,22 @@
-- Direct admin collectible grants are one idempotent aggregate: unique
-- issuance, saved ownership, private message, pts/outbox and this receipt.
CREATE TABLE public.star_gift_admin_grant_commands (
recipient_user_id bigint NOT NULL,
command_key text NOT NULL,
request_fingerprint bytea NOT NULL,
sender_user_id bigint NOT NULL,
gift_id bigint NOT NULL,
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_admin_grant_commands_pkey PRIMARY KEY (recipient_user_id, command_key),
CONSTRAINT star_gift_admin_grant_command_saved_uniq UNIQUE (saved_gift_id),
CONSTRAINT star_gift_admin_grant_command_unique_uniq UNIQUE (unique_gift_id),
CONSTRAINT star_gift_admin_grant_command_shape_check CHECK (
recipient_user_id > 0
AND sender_user_id = 777000
AND gift_id > 0
AND char_length(command_key) BETWEEN 1 AND 256
AND octet_length(request_fingerprint) = 32
)
);