feat: add NFT usernames and bot verification (#22)

Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View file

@ -0,0 +1,45 @@
-- Restore the single-username-per-peer invariant. Collectible registry rows are
-- dropped first so the old unique index can be recreated; the assets and their
-- provenance log are then removed with the tables.
DELETE FROM public.peer_usernames WHERE collectible_id IS NOT NULL OR NOT editable;
DROP INDEX IF EXISTS public.peer_usernames_collectible_idx;
DROP INDEX IF EXISTS public.peer_usernames_peer_order_idx;
DROP INDEX IF EXISTS public.peer_usernames_peer_editable_idx;
ALTER TABLE public.peer_usernames
DROP CONSTRAINT IF EXISTS peer_usernames_sort_order_check,
DROP CONSTRAINT IF EXISTS peer_usernames_collectible_not_editable_check,
DROP CONSTRAINT IF EXISTS peer_usernames_username_case_check;
ALTER TABLE public.peer_usernames
DROP COLUMN IF EXISTS collectible_id,
DROP COLUMN IF EXISTS sort_order,
DROP COLUMN IF EXISTS editable,
DROP COLUMN IF EXISTS active,
DROP COLUMN IF EXISTS username;
CREATE UNIQUE INDEX IF NOT EXISTS peer_usernames_peer_unique_idx
ON public.peer_usernames (peer_type, peer_id);
CREATE OR REPLACE FUNCTION public.delete_user_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
DELETE FROM public.peer_usernames WHERE peer_type = 'user' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
CREATE OR REPLACE FUNCTION public.delete_channel_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
DELETE FROM public.peer_usernames WHERE peer_type = 'channel' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
DROP TABLE IF EXISTS public.collectible_username_transfers;
DROP TABLE IF EXISTS public.collectible_usernames;

View file

@ -0,0 +1,194 @@
-- Collectible (Fragment-style) usernames.
--
-- A peer keeps exactly one editable username -- the slot that
-- account.updateUsername / channels.updateUsername owns -- plus any number of
-- collectible usernames it holds. peer_usernames stays the single authoritative
-- registry for global uniqueness across users and channels, so ResolveUsername,
-- public landing pages and occupancy checks keep working for collectible names
-- without a second lookup path.
--
-- The pre-existing one-row-per-peer unique index is replaced by a partial index
-- covering only editable rows: the old invariant is preserved exactly for the
-- editable slot while collectible rows are free to accumulate.
--
-- Ownership lives in collectible_usernames (the asset) and is projected into
-- peer_usernames (the registry). A collectible row in the registry always
-- carries collectible_id and is never editable, so client-driven username edits
-- cannot mutate or release an owned asset.
CREATE TABLE public.collectible_usernames (
id bigserial PRIMARY KEY,
username text NOT NULL,
username_lower text NOT NULL CHECK (
username_lower <> '' AND lower(username) = username_lower
),
status text NOT NULL CHECK (status IN ('vault', 'owned', 'burned')),
-- Empty owner is the vault/burned state; 'owned' always has a real peer.
owner_peer_type text NOT NULL CHECK (owner_peer_type IN ('', 'user', 'channel')),
owner_peer_id bigint NOT NULL CHECK (owner_peer_id >= 0),
CHECK (
(status = 'owned' AND owner_peer_type <> '' AND owner_peer_id > 0)
OR (status <> 'owned' AND owner_peer_type = '' AND owner_peer_id = 0)
),
-- fragment.collectibleInfo projection. Amounts are minor units for fiat and
-- nanotons for TON, matching the star gift lifecycle ledger convention.
purchase_date timestamptz NOT NULL,
currency text NOT NULL CHECK (currency IN ('XTR', 'TON', 'USD')),
amount bigint NOT NULL CHECK (amount >= 0),
crypto_currency text NOT NULL DEFAULT '' CHECK (crypto_currency IN ('', 'TON')),
crypto_amount bigint NOT NULL DEFAULT 0 CHECK (crypto_amount >= 0),
CHECK (
(crypto_currency = '' AND crypto_amount = 0)
OR (crypto_currency <> '' AND crypto_amount > 0)
),
url text NOT NULL DEFAULT '' CHECK (octet_length(url) <= 512),
-- Provenance: the first holder, kept even after transfers and burns.
original_owner_peer_type text NOT NULL DEFAULT '' CHECK (
original_owner_peer_type IN ('', 'user', 'channel')
),
original_owner_peer_id bigint NOT NULL DEFAULT 0 CHECK (original_owner_peer_id >= 0),
transfer_count integer NOT NULL DEFAULT 0 CHECK (transfer_count >= 0),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (updated_at >= created_at)
);
-- A burned asset releases the name for a fresh issue while retaining immutable
-- provenance. At most one live asset may own a name; any number of burned
-- historical rows may remain.
CREATE UNIQUE INDEX collectible_usernames_live_name_idx
ON public.collectible_usernames (username_lower)
WHERE status <> 'burned';
CREATE INDEX collectible_usernames_name_history_idx
ON public.collectible_usernames (username_lower, id DESC);
CREATE INDEX collectible_usernames_owner_idx
ON public.collectible_usernames (owner_peer_type, owner_peer_id, id DESC)
WHERE status = 'owned';
CREATE INDEX collectible_usernames_status_idx
ON public.collectible_usernames (status, id DESC);
-- Append-only provenance log. command_key makes admin mint/transfer/revoke
-- replay-safe the same way star_gift_admin_grant_commands does for gifts.
CREATE TABLE public.collectible_username_transfers (
id bigserial PRIMARY KEY,
collectible_id bigint NOT NULL REFERENCES public.collectible_usernames(id)
ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('mint', 'transfer', 'revoke', 'burn')),
from_peer_type text NOT NULL CHECK (from_peer_type IN ('', 'user', 'channel')),
from_peer_id bigint NOT NULL CHECK (from_peer_id >= 0),
to_peer_type text NOT NULL CHECK (to_peer_type IN ('', 'user', 'channel')),
to_peer_id bigint NOT NULL CHECK (to_peer_id >= 0),
currency text NOT NULL DEFAULT '' CHECK (currency IN ('', 'XTR', 'TON', 'USD')),
amount bigint NOT NULL DEFAULT 0 CHECK (amount >= 0),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
command_key text CHECK (command_key IS NULL OR octet_length(command_key) BETWEEN 1 AND 128),
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX collectible_username_transfers_command_idx
ON public.collectible_username_transfers (command_key)
WHERE command_key IS NOT NULL;
CREATE INDEX collectible_username_transfers_asset_idx
ON public.collectible_username_transfers (collectible_id, id DESC);
ALTER TABLE public.peer_usernames
ADD COLUMN username text NOT NULL DEFAULT '',
ADD COLUMN active boolean NOT NULL DEFAULT true,
ADD COLUMN editable boolean NOT NULL DEFAULT true,
ADD COLUMN sort_order integer NOT NULL DEFAULT 0,
ADD COLUMN collectible_id bigint REFERENCES public.collectible_usernames(id)
ON DELETE CASCADE;
-- Recover the original-case display form for rows written before the column
-- existed; fall back to the lowercase key when the peer row is already gone.
UPDATE public.peer_usernames pu
SET username = u.username
FROM public.users u
WHERE pu.peer_type = 'user'
AND pu.peer_id = u.id
AND pu.username = ''
AND lower(u.username) = pu.username_lower;
UPDATE public.peer_usernames pu
SET username = c.username
FROM public.channels c
WHERE pu.peer_type = 'channel'
AND pu.peer_id = c.id
AND pu.username = ''
AND lower(COALESCE(c.username, '')) = pu.username_lower;
UPDATE public.peer_usernames
SET username = username_lower
WHERE username = '';
ALTER TABLE public.peer_usernames
ALTER COLUMN username DROP DEFAULT,
ADD CONSTRAINT peer_usernames_username_case_check
CHECK (lower(username) = username_lower),
ADD CONSTRAINT peer_usernames_collectible_not_editable_check
CHECK (collectible_id IS NULL OR NOT editable),
ADD CONSTRAINT peer_usernames_sort_order_check
CHECK (sort_order >= 0 AND sort_order <= 1024);
DROP INDEX IF EXISTS public.peer_usernames_peer_unique_idx;
CREATE UNIQUE INDEX peer_usernames_peer_editable_idx
ON public.peer_usernames (peer_type, peer_id)
WHERE editable;
CREATE INDEX peer_usernames_peer_order_idx
ON public.peer_usernames (peer_type, peer_id, sort_order, username_lower);
CREATE UNIQUE INDEX peer_usernames_collectible_idx
ON public.peer_usernames (collectible_id)
WHERE collectible_id IS NOT NULL;
-- Peer deletion must not destroy a collectible asset: the registry row goes
-- away with the peer, the asset returns to the vault and keeps its provenance.
CREATE OR REPLACE FUNCTION public.delete_user_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE public.collectible_usernames
SET status = 'vault',
owner_peer_type = '',
owner_peer_id = 0,
version = version + 1,
updated_at = now()
WHERE status = 'owned'
AND owner_peer_type = 'user'
AND owner_peer_id = OLD.id;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
CREATE OR REPLACE FUNCTION public.delete_channel_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE public.collectible_usernames
SET status = 'vault',
owner_peer_type = '',
owner_peer_id = 0,
version = version + 1,
updated_at = now()
WHERE status = 'owned'
AND owner_peer_type = 'channel'
AND owner_peer_id = OLD.id;
DELETE FROM public.peer_usernames
WHERE peer_type = 'channel' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;

View file

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS public.moderation_cases_target_history_idx;
DROP TABLE IF EXISTS public.account_rating_events;
DROP TABLE IF EXISTS public.account_rating;

View file

@ -0,0 +1,73 @@
-- Server-local composite account rating for moderation/operations in the admin
-- panel. This is intentionally not projected into Telegram's userFull
-- stars_rating/stars_my_pending_rating fields: those fields describe official
-- Stars transaction-volume semantics, not this activity/moderation score.
--
-- account_rating is a derived read model: it can always be rebuilt from the
-- contributing sources (stars_transactions, message counts, moderation state)
-- plus the manual adjustments recorded in account_rating_events. Every stored
-- component is kept separately so the admin panel can show why a level was
-- reached, and so recomputing one signal never silently discards another.
--
-- 'stars' is the composite score used by the local admin model, not a wallet
-- balance and not an official Telegram Stars Rating value.
CREATE TABLE public.account_rating (
user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
level integer NOT NULL DEFAULT 0 CHECK (level >= 0),
stars bigint NOT NULL DEFAULT 0,
current_level_stars bigint NOT NULL DEFAULT 0 CHECK (current_level_stars >= 0),
-- NULL means the top local admin level has been reached.
next_level_stars bigint CHECK (next_level_stars IS NULL OR next_level_stars > 0),
CHECK (next_level_stars IS NULL OR next_level_stars > current_level_stars),
-- Signed contributions. penalty_component is stored as a non-negative
-- magnitude and subtracted, so an audit never has to guess the sign.
stars_component bigint NOT NULL DEFAULT 0 CHECK (stars_component >= 0),
activity_component bigint NOT NULL DEFAULT 0 CHECK (activity_component >= 0),
penalty_component bigint NOT NULL DEFAULT 0 CHECK (penalty_component >= 0),
manual_component bigint NOT NULL DEFAULT 0,
-- Rating earned but not yet applied to the visible level.
pending_stars bigint NOT NULL DEFAULT 0,
pending_date timestamptz,
CHECK ((pending_stars = 0 AND pending_date IS NULL) OR (pending_stars <> 0 AND pending_date IS NOT NULL)),
computed_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0)
);
CREATE INDEX account_rating_leaderboard_idx
ON public.account_rating (level DESC, stars DESC, user_id);
CREATE INDEX account_rating_stale_idx
ON public.account_rating (computed_at, user_id);
-- Append-only contribution log. 'manual' rows are admin adjustments and are the
-- only rows that survive a full recompute; command_key gives them the same
-- replay safety as other admin commands.
CREATE TABLE public.account_rating_events (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('stars', 'activity', 'moderation', 'manual', 'recompute')),
amount bigint NOT NULL,
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
command_key text CHECK (command_key IS NULL OR octet_length(command_key) BETWEEN 1 AND 128),
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX account_rating_events_command_idx
ON public.account_rating_events (command_key)
WHERE command_key IS NOT NULL;
CREATE INDEX account_rating_events_user_idx
ON public.account_rating_events (user_id, id DESC);
CREATE INDEX account_rating_events_kind_idx
ON public.account_rating_events (kind, created_at DESC, id DESC);
-- Rating recompute counts upheld cases per target. The existing target index is
-- partial on the undecided states, so without this one the count degrades to a
-- sequential scan on every recompute.
CREATE INDEX moderation_cases_target_history_idx
ON public.moderation_cases (target_peer_type, target_peer_id)
WHERE status IN ('action_pending', 'action_failed', 'resolved');

View file

@ -0,0 +1,9 @@
-- Remove the built-in @verifybot seed. Its private history is left alone: chat
-- rows reference the account, and dropping them would rewrite users' dialogs.
DELETE FROM public.read_model_versions
WHERE owner_user_id = 1250000011 AND peer_type = 'user' AND peer_id = 1250000011;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = 1250000011;
DELETE FROM public.bots WHERE bot_user_id = 1250000011;

View file

@ -0,0 +1,89 @@
-- Built-in @verifybot: the front door for official platform verification.
--
-- Seeded here rather than lazily on first message so the username is occupied
-- from the moment the schema is current: peer_usernames.username_lower is the
-- only thing standing between a reserved bot handle and an ordinary user
-- claiming it, and a lazily created account would leave that window open.
--
-- access_hash is double-written with domain.VerifyBotAccessHash; the two must
-- never drift, exactly as for the other service bots (0044, 0045, 0047).
--
-- The peer_usernames insert carries the multi-username registry columns added in
-- 0149, so the handle occupies the editable slot.
INSERT INTO public.users (
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
) VALUES (
1250000011, 7802113947355620887, '', 'Verify Bot', '', 'verifybot', '',
now(), now(), true, false,
'Apply for official verification of a public channel, supergroup or bot.',
0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = GREATEST(public.users.bot_info_version, EXCLUDED.bot_info_version),
updated_at = now();
INSERT INTO public.bots (
bot_user_id, owner_user_id, token_secret, description, commands,
bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at,
menu_button_type, menu_button_text, menu_button_url, bot_inline_geo
) VALUES (
1250000011, 1250000011, '',
'Apply for official verification of a public channel, supergroup or bot. The bot collects the application and reports the decision back to you.',
'[
{"command": "start", "description": "how verification works"},
{"command": "new", "description": "file a verification application"},
{"command": "status", "description": "check your applications"},
{"command": "cancel", "description": "cancel the current application"},
{"command": "help", "description": "show help"}
]'::jsonb,
false, true, '', now(), now(), 0, '', '', false
)
ON CONFLICT (bot_user_id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
token_secret = EXCLUDED.token_secret,
description = EXCLUDED.description,
commands = EXCLUDED.commands,
bot_chat_history = EXCLUDED.bot_chat_history,
bot_nochats = EXCLUDED.bot_nochats,
inline_placeholder = EXCLUDED.inline_placeholder,
menu_button_type = EXCLUDED.menu_button_type,
menu_button_text = EXCLUDED.menu_button_text,
menu_button_url = EXCLUDED.menu_button_url,
bot_inline_geo = EXCLUDED.bot_inline_geo,
updated_at = now();
INSERT INTO public.peer_usernames (
username_lower, username, peer_type, peer_id, active, editable, sort_order, updated_at
)
VALUES ('verifybot', 'verifybot', 'user', 1250000011, true, true, 0, now())
ON CONFLICT (username_lower) DO UPDATE SET
username = EXCLUDED.username,
peer_type = EXCLUDED.peer_type,
peer_id = EXCLUDED.peer_id,
active = EXCLUDED.active,
editable = EXCLUDED.editable,
updated_at = now();
INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash)
VALUES
('contact_account', 1250000011, 'user', 1250000011, 1, now(), 2500001100001),
('channel_active_memberships', 1250000011, 'user', 1250000011, 1, now(), 2500001100002)
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = GREATEST(public.read_model_versions.version, EXCLUDED.version),
updated_at = now(),
hash = EXCLUDED.hash;

View file

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS public.verification_notification_outbox;
DROP TABLE IF EXISTS public.verification_application_events;
DROP TABLE IF EXISTS public.verification_applications;

View file

@ -0,0 +1,138 @@
-- Official platform verification applications.
--
-- The application is the durable audit subject: it is never deleted, only moved
-- through its status machine, and every transition appends an immutable row to
-- verification_application_events. Decisions additionally go through the shared
-- admin command journal (admin_commands / admin_audit_logs), so the panel keeps
-- one audit story for all operator actions.
--
-- The target is addressed by its stable peer id. target_title / target_username
-- are a submission-time snapshot for the review queue and the audit trail,
-- because a username can move between peers and a title can change after filing.
CREATE TABLE public.verification_applications (
id bigserial PRIMARY KEY,
applicant_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
target_type text NOT NULL CHECK (target_type IN ('bot', 'channel', 'supergroup', 'user')),
target_id bigint NOT NULL CHECK (target_id > 0),
target_title text NOT NULL DEFAULT '' CHECK (octet_length(target_title) <= 1024),
target_username text NOT NULL DEFAULT '' CHECK (octet_length(target_username) <= 64),
target_access_hash bigint NOT NULL DEFAULT 0,
category text NOT NULL DEFAULT '' CHECK (octet_length(category) <= 64),
description text NOT NULL DEFAULT '' CHECK (octet_length(description) <= 4096),
official_website text NOT NULL DEFAULT '' CHECK (octet_length(official_website) <= 512),
-- Links are stored as arrays rather than a child table: they are read and
-- written as one whole, are bounded, and never need to be queried across
-- applications.
social_links text[] NOT NULL DEFAULT '{}' CHECK (cardinality(social_links) <= 10),
press_links text[] NOT NULL DEFAULT '{}' CHECK (cardinality(press_links) <= 10),
additional_note text NOT NULL DEFAULT '' CHECK (octet_length(additional_note) <= 4096),
status text NOT NULL CHECK (status IN (
'draft', 'submitted', 'in_review', 'approved', 'rejected', 'cancelled'
)),
reviewer_admin_id text NOT NULL DEFAULT '' CHECK (octet_length(reviewer_admin_id) <= 128),
decision_reason text NOT NULL DEFAULT '' CHECK (octet_length(decision_reason) <= 4096),
-- internal_note is operator-only and must never be projected to the applicant.
internal_note text NOT NULL DEFAULT '' CHECK (octet_length(internal_note) <= 8192),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
submitted_at timestamptz,
reviewed_at timestamptz,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at),
-- A decided application always carries its reviewer and timestamp; a rejected
-- one additionally carries the reason the applicant is told.
CHECK (
(status IN ('approved', 'rejected')) =
(reviewed_at IS NOT NULL AND reviewer_admin_id <> '')
),
CHECK (status <> 'rejected' OR decision_reason <> ''),
CHECK (status = 'draft' OR submitted_at IS NOT NULL)
);
-- Exactly one live application per target. Draft, submitted and in_review are
-- the occupying states; decided and cancelled ones are history and do not block
-- a fresh attempt.
CREATE UNIQUE INDEX verification_applications_active_target_idx
ON public.verification_applications (target_type, target_id)
WHERE status IN ('draft', 'submitted', 'in_review');
-- One draft per applicant: the bot dialog is a single conversation, so a second
-- draft would have no way to be addressed.
CREATE UNIQUE INDEX verification_applications_applicant_draft_idx
ON public.verification_applications (applicant_user_id)
WHERE status = 'draft';
CREATE INDEX verification_applications_queue_idx
ON public.verification_applications (status, created_at DESC, id DESC);
CREATE INDEX verification_applications_applicant_idx
ON public.verification_applications (applicant_user_id, id DESC);
CREATE INDEX verification_applications_target_idx
ON public.verification_applications (target_type, target_id, id DESC);
CREATE INDEX verification_applications_reviewer_idx
ON public.verification_applications (reviewer_admin_id, reviewed_at DESC, id DESC)
WHERE reviewer_admin_id <> '';
-- Username search in the review queue is a prefix match on the snapshot.
CREATE INDEX verification_applications_username_idx
ON public.verification_applications (lower(target_username))
WHERE target_username <> '';
-- Cooldown lookups after a rejection: newest decision per applicant+target.
CREATE INDEX verification_applications_cooldown_idx
ON public.verification_applications (applicant_user_id, target_type, target_id, reviewed_at DESC)
WHERE status = 'rejected';
-- Immutable per-application history. Rows are append-only: there is no UPDATE or
-- DELETE path in the store, and the panel renders this as the application
-- timeline.
CREATE TABLE public.verification_application_events (
id bigserial PRIMARY KEY,
application_id bigint NOT NULL REFERENCES public.verification_applications(id)
ON DELETE RESTRICT,
kind text NOT NULL CHECK (kind IN (
'created', 'updated', 'submitted', 'claimed', 'approved', 'rejected',
'cancelled', 'revoked', 'notified'
)),
from_status text NOT NULL DEFAULT '' CHECK (octet_length(from_status) <= 32),
to_status text NOT NULL DEFAULT '' CHECK (octet_length(to_status) <= 32),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 4096),
note text NOT NULL DEFAULT '' CHECK (octet_length(note) <= 8192),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL
);
CREATE INDEX verification_application_events_app_idx
ON public.verification_application_events (application_id, id DESC);
CREATE INDEX verification_application_events_actor_idx
ON public.verification_application_events (actor, created_at DESC, id DESC)
WHERE actor <> '';
-- Applicant notifications are delivered by @verifybot after the decision commits.
-- The outbox keeps that delivery exactly-once across restarts and makes a
-- repeated approve idempotent: the unique key is the decision, not the attempt.
CREATE TABLE public.verification_notification_outbox (
id bigserial PRIMARY KEY,
application_id bigint NOT NULL REFERENCES public.verification_applications(id)
ON DELETE CASCADE,
recipient_user_id bigint NOT NULL CHECK (recipient_user_id > 0),
kind text NOT NULL CHECK (kind IN ('approved', 'rejected', 'revoked')),
payload jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (
jsonb_typeof(payload) = 'object' AND octet_length(payload::text) <= 8192
),
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
delivered_at timestamptz,
last_error text NOT NULL DEFAULT '' CHECK (octet_length(last_error) <= 1024),
created_at timestamptz NOT NULL,
CONSTRAINT verification_notification_once UNIQUE (application_id, kind)
);
CREATE INDEX verification_notification_pending_idx
ON public.verification_notification_outbox (created_at, id)
WHERE delivered_at IS NULL;

View file

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS public.custom_verification_requests;
DROP TABLE IF EXISTS public.custom_verifications;
DROP TABLE IF EXISTS public.bot_verifier_settings;
DROP TABLE IF EXISTS public.verification_icons;

View file

@ -0,0 +1,140 @@
-- Third-party bot verification (core.telegram.org/api/bots/verification).
--
-- This is a SEPARATE mechanism from the official platform badge implemented in
-- 0153/0154. Official verification is a boolean on the peer that only the
-- operator can set and that clients render as the standard checkmark. Third-party
-- verification is an attributed mark granted by a verifier bot: it carries that
-- verifier's own custom-emoji icon and a human-readable description, renders
-- BEFORE the name, and never becomes the official checkmark. Both can coexist on
-- one peer, and neither reads the other's tables.
--
-- Layer 228 surfaces:
-- user#b1b8cc83 bot_verification_icon:flags2.14?long
-- channel#d49f34c6 bot_verification_icon:flags2.13?long
-- userFull#6cbe645 bot_verification:flags2.12?BotVerification
-- channelFull#a04e8d3a bot_verification:flags2.17?BotVerification
-- chatInvite#5c9d3702 bot_verification:flags.13?BotVerification
-- botInfo#4d8a0299 verifier_settings:flags.9?BotVerifierSettings
-- The icon catalogue. An icon is a custom emoji document the client resolves with
-- messages.getCustomEmojiDocuments, so document_id must name a real document:
-- clients render nothing for an id they cannot fetch.
CREATE TABLE public.verification_icons (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL UNIQUE CHECK (document_id > 0),
-- owner_bot_id is 0 for a shared catalogue entry any verifier may use, and a
-- bot id when the operator reserved the icon for one verifier.
owner_bot_id bigint NOT NULL DEFAULT 0 CHECK (owner_bot_id >= 0),
name text NOT NULL CHECK (octet_length(name) BETWEEN 1 AND 512),
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (updated_at >= created_at)
);
CREATE INDEX verification_icons_active_idx
ON public.verification_icons (active, id DESC);
CREATE INDEX verification_icons_owner_idx
ON public.verification_icons (owner_bot_id, id DESC)
WHERE owner_bot_id <> 0;
-- Verifier status. A row here is what makes a bot a verifier: it is projected as
-- botInfo.verifier_settings and is the only authority bots.setCustomVerification
-- consults, which is why granting it is an operator action and never a bot one.
CREATE TABLE public.bot_verifier_settings (
bot_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
icon_document_id bigint NOT NULL CHECK (icon_document_id > 0),
company_name text NOT NULL CHECK (octet_length(company_name) BETWEEN 1 AND 512),
default_description text NOT NULL DEFAULT '' CHECK (octet_length(default_description) <= 280),
-- can_modify_custom_description mirrors the TL flag: when false the verifier
-- may only apply default_description, so a per-peer description cannot be
-- smuggled past the operator.
can_modify_custom_description boolean NOT NULL DEFAULT false,
enabled boolean NOT NULL DEFAULT true,
granted_by text NOT NULL DEFAULT '' CHECK (octet_length(granted_by) <= 128),
grant_reason text NOT NULL DEFAULT '' CHECK (octet_length(grant_reason) <= 4096),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at)
);
CREATE INDEX bot_verifier_settings_enabled_idx
ON public.bot_verifier_settings (enabled, bot_id);
-- Granted marks. The wire model carries one BotVerification per peer, so a new
-- verifier replaces the previous mark instead of leaving hidden rows that could
-- reappear after a later revocation.
CREATE TABLE public.custom_verifications (
id bigserial PRIMARY KEY,
verifier_bot_id bigint NOT NULL REFERENCES public.bot_verifier_settings(bot_id)
ON DELETE CASCADE,
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id bigint NOT NULL CHECK (peer_id > 0),
-- icon_document_id is denormalised from the verifier at grant time: the mark
-- must keep rendering the icon it was granted with even if the verifier later
-- changes its own.
icon_document_id bigint NOT NULL CHECK (icon_document_id > 0),
description text NOT NULL DEFAULT '' CHECK (octet_length(description) <= 4096),
granted_by_user_id bigint NOT NULL DEFAULT 0 CHECK (granted_by_user_id >= 0),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CONSTRAINT custom_verifications_peer_once UNIQUE (peer_type, peer_id),
CHECK (updated_at >= created_at)
);
-- Projection and ownership lookup by peer.
CREATE INDEX custom_verifications_peer_idx
ON public.custom_verifications (peer_type, peer_id, id DESC);
CREATE INDEX custom_verifications_verifier_idx
ON public.custom_verifications (verifier_bot_id, id DESC);
-- Applications a peer files with a verifier bot. The mark itself lives in
-- custom_verifications; this is the review queue in front of it, so a rejected or
-- revoked application stays as history without implying a mark.
CREATE TABLE public.custom_verification_requests (
id bigserial PRIMARY KEY,
verifier_bot_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
applicant_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id bigint NOT NULL CHECK (peer_id > 0),
peer_title text NOT NULL DEFAULT '' CHECK (octet_length(peer_title) <= 1024),
peer_username text NOT NULL DEFAULT '' CHECK (octet_length(peer_username) <= 64),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 16384),
requested_description text NOT NULL DEFAULT '' CHECK (octet_length(requested_description) <= 280),
status text NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'revoked')),
decided_by text NOT NULL DEFAULT '' CHECK (octet_length(decided_by) <= 128),
decision_reason text NOT NULL DEFAULT '' CHECK (octet_length(decision_reason) <= 4096),
internal_note text NOT NULL DEFAULT '' CHECK (octet_length(internal_note) <= 32768),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
approved_at timestamptz,
rejected_at timestamptz,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at),
CHECK ((status = 'approved') = (approved_at IS NOT NULL)),
CHECK ((status = 'rejected') = (rejected_at IS NOT NULL)),
CHECK (status <> 'rejected' OR decision_reason <> '')
);
-- One live application per (verifier, peer): a second pending row would let two
-- decisions race for one mark.
CREATE UNIQUE INDEX custom_verification_requests_pending_idx
ON public.custom_verification_requests (verifier_bot_id, peer_type, peer_id)
WHERE status = 'pending';
CREATE INDEX custom_verification_requests_queue_idx
ON public.custom_verification_requests (status, created_at DESC, id DESC);
CREATE INDEX custom_verification_requests_verifier_idx
ON public.custom_verification_requests (verifier_bot_id, id DESC);
CREATE INDEX custom_verification_requests_applicant_idx
ON public.custom_verification_requests (applicant_user_id, id DESC);
CREATE INDEX custom_verification_requests_peer_idx
ON public.custom_verification_requests (peer_type, peer_id, id DESC);

View file

@ -0,0 +1,12 @@
-- Remove the built-in @verifierbot seed. Its private history is left alone: chat
-- rows reference the account, and dropping them would rewrite users' dialogs.
--
-- Any verifier status an operator granted this bot lives in bot_verifier_settings
-- (0155) and cascades when this account seed is removed.
DELETE FROM public.read_model_versions
WHERE owner_user_id = 1250000013 AND peer_type = 'user' AND peer_id = 1250000013;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = 1250000013;
DELETE FROM public.bots WHERE bot_user_id = 1250000013;

View file

@ -0,0 +1,103 @@
-- Built-in @verifierbot: the applicant front door for THIRD-PARTY verification
-- (core.telegram.org/api/bots/verification).
--
-- This is the first verifier bot of a deployment, shipped so the feature has a
-- working reference: it collects applications for its own icon+description mark
-- and reports the operator's decision back to the applicant. It is deliberately
-- NOT a second way to get the platform checkmark -- @verifybot (0153) owns that
-- mechanism, and the two never read each other's state.
--
-- verified = false on purpose. The official flag is granted by the operator to
-- peers that passed platform review; a verifier bot carrying it would blur exactly
-- the distinction this bot has to explain to every applicant. What makes this
-- account a verifier is a row in bot_verifier_settings (0155), which an operator
-- grants by hand in the admin panel -- never this migration: seeding verifier
-- status here would hand out a badge printer with the schema.
--
-- Seeded rather than created lazily on first message so the username is occupied
-- from the moment the schema is current: peer_usernames.username_lower is the only
-- thing standing between a reserved bot handle and an ordinary user claiming it,
-- and a lazily created account would leave that window open.
--
-- access_hash is double-written with domain.VerifierBotAccessHash; the two must
-- never drift, exactly as for the other service bots (0044, 0045, 0047, 0153).
--
-- The peer_usernames insert carries the multi-username registry columns added in
-- 0149, so the handle occupies the editable slot.
INSERT INTO public.users (
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
) VALUES (
1250000013, 6913402578811563729, '', 'Verifier Bot', '', 'verifierbot', '',
now(), now(), false, false,
'Third-party verification: my icon before your name and a line in your profile. Not the official checkmark.',
0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = GREATEST(public.users.bot_info_version, EXCLUDED.bot_info_version),
updated_at = now();
INSERT INTO public.bots (
bot_user_id, owner_user_id, token_secret, description, commands,
bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at,
menu_button_type, menu_button_text, menu_button_url, bot_inline_geo
) VALUES (
1250000013, 1250000013, '',
'I grant third-party verification marks: my own icon before the name of your bot, channel or account, plus a description in its profile. This is not the official platform checkmark. I collect the application, an operator decides, and I message you here with the outcome.',
'[
{"command": "start", "description": "what a third-party mark is"},
{"command": "verify", "description": "apply for the mark"},
{"command": "status", "description": "your applications and marks"},
{"command": "revoke", "description": "remove a mark from your peer"},
{"command": "help", "description": "show help"}
]'::jsonb,
false, true, '', now(), now(), 0, '', '', false
)
ON CONFLICT (bot_user_id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
token_secret = EXCLUDED.token_secret,
description = EXCLUDED.description,
commands = EXCLUDED.commands,
bot_chat_history = EXCLUDED.bot_chat_history,
bot_nochats = EXCLUDED.bot_nochats,
inline_placeholder = EXCLUDED.inline_placeholder,
menu_button_type = EXCLUDED.menu_button_type,
menu_button_text = EXCLUDED.menu_button_text,
menu_button_url = EXCLUDED.menu_button_url,
bot_inline_geo = EXCLUDED.bot_inline_geo,
updated_at = now();
INSERT INTO public.peer_usernames (
username_lower, username, peer_type, peer_id, active, editable, sort_order, updated_at
)
VALUES ('verifierbot', 'verifierbot', 'user', 1250000013, true, true, 0, now())
ON CONFLICT (username_lower) DO UPDATE SET
username = EXCLUDED.username,
peer_type = EXCLUDED.peer_type,
peer_id = EXCLUDED.peer_id,
active = EXCLUDED.active,
editable = EXCLUDED.editable,
updated_at = now();
INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash)
VALUES
('contact_account', 1250000013, 'user', 1250000013, 1, now(), 2500001300001),
('channel_active_memberships', 1250000013, 'user', 1250000013, 1, now(), 2500001300002)
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = GREATEST(public.read_model_versions.version, EXCLUDED.version),
updated_at = now(),
hash = EXCLUDED.hash;

View file

@ -0,0 +1,23 @@
-- Narrowing back can only succeed once no custom-emoji reaction rows remain, so
-- drop them first: a CHECK that a stored row violates cannot be added.
DELETE FROM public.channel_message_reactions WHERE (reaction_type)::text <> 'emoji';
DELETE FROM public.user_top_reactions WHERE (reaction_type)::text <> 'emoji';
DELETE FROM public.user_recent_reactions WHERE (reaction_type)::text <> 'emoji';
ALTER TABLE public.channel_message_reactions
DROP CONSTRAINT IF EXISTS channel_message_reactions_type_check;
ALTER TABLE public.channel_message_reactions
ADD CONSTRAINT channel_message_reactions_type_check
CHECK ((reaction_type)::text = 'emoji'::text);
ALTER TABLE public.user_top_reactions
DROP CONSTRAINT IF EXISTS user_top_reactions_reaction_type_check;
ALTER TABLE public.user_top_reactions
ADD CONSTRAINT user_top_reactions_reaction_type_check
CHECK ((reaction_type)::text = 'emoji'::text);
ALTER TABLE public.user_recent_reactions
DROP CONSTRAINT IF EXISTS user_recent_reactions_reaction_type_check;
ALTER TABLE public.user_recent_reactions
ADD CONSTRAINT user_recent_reactions_reaction_type_check
CHECK ((reaction_type)::text = 'emoji'::text);

View file

@ -0,0 +1,28 @@
-- Custom-emoji reactions on channel messages.
--
-- The squashed 0001_init schema constrains channel_message_reactions.reaction_type,
-- user_top_reactions.reaction_type and user_recent_reactions.reaction_type to
-- 'emoji' only, while the saved-tag tables already allow 'custom_emoji' and the
-- store writes reactionCustomEmoji through all of them. On a database built from
-- these migrations a custom-emoji reaction in a channel therefore fails with
-- "violates check constraint ..._type_check"; upstream's own
-- TestChannelStoreCustomEmojiReactionPolicyRoundTrips fails for exactly this
-- reason. Widen the three CHECKs to the pair the saved-tag tables already use.
ALTER TABLE public.channel_message_reactions
DROP CONSTRAINT IF EXISTS channel_message_reactions_type_check;
ALTER TABLE public.channel_message_reactions
ADD CONSTRAINT channel_message_reactions_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));
ALTER TABLE public.user_top_reactions
DROP CONSTRAINT IF EXISTS user_top_reactions_reaction_type_check;
ALTER TABLE public.user_top_reactions
ADD CONSTRAINT user_top_reactions_reaction_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));
ALTER TABLE public.user_recent_reactions
DROP CONSTRAINT IF EXISTS user_recent_reactions_reaction_type_check;
ALTER TABLE public.user_recent_reactions
ADD CONSTRAINT user_recent_reactions_reaction_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));

View file

@ -0,0 +1,5 @@
-- Nothing to restore: the rating read model is derived from live signals, so a
-- deleted projection is recomputed rather than recovered, and the seeding pass
-- deliberately never offers these accounts again. Down is a no-op rather than a
-- lie about being able to bring the rows back.
SELECT 1;

View file

@ -0,0 +1,20 @@
-- Service accounts carry no composite rating.
--
-- The rating measures what an account did with Stars, so a bot -- which does not
-- transact on its own behalf -- and the built-in service accounts -- which are
-- infrastructure rather than participants -- have no meaningful score. The seeding
-- pass now excludes both, but the platform account (777000) is not flagged is_bot,
-- so a deployment that already ran a recompute cycle has a projection row for it
-- and would show a level badge on the platform account's profile.
--
-- Drop those rows and their ledger. The read model is derived, so deleting a row
-- loses nothing that cannot be recomputed; the ledger rows go with it because a
-- manual adjustment for an account that can no longer be rated is unreachable
-- bookkeeping.
DELETE FROM public.account_rating_events
WHERE user_id IN (777000, 93372553, 1063110917, 1250000007, 1250000011, 1250000013)
OR user_id IN (SELECT id FROM public.users WHERE is_bot);
DELETE FROM public.account_rating
WHERE user_id IN (777000, 93372553, 1063110917, 1250000007, 1250000011, 1250000013)
OR user_id IN (SELECT id FROM public.users WHERE is_bot);