Merge branch 'iamxvbaba:main' into main

This commit is contained in:
梦渊 2026-07-05 00:26:33 +08:00 • committed by GitHub
commit 36bf20b341
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 2870 additions and 175 deletions

View file

@ -0,0 +1,66 @@
"AccDescrMentionDown" = "Go to next mention";
"AccDescrPageDown" = "Go to bottom";
"AccDescrPollVoteDown" = "Go to next unread poll vote";
"AccDescrReactionMentionDown" = "Go to next unread reactions";
"ArchivedChats" = "Archived Chats";
"MenuArchivedChats" = "Archived Chats";
"NewMessageTitle" = "New Message";
"NewChannel" = "New Channel";
"NewGroup" = "New Group";
"MessageUnsupported" = "Unsupported message";
"AttachTodo" = "Checklist";
"TitleNewToDoList" = "New Checklist";
"TitleEditToDoList" = "Edit Checklist";
"TitleAppendToDoList" = "Add Task";
"TitleToDoList" = "Checklist";
"TitleTask" = "Task";
"TitleAddTask" = "Add a task";
"AllowOthersAddTasks" = "Allow Others to Add Tasks";
"AriaToDoCancel" = "Cancel checklist creation";
"TitleGroupToDoList" = "Group Checklist";
"TitleUserToDoList" = "{peer}'s Checklist";
"TitleYourToDoList" = "Your Checklist";
"DescriptionCompletedToDoTasks" = "{number} of {count} completed";
"MessageActionTodoCompletionsAsDone" = "{peer} marked \"{task}\" as done";
"MessageActionTodoCompletionsAsDoneYou" = "You marked \"{task}\" as done";
"MessageActionTodoCompletionsAsDoneMultiple" = "{peer} marked {tasks} as done";
"MessageActionTodoCompletionsAsDoneMultipleYou" = "You marked {tasks} as done";
"MessageActionTodoCompletionsAsNotDone" = "{peer} marked \"{task}\" as not done";
"MessageActionTodoCompletionsAsNotDoneYou" = "You marked \"{task}\" as not done";
"MessageActionTodoCompletionsAsNotDoneMultiple" = "{peer} marked {tasks} as not done";
"MessageActionTodoCompletionsAsNotDoneMultipleYou" = "You marked {tasks} as not done";
"MessageActionTodoTaskCount#one" = "{count} task";
"MessageActionTodoTaskCount#other" = "{count} tasks";
"MenuButtonAppendTodoList" = "Add a Task";
"MessageActionAppendTodo" = "{peer} added a new task \"{task}\" to {list}";
"MessageActionAppendTodoYou" = "You added a new task \"{task}\" to {list}";
"MessageActionAppendTodoMultiple" = "{peer} added {tasks} to {list}";
"MessageActionAppendTodoMultipleYou" = "You added {tasks} to {list}";
"SubscribeToTelegramPremiumForToggleTask" = "Subscribe to **Telegram Premium** to toggle tasks";
"SubscribeToTelegramPremiumForCreateToDo" = "Subscribe to **Telegram Premium** to create Checklists";
"SubscribeToTelegramPremiumForAppendToDo" = "Subscribe to **Telegram Premium** to add tasks";
"HintTodoListTasksCount2#one" = "You can add {count} more task";
"HintTodoListTasksCount2#other" = "You can add {count} more tasks";
"ToDoListErrorChooseTitle" = "Please enter a title.";
"ToDoListErrorChooseTasks" = "Please enter at least one task.";
"PremiumPreviewTodo" = "Checklists";
"PremiumPreviewTodoDescription" = "Plan, assign, and complete tasks - seamlessly and efficiently.";
"Weekday.ShortSunday" = "Sun";
"Weekday.ShortMonday" = "Mon";
"Weekday.ShortTuesday" = "Tue";
"Weekday.ShortWednesday" = "Wed";
"Weekday.ShortThursday" = "Thu";
"Weekday.ShortFriday" = "Fri";
"Weekday.ShortSaturday" = "Sat";
"Weekday.Sunday" = "Sunday";
"Weekday.Monday" = "Monday";
"Weekday.Tuesday" = "Tuesday";
"Weekday.Wednesday" = "Wednesday";
"Weekday.Thursday" = "Thursday";
"Weekday.Friday" = "Friday";
"Weekday.Saturday" = "Saturday";
"Weekday.Today" = "Today";
"Weekday.Yesterday" = "Yesterday";

27
data/server_rsa.pem Normal file
View file

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAxF//0M0+/5PzgdNagTX+J+dJgr75ZCTuiG8i4x7YwmJF+jiO
GCjm7X7BLCaMc1+hOZYDL3+Gvle/AKykW1qouaCJMVx/H+2l8LFXLelZ2PLawTb8
A7BlTqWzL3db5BugMNWziL9TuhR8In1bwKY07QVpR9in5zjAsAGLBk+mGt0DnVyM
f1Xop2lLCFNmm0F4ykcAeaLCCIPbGWddliLY8xEEhI4GO2l1U3kZMwIOdOnAGJFt
gUAoTe+FHR6F1s9adCVZB1teL/hf9R+WmekJwygVz0MYEH7y6U49T45+/W7OF6X6
g0W0j1uSSrsY4qN7twxbTad9zdGZ7ys+9v+PuQIDAQABAoIBAAjuqO8xds0fSLM+
Dt7TuuTLw288CpIAkA0KqRaVn5xv5ej0y5nTunVRD65XbooM7oN1DF4LufBMg3af
Y7Z4EDapU7Q4Fdw47hRdrK5878ZLfaHOQ3ZTfrguF1FwZ3CfxRCTl9//e+p55g+X
jiXcKYoideB7vV9qGHGpEE7QLqkIEinMEWNaB8utctH7TXdWa4pyfIGiPrHMN2zH
hF40Ib7nJA6khtzsNCDSD9XnblENEod1E5gRsjQ9fGshdBtpDsXrLBFL7KMDDoQm
czrt/KxlZMpbtOfz6ua5eKEBBTtNutmVcp1q9y+cQpPZznYiRTL+OJzVgfuHOO/0
ea2j/wECgYEA6I6f+ELo8AP7Q4W0qssuXKvHEF/REEfx2Lueh0lrUhVR3IgjakFc
bTRlLEQK8QNbDAmNMuHrEZnjmGu7ny3zN94slC/ycI5pd9hQGVwO22er9o2+M9w2
JpB4Qf8c8SK/pHQN9+jOtBxV2drbfe0oUoKEtYyD5cbtjFT3yfeA2CECgYEA2Cuo
S841kVppx1KwpL4i1fgGQPykaKrD4oGZrYgv2FxW55i/q5FcYL6fE37JaM1po/RB
tnHg35Nang/yuZXz6Eb4K0CyL+8LuhCIb6PxDvqyCXbkXTwHSVRAnGEMSN3za/7F
FCJF1ABWg8jlKRdA/lRwqo8P8ZgBeBMqpKZh5JkCgYBhCImk2743z2F6tgJBNU/d
6OrBYUlpIqu9g+NMjYzTDgQ/ISqtviHjivYf9zAdignmRuH8dhlQGcbGJUV+0Hxo
NjKhjjP5VOKa183sFuY4E9TDpjbTirGpe6RB3UFlN9uAsc/WPfRpYF1N7iyhKWAm
tUDMQ5oROOSLJjTRt4yyIQKBgQCTfy5lEv2wAP399+j5b5a7YnF59CiGFkZ0DbP7
GNp0iYTun2Xgvd1HUamfFrp8nPQA38/amdczFgsVoJIgmTWEfrAkaw9p73ST72X4
rubzLpE+LfZhu2zJVwiC6yEDsxW90WdNdpkor1ZYs0HRiMFbB+icJ+NcGDigYouN
Ls3KuQKBgQCTAxVM7zNPgsffudZP7FMny20LMR6+FQo7xRz5ElYaCVgXM1NH4K/r
yFkOCGhON6I/MUNBPxWLEuZBRfyrly3bp3j7R6/hLX4fNyw9B3GQuVyCHD2D4tod
6J5Vo/EseqpeeKa6Cv/Y2HVB0kfGw1VCC/gM01L8ued3hsr2LD2kCw==
-----END RSA PRIVATE KEY-----

8
data/server_rsa.pub Normal file
View file

@ -0,0 +1,8 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxF//0M0+/5PzgdNagTX+J+dJgr75ZCTuiG8i4x7YwmJF+jiOGCjm
7X7BLCaMc1+hOZYDL3+Gvle/AKykW1qouaCJMVx/H+2l8LFXLelZ2PLawTb8A7Bl
TqWzL3db5BugMNWziL9TuhR8In1bwKY07QVpR9in5zjAsAGLBk+mGt0DnVyMf1Xo
p2lLCFNmm0F4ykcAeaLCCIPbGWddliLY8xEEhI4GO2l1U3kZMwIOdOnAGJFtgUAo
Te+FHR6F1s9adCVZB1teL/hf9R+WmekJwygVz0MYEH7y6U49T45+/W7OF6X6g0W0
j1uSSrsY4qN7twxbTad9zdGZ7ys+9v+PuQIDAQAB
-----END RSA PUBLIC KEY-----

View file

@ -0,0 +1,8 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxF//0M0+/5PzgdNagTX+J+dJgr75ZCTuiG8i4x7YwmJF+jiOGCjm
7X7BLCaMc1+hOZYDL3+Gvle/AKykW1qouaCJMVx/H+2l8LFXLelZ2PLawTb8A7Bl
TqWzL3db5BugMNWziL9TuhR8In1bwKY07QVpR9in5zjAsAGLBk+mGt0DnVyMf1Xo
p2lLCFNmm0F4ykcAeaLCCIPbGWddliLY8xEEhI4GO2l1U3kZMwIOdOnAGJFtgUAo
Te+FHR6F1s9adCVZB1teL/hf9R+WmekJwygVz0MYEH7y6U49T45+/W7OF6X6g0W0
j1uSSrsY4qN7twxbTad9zdGZ7ys+9v+PuQIDAQAB
-----END RSA PUBLIC KEY-----

View file

@ -1759,7 +1759,7 @@ CREATE TABLE public.channel_messages (
reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL,
from_boosts_applied integer DEFAULT 0 NOT NULL, from_boosts_applied integer DEFAULT 0 NOT NULL,
rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, rich_message jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT channel_messages_content_check CHECK (((body <> ''::text) OR (action <> '{}'::jsonb) OR (media <> '{}'::jsonb))), CONSTRAINT channel_messages_content_check CHECK (((body <> ''::text) OR (action <> '{}'::jsonb) OR (media <> '{}'::jsonb) OR (rich_message <> '{}'::jsonb))),
CONSTRAINT channel_messages_peer_type_check CHECK ((((from_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])) AND ((send_as_peer_type IS NULL) OR ((send_as_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))) AND (((reply_to_peer_type)::text = ''::text) OR ((reply_to_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))))) CONSTRAINT channel_messages_peer_type_check CHECK ((((from_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])) AND ((send_as_peer_type IS NULL) OR ((send_as_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))) AND (((reply_to_peer_type)::text = ''::text) OR ((reply_to_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))))
); );
@ -2472,7 +2472,7 @@ CREATE TABLE public.private_messages (
reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL,
via_bot_id bigint DEFAULT 0 NOT NULL, via_bot_id bigint DEFAULT 0 NOT NULL,
rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, rich_message jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT private_messages_nonempty_body CHECK (((body <> ''::text) OR (media <> '{}'::jsonb))) CONSTRAINT private_messages_nonempty_body CHECK (((body <> ''::text) OR (media <> '{}'::jsonb) OR (rich_message <> '{}'::jsonb)))
); );
@ -2596,6 +2596,7 @@ CREATE TABLE public.scheduled_messages (
body text DEFAULT ''::text NOT NULL, body text DEFAULT ''::text NOT NULL,
entities jsonb DEFAULT '[]'::jsonb NOT NULL, entities jsonb DEFAULT '[]'::jsonb NOT NULL,
media jsonb DEFAULT '{}'::jsonb NOT NULL, media jsonb DEFAULT '{}'::jsonb NOT NULL,
rich_message jsonb DEFAULT '{}'::jsonb NOT NULL,
silent boolean DEFAULT false NOT NULL, silent boolean DEFAULT false NOT NULL,
noforwards boolean DEFAULT false NOT NULL, noforwards boolean DEFAULT false NOT NULL,
reply_to_msg_id integer DEFAULT 0 NOT NULL, reply_to_msg_id integer DEFAULT 0 NOT NULL,

View file

@ -0,0 +1,20 @@
ALTER TABLE public.channel_messages
DROP CONSTRAINT IF EXISTS channel_messages_content_check;
ALTER TABLE public.channel_messages
ADD CONSTRAINT channel_messages_content_check
CHECK (
body <> ''::text
OR action <> '{}'::jsonb
OR media <> '{}'::jsonb
);
ALTER TABLE public.private_messages
DROP CONSTRAINT IF EXISTS private_messages_nonempty_body;
ALTER TABLE public.private_messages
ADD CONSTRAINT private_messages_nonempty_body
CHECK (
body <> ''::text
OR media <> '{}'::jsonb
);

View file

@ -0,0 +1,22 @@
ALTER TABLE public.channel_messages
DROP CONSTRAINT IF EXISTS channel_messages_content_check;
ALTER TABLE public.channel_messages
ADD CONSTRAINT channel_messages_content_check
CHECK (
body <> ''::text
OR action <> '{}'::jsonb
OR media <> '{}'::jsonb
OR rich_message <> '{}'::jsonb
);
ALTER TABLE public.private_messages
DROP CONSTRAINT IF EXISTS private_messages_nonempty_body;
ALTER TABLE public.private_messages
ADD CONSTRAINT private_messages_nonempty_body
CHECK (
body <> ''::text
OR media <> '{}'::jsonb
OR rich_message <> '{}'::jsonb
);

View file

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

View file

@ -0,0 +1,2 @@
ALTER TABLE public.scheduled_messages
ADD COLUMN IF NOT EXISTS rich_message jsonb DEFAULT '{}'::jsonb NOT NULL;

18
go.mod
View file

@ -12,16 +12,16 @@ require (
github.com/gotd/tl v0.4.0 github.com/gotd/tl v0.4.0
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/pion/datachannel v1.6.0 github.com/pion/datachannel v1.6.2
github.com/pion/dtls/v3 v3.1.4 github.com/pion/dtls/v3 v3.1.4
github.com/pion/ice/v4 v4.2.7 github.com/pion/ice/v4 v4.2.7
github.com/pion/logging v0.2.4 github.com/pion/logging v0.2.4
github.com/pion/rtcp v1.2.16 github.com/pion/rtcp v1.2.17
github.com/pion/rtp v1.10.2 github.com/pion/rtp v1.10.3
github.com/pion/sctp v1.10.0 github.com/pion/sctp v1.10.3
github.com/pion/srtp/v3 v3.0.11 github.com/pion/srtp/v3 v3.0.12
github.com/pion/transport/v4 v4.0.2 github.com/pion/transport/v4 v4.0.2
github.com/pion/turn/v5 v5.0.9 github.com/pion/turn/v5 v5.0.10
github.com/redis/go-redis/v9 v9.20.0 github.com/redis/go-redis/v9 v9.20.0
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.28.0 go.uber.org/zap v1.28.0
@ -48,13 +48,13 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/compress v1.19.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ogen-go/ogen v1.22.0 // indirect github.com/ogen-go/ogen v1.22.0 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v3 v3.1.5 // indirect github.com/pion/stun/v3 v3.1.6 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect github.com/refraction-networking/utls v1.8.2 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
@ -70,7 +70,7 @@ require (
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.46.0 // indirect golang.org/x/tools v0.47.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
rsc.io/qr v0.2.0 // indirect rsc.io/qr v0.2.0 // indirect
) )

36
go.sum
View file

@ -84,8 +84,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@ -110,8 +110,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
@ -122,22 +122,22 @@ github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= github.com/pion/rtp v1.10.3 h1:r5nJQdtM9Dc4ZYxtTcPPz7PIFArKJIf/DMlIUxU7+1c=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= github.com/pion/rtp v1.10.3/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg= github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI=
github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM= github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ=
github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -213,8 +213,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -891,6 +891,7 @@ func cloneDraft(draft domain.DialogDraft) domain.DialogDraft {
webpage := *draft.WebPage webpage := *draft.WebPage
draft.WebPage = &webpage draft.WebPage = &webpage
} }
draft.RichMessage = cloneRichMessage(draft.RichMessage)
return draft return draft
} }
@ -927,10 +928,53 @@ func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 {
if d.Draft.WebPage != nil { if d.Draft.WebPage != nil {
_, _ = h.Write([]byte(d.Draft.WebPage.URL)) _, _ = h.Write([]byte(d.Draft.WebPage.URL))
} }
writeDraftRichHash(h, buf[:], d.Draft.RichMessage)
} }
return int64(h.Sum64()) return int64(h.Sum64())
} }
func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
if m == nil {
return nil
}
clone := *m
clone.Blocks = append([]byte(nil), m.Blocks...)
clone.Photos = append([]domain.Photo(nil), m.Photos...)
clone.Documents = append([]domain.Document(nil), m.Documents...)
return &clone
}
func writeDraftRichHash(h interface{ Write([]byte) (int, error) }, buf []byte, rich *domain.MessageRichMessage) {
if rich.IsZero() {
return
}
if rich.Rtl {
buf[0] = 1
} else {
buf[0] = 0
}
if rich.Part {
buf[1] = 1
} else {
buf[1] = 0
}
binary.LittleEndian.PutUint64(buf[2:10], uint64(len(rich.Blocks)))
binary.LittleEndian.PutUint64(buf[10:18], uint64(len(rich.Photos)))
binary.LittleEndian.PutUint64(buf[18:26], uint64(len(rich.Documents)))
_, _ = h.Write(buf[:26])
_, _ = h.Write(rich.Blocks)
for _, photo := range rich.Photos {
binary.LittleEndian.PutUint64(buf[:8], uint64(photo.ID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(photo.AccessHash))
_, _ = h.Write(buf[:16])
}
for _, document := range rich.Documents {
binary.LittleEndian.PutUint64(buf[:8], uint64(document.ID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(document.AccessHash))
_, _ = h.Write(buf[:16])
}
}
func mergeDialogLists(out, in domain.DialogList) domain.DialogList { func mergeDialogLists(out, in domain.DialogList) domain.DialogList {
out.Dialogs = append(out.Dialogs, in.Dialogs...) out.Dialogs = append(out.Dialogs, in.Dialogs...)
out.Messages = append(out.Messages, in.Messages...) out.Messages = append(out.Messages, in.Messages...)

View file

@ -31,6 +31,9 @@ const tdesktopClient = "tdesktop"
// true(屏蔽),GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。 // true(屏蔽),GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。
// - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium // - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium
// 联动:premium 用户可在同一消息放 3 个 reaction,服务端档位必须 ≥ 该宣告值。 // 联动:premium 用户可在同一消息放 3 个 reaction,服务端档位必须 ≥ 该宣告值。
// - boosts_channel_level_max=100 必须显式下发:DrKLO 的频道自定义 reaction 编辑页
// 用它作为可选 reaction 个数的本地 LengthFilter 上限。缺 key 会保留旧偏好值;实测
// 旧值为 4 时,频道已选 4 个 reaction 后继续点新 emoji 会被客户端本地静默挡掉。
// - dialog_filters_enabled=true 必须显式下发:TDesktop settings_main.cpp:394 据此(或账号 // - dialog_filters_enabled=true 必须显式下发:TDesktop settings_main.cpp:394 据此(或账号
// 已有文件夹)才在 Settings 显示「Folders」入口,缺 key → 新账号看不到文件夹管理、无法 // 已有文件夹)才在 Settings 显示「Folders」入口,缺 key → 新账号看不到文件夹管理、无法
// 建文件夹/采纳 getSuggestedDialogFilters 模板。 // 建文件夹/采纳 getSuggestedDialogFilters 模板。
@ -43,9 +46,9 @@ const tdesktopClient = "tdesktop"
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。 // 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时 // - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
// 直接读取这些 key 做本地输入限制和示例数量。 // 直接读取这些 key 做本地输入限制和示例数量。
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000` const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
const defaultAppConfigHash = 18 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 const defaultAppConfigHash = 20 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
// Service 提供客户端启动配置与国家区号目录。 // Service 提供客户端启动配置与国家区号目录。
// //

View file

@ -29,9 +29,13 @@ func TestAppConfigPremiumKeys(t *testing.T) {
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked { if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"]) t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
} }
if posting, ok := decoded["rich_message_posting"].(string); !ok || posting != "enabled" {
t.Fatalf("rich_message_posting = %v, want enabled (TDesktop 富文本编辑入口默认打开)", decoded["rich_message_posting"])
}
wantNumbers := map[string]float64{ wantNumbers := map[string]float64{
"reactions_user_max_default": 1, "reactions_user_max_default": 1,
"reactions_user_max_premium": 3, "reactions_user_max_premium": 3,
"boosts_channel_level_max": 100,
"about_length_limit_default": 70, "about_length_limit_default": 70,
"about_length_limit_premium": 140, "about_length_limit_premium": 140,
"dialogs_pinned_limit_default": 5, "dialogs_pinned_limit_default": 5,

View file

@ -2,6 +2,7 @@ package langpack
import ( import (
"context" "context"
"strings"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store" "telesrv/internal/store"
@ -24,18 +25,41 @@ func (s *Service) GetLangPack(ctx context.Context, langPack, langCode string) (d
// GetDifference 返回从 fromVersion 到当前版本的语言包差异。 // GetDifference 返回从 fromVersion 到当前版本的语言包差异。
func (s *Service) GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { func (s *Service) GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
packName := normalizePack(langPack)
code := normalizeCode(langCode)
if s == nil || s.packs == nil { if s == nil || s.packs == nil {
return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil return domain.LangPack{LangPack: packName, LangCode: code, FromVersion: fromVersion}, nil
} }
return s.packs.GetPack(ctx, normalizePack(langPack), normalizeCode(langCode), fromVersion) pack, err := s.packs.GetPack(ctx, packName, code, fromVersion)
if err != nil {
return domain.LangPack{}, err
}
return s.overlayWebAStrings(ctx, pack, packName, code, fromVersion)
} }
// GetStrings 返回指定 key 的语言包字符串。 // GetStrings 返回指定 key 的语言包字符串。
func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
packName := normalizePack(langPack)
code := normalizeCode(langCode)
if s == nil || s.packs == nil { if s == nil || s.packs == nil {
return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil return domain.LangPack{LangPack: packName, LangCode: code}, nil
} }
return s.packs.GetStrings(ctx, normalizePack(langPack), normalizeCode(langCode), keys) pack, err := s.packs.GetStrings(ctx, packName, code, keys)
if err != nil {
return domain.LangPack{}, err
}
if len(keys) == 0 {
return s.overlayWebAStrings(ctx, pack, packName, code, 0)
}
missing := missingLangPackKeys(keys, pack.Strings)
if len(missing) == 0 || !shouldOverlayWebA(packName) {
return pack, nil
}
overlay, err := s.packs.GetStrings(ctx, "weba", code, missing)
if err != nil {
return domain.LangPack{}, err
}
return mergeMissingLangPackStrings(pack, overlay), nil
} }
func normalizePack(langPack string) string { func normalizePack(langPack string) string {
@ -46,8 +70,76 @@ func normalizePack(langPack string) string {
} }
func normalizeCode(langCode string) string { func normalizeCode(langCode string) string {
if langCode == "" { code := strings.ToLower(strings.TrimSpace(langCode))
if code == "" {
return "en" return "en"
} }
return langCode return strings.TrimSuffix(code, "-raw")
}
func shouldOverlayWebA(langPack string) bool {
switch strings.ToLower(langPack) {
case "android", "ios", "tdesktop", "macos":
return true
default:
return false
}
}
func (s *Service) overlayWebAStrings(ctx context.Context, pack domain.LangPack, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
if fromVersion != 0 || !shouldOverlayWebA(langPack) {
return pack, nil
}
overlay, err := s.packs.GetPack(ctx, "weba", langCode, fromVersion)
if err != nil {
return domain.LangPack{}, err
}
return mergeMissingLangPackStrings(pack, overlay), nil
}
func mergeMissingLangPackStrings(pack, overlay domain.LangPack) domain.LangPack {
if len(overlay.Strings) == 0 {
return pack
}
if pack.LangCode == "" {
pack.LangCode = overlay.LangCode
}
if overlay.Version > pack.Version {
pack.Version = overlay.Version
}
seen := make(map[string]struct{}, len(pack.Strings)+len(overlay.Strings))
for _, item := range pack.Strings {
seen[item.Key] = struct{}{}
}
for _, item := range overlay.Strings {
if _, ok := seen[item.Key]; ok {
continue
}
pack.Strings = append(pack.Strings, item)
seen[item.Key] = struct{}{}
}
return pack
}
func missingLangPackKeys(keys []string, strings []domain.LangPackString) []string {
if len(keys) == 0 {
return nil
}
have := make(map[string]struct{}, len(strings))
for _, item := range strings {
have[item.Key] = struct{}{}
}
missing := make([]string, 0)
seenMissing := make(map[string]struct{}, len(keys))
for _, key := range keys {
if _, ok := have[key]; ok {
continue
}
if _, ok := seenMissing[key]; ok {
continue
}
missing = append(missing, key)
seenMissing[key] = struct{}{}
}
return missing
} }

View file

@ -0,0 +1,81 @@
package langpack
import (
"context"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestServiceNormalizesWebARawLangCode(t *testing.T) {
ctx := context.Background()
packs := memory.NewLangPackStore()
svc := NewService(packs)
seed := domain.LangPack{
LangPack: "android",
LangCode: "en",
Version: 7,
Strings: []domain.LangPackString{
{Key: "LogOutTitle", Value: "Log Out"},
{Key: "NewMessageTitle", Value: "New Message"},
},
}
if err := packs.UpsertPack(ctx, seed); err != nil {
t.Fatalf("seed langpack: %v", err)
}
webASeed := domain.LangPack{
LangPack: "weba",
LangCode: "en",
Version: 12,
Strings: []domain.LangPackString{
{Key: "AccDescrPollVoteDown", Value: "Go to next unread poll vote"},
{Key: "NewMessageTitle", Value: "New Message from WebA"},
},
}
if err := packs.UpsertPack(ctx, webASeed); err != nil {
t.Fatalf("seed weba langpack: %v", err)
}
pack, err := svc.GetLangPack(ctx, "android", "EN-raw")
if err != nil {
t.Fatalf("get langpack: %v", err)
}
if pack.LangCode != "en" || pack.Version != webASeed.Version || len(pack.Strings) != len(seed.Strings)+1 {
t.Fatalf("pack = %+v, want normalized en pack", pack)
}
if got := stringValue(pack.Strings, "AccDescrPollVoteDown"); got != "Go to next unread poll vote" {
t.Fatalf("AccDescrPollVoteDown = %q, want WebA fallback", got)
}
if got := stringValue(pack.Strings, "NewMessageTitle"); got != "New Message" {
t.Fatalf("NewMessageTitle = %q, want source pack to keep precedence", got)
}
selected, err := svc.GetStrings(ctx, "android", "en-raw", []string{"LogOutTitle", "AccDescrPollVoteDown"})
if err != nil {
t.Fatalf("get strings: %v", err)
}
if got := stringValue(selected.Strings, "LogOutTitle"); got != "Log Out" {
t.Fatalf("LogOutTitle = %q, want source pack value", got)
}
if got := stringValue(selected.Strings, "AccDescrPollVoteDown"); got != "Go to next unread poll vote" {
t.Fatalf("AccDescrPollVoteDown = %q, want WebA fallback", got)
}
notModified, err := svc.GetDifference(ctx, "android", "en-raw", seed.Version)
if err != nil {
t.Fatalf("get difference: %v", err)
}
if notModified.LangCode != "en" || len(notModified.Strings) != 0 {
t.Fatalf("difference = %+v, want normalized not-modified source pack", notModified)
}
}
func stringValue(strings []domain.LangPackString, key string) string {
for _, item := range strings {
if item.Key == key {
return item.Value
}
}
return ""
}

View file

@ -7,7 +7,7 @@ import (
) )
const ( const (
appConfigHash = 13 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 appConfigHash = 15 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
countriesListHash = 1 countriesListHash = 1
timezonesListHash = 1 timezonesListHash = 1
) )
@ -38,6 +38,10 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
// stargifts_blocked=false:DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格。 // stargifts_blocked=false:DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格。
{Key: "stargifts_blocked", Value: &tg.JSONBool{Value: false}}, {Key: "stargifts_blocked", Value: &tg.JSONBool{Value: false}},
{Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}}, {Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}},
// DrKLO 频道自定义 reaction 编辑页用它作为可选 reaction 数量上限。
{Key: "boosts_channel_level_max", Value: &tg.JSONNumber{Value: 100}},
// TDesktop 富文本编辑入口:官方默认缺省 disabled,显式 enabled 才显示/允许进入编辑器。
{Key: "rich_message_posting", Value: &tg.JSONString{Value: "enabled"}},
// dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。 // dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。
{Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}}, {Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}},
{Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}}, {Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}},

View file

@ -39,11 +39,15 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got) t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got)
} }
values := make(map[string]float64) values := make(map[string]float64)
strings := make(map[string]string)
if object, ok := got.Config.(*tg.JSONObject); ok && object != nil { if object, ok := got.Config.(*tg.JSONObject); ok && object != nil {
for _, entry := range object.Value { for _, entry := range object.Value {
if number, ok := entry.Value.(*tg.JSONNumber); ok { if number, ok := entry.Value.(*tg.JSONNumber); ok {
values[entry.Key] = number.Value values[entry.Key] = number.Value
} }
if str, ok := entry.Value.(*tg.JSONString); ok {
strings[entry.Key] = str.Value
}
} }
} }
want := map[string]float64{ want := map[string]float64{
@ -56,6 +60,9 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
t.Fatalf("AppConfig[%q] = %v, want %v", key, values[key], expected) t.Fatalf("AppConfig[%q] = %v, want %v", key, values[key], expected)
} }
} }
if strings["rich_message_posting"] != "enabled" {
t.Fatalf("AppConfig[rich_message_posting] = %q, want enabled", strings["rich_message_posting"])
}
if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok { if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok {
t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash)) t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash))
} }

View file

@ -602,6 +602,8 @@ type ChannelMessage struct {
Reactions *ChannelMessageReactions Reactions *ChannelMessageReactions
Action *ChannelMessageAction Action *ChannelMessageAction
Media *MessageMedia Media *MessageMedia
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
RichMessage *MessageRichMessage
// FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。 // FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。
FromBoostsApplied int FromBoostsApplied int
TTLPeriod int TTLPeriod int
@ -1373,6 +1375,7 @@ type SendChannelMessageRequest struct {
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
GroupedID int64 GroupedID int64
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
RichMessage *MessageRichMessage
SendAs *Peer SendAs *Peer
Action *ChannelMessageAction Action *ChannelMessageAction
Date int Date int
@ -1615,6 +1618,9 @@ type EditChannelMessageRequest struct {
// SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘)。 // SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘)。
SetReplyMarkup bool SetReplyMarkup bool
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
// SetRichMessage 置位时替换 rich_message(RichMessage nil/空 = 清空富文本)。
SetRichMessage bool
RichMessage *MessageRichMessage
// ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。 // ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。
ViaBotEditBotID int64 ViaBotEditBotID int64
// AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的 // AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的

View file

@ -9,7 +9,7 @@ func ChannelBannedRightsBlockMessage(req SendChannelMessageRequest, channel Chan
return false return false
} }
if req.Media.IsZero() { if req.Media.IsZero() {
if strings.TrimSpace(req.Message) == "" { if strings.TrimSpace(req.Message) == "" && req.RichMessage.IsZero() {
return false return false
} }
return channelBannedRightsBlockWithBoost(channel, member.BannedRights.SendPlain, channel.DefaultBannedRights.SendPlain, selfBoostsApplied) return channelBannedRightsBlockWithBoost(channel, member.BannedRights.SendPlain, channel.DefaultBannedRights.SendPlain, selfBoostsApplied)

View file

@ -113,6 +113,7 @@ type DialogDraft struct {
ReplyTo *MessageReply ReplyTo *MessageReply
WebPage *DialogDraftWebPage WebPage *DialogDraftWebPage
Effect int64 Effect int64
RichMessage *MessageRichMessage
} }
// Empty reports whether this draft should clear the cloud draft slot. // Empty reports whether this draft should clear the cloud draft slot.
@ -124,7 +125,8 @@ func (d DialogDraft) Empty() bool {
len(d.Entities) == 0 && len(d.Entities) == 0 &&
(d.ReplyTo == nil || replyOnlyTopic) && (d.ReplyTo == nil || replyOnlyTopic) &&
d.WebPage == nil && d.WebPage == nil &&
d.Effect == 0 d.Effect == 0 &&
d.RichMessage.IsZero()
} }
// DialogArchiveSummary 聚合归档(folder_id=1)状态,供主列表 getDialogs // DialogArchiveSummary 聚合归档(folder_id=1)状态,供主列表 getDialogs

View file

@ -418,6 +418,10 @@ type EditMessageRequest struct {
// 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位(P3)。 // 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位(P3)。
SetReplyMarkup bool SetReplyMarkup bool
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
// SetRichMessage 置位时替换 rich_message(RichMessage nil/空 = 清空富文本)。
// TDesktop EditRichMessage 只带 f_rich_message,不带 f_message;store 不能把它误判为空编辑。
SetRichMessage bool
RichMessage *MessageRichMessage
// ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。 // ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。
ViaBotEditBotID int64 ViaBotEditBotID int64
// AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过 // AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过
@ -598,6 +602,7 @@ type ScheduledMessage struct {
Message string Message string
Entities []MessageEntity Entities []MessageEntity
Media *MessageMedia Media *MessageMedia
RichMessage *MessageRichMessage
Silent bool Silent bool
NoForwards bool NoForwards bool
ReplyTo *MessageReply ReplyTo *MessageReply
@ -626,6 +631,7 @@ type ScheduleMessageRequest struct {
Message string Message string
Entities []MessageEntity Entities []MessageEntity
Media *MessageMedia Media *MessageMedia
RichMessage *MessageRichMessage
Silent bool Silent bool
NoForwards bool NoForwards bool
ReplyTo *MessageReply ReplyTo *MessageReply
@ -639,14 +645,16 @@ type ScheduleMessageRequest struct {
// EditScheduledMessageRequest updates one pending scheduled message before it // EditScheduledMessageRequest updates one pending scheduled message before it
// enters normal history. // enters normal history.
type EditScheduledMessageRequest struct { type EditScheduledMessageRequest struct {
OwnerUserID int64 OwnerUserID int64
Peer Peer Peer Peer
ID int ID int
SetMessage bool SetMessage bool
Message string Message string
Entities []MessageEntity Entities []MessageEntity
ScheduleDate int SetRichMessage bool
Date int RichMessage *MessageRichMessage
ScheduleDate int
Date int
} }
// ScheduledMessageFilter selects scheduled messages for one owner/peer. // ScheduledMessageFilter selects scheduled messages for one owner/peer.

View file

@ -8,7 +8,6 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap"
"github.com/gotd/td/bin" "github.com/gotd/td/bin"
"github.com/gotd/td/crypto" "github.com/gotd/td/crypto"
"github.com/gotd/td/exchange" "github.com/gotd/td/exchange"
@ -54,12 +53,7 @@ func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first
} }
start := s.clock.Now() start := s.clock.Now()
res, err := exchange.NewExchanger(buffered, s.dc). res, err := s.runServerExchange(runCtx, buffered)
WithClock(s.clock).
WithRand(s.rand).
WithLogger(logzap.New(s.log.Named("exchange"))).
Server(s.key).
Run(runCtx)
if err != nil { if err != nil {
// gotd v0.158:握手中读到非零 auth_key_id 帧(客户端用既有 auth key 而非重新交换) // gotd v0.158:握手中读到非零 auth_key_id 帧(客户端用既有 auth key 而非重新交换)
// 经类型化 UnexpectedEncryptedError 暴露并随附原始帧(旧版仅靠错误文案匹配,升级后失效)。 // 经类型化 UnexpectedEncryptedError 暴露并随附原始帧(旧版仅靠错误文案匹配,升级后失效)。

View file

@ -0,0 +1,399 @@
package mtprotoedge
import (
"context"
crand "crypto/rand"
"encoding/hex"
"fmt"
"io"
"math/big"
"time"
gofaster "github.com/go-faster/errors"
"go.uber.org/zap"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/crypto"
"github.com/gotd/td/exchange"
"github.com/gotd/td/mt"
"github.com/gotd/td/proto"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/transport"
)
// runServerExchange is a gotd server exchange compatibility shim.
//
// DrKLO Android marks media temporary auth-key exchange with a negative DC in
// p_q_inner_data_temp_dc (for example DC 2 -> -2). gotd v0.158.0 validates this
// field by exact equality and rejects that legitimate media-temp path. Keep the
// permanent-key check strict, but allow temp-key DC values whose absolute value
// matches this server DC.
func (s *Server) runServerExchange(ctx context.Context, conn transport.Conn) (exchange.ServerExchangeResult, error) {
ex := serverExchangeCompat{
conn: conn,
clock: s.clock,
rand: s.rand,
timeout: exchange.DefaultTimeout,
key: s.key,
dc: s.dc,
log: s.log.Named("exchange"),
rng: compatServerRNG{rand: s.rand},
}
return ex.run(ctx)
}
type serverExchangeCompat struct {
conn transport.Conn
clock clock.Clock
rand io.Reader
timeout time.Duration
key exchange.PrivateKey
dc int
log *zap.Logger
rng compatServerRNG
}
func (s serverExchangeCompat) run(ctx context.Context) (exchange.ServerExchangeResult, error) {
wrapKeyNotFound := func(err error) error {
return exchangeError(codec.CodeAuthKeyNotFound, err)
}
var req compatReqPQ
b := new(bin.Buffer)
if err := s.readUnencrypted(ctx, b, &req); err != nil {
return exchange.ServerExchangeResult{}, err
}
s.log.Debug("Received client ReqPqMultiRequest")
serverNonce, err := crypto.RandInt128(s.rand)
if err != nil {
return exchange.ServerExchangeResult{}, gofaster.Wrap(err, "generate server nonce")
}
pq, err := s.rng.PQ()
if err != nil {
return exchange.ServerExchangeResult{}, gofaster.Wrap(err, "generate pq")
}
SendResPQ:
s.log.Debug("Sending ResPQ", zap.String("pq", pq.String()))
if err := s.writeUnencrypted(ctx, b, &mt.ResPQ{
Pq: pq.Bytes(),
Nonce: req.Nonce,
ServerNonce: serverNonce,
ServerPublicKeyFingerprints: []int64{
s.key.Fingerprint(),
},
}); err != nil {
return exchange.ServerExchangeResult{}, err
}
var dhParams compatReqOrDH
if err := s.readUnencrypted(ctx, b, &dhParams); err != nil {
return exchange.ServerExchangeResult{}, err
}
switch dhParams.Type {
case mt.ReqPqRequestTypeID, mt.ReqPqMultiRequestTypeID:
s.log.Debug("Received ReqPQ again")
req = dhParams.Req
goto SendResPQ
default:
s.log.Debug("Received client ReqDHParamsRequest")
}
var innerData mt.PQInnerData
{
r, err := crypto.DecodeRSAPad(dhParams.DH.EncryptedData, s.key.RSA)
if err != nil {
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
}
b.ResetTo(r)
d, err := mt.DecodePQInnerData(b)
if err != nil {
return exchange.ServerExchangeResult{}, err
}
if err := s.validatePQInnerDataDC(d); err != nil {
return exchange.ServerExchangeResult{}, err
}
innerData = mt.PQInnerData{
Pq: d.GetPq(),
P: d.GetP(),
Q: d.GetQ(),
Nonce: d.GetNonce(),
ServerNonce: d.GetServerNonce(),
NewNonce: d.GetNewNonce(),
}
}
dhPrime, err := s.rng.DhPrime()
if err != nil {
return exchange.ServerExchangeResult{}, gofaster.Wrap(err, "generate dh_prime")
}
g := 3
a, ga, err := s.rng.GA(g, dhPrime)
if err != nil {
return exchange.ServerExchangeResult{}, gofaster.Wrap(err, "generate g_a")
}
data := mt.ServerDHInnerData{
Nonce: req.Nonce,
ServerNonce: serverNonce,
G: g,
GA: ga.Bytes(),
DhPrime: dhPrime.Bytes(),
ServerTime: int(s.clock.Now().Unix()),
}
b.Reset()
if err := data.Encode(b); err != nil {
return exchange.ServerExchangeResult{}, err
}
key, iv := crypto.TempAESKeys(innerData.NewNonce.BigInt(), serverNonce.BigInt())
answer, err := crypto.EncryptExchangeAnswer(s.rand, b.Raw(), key, iv)
if err != nil {
return exchange.ServerExchangeResult{}, err
}
s.log.Debug("Sending ServerDHParamsOk", zap.Int("g", g))
if err := s.writeUnencrypted(ctx, b, &mt.ServerDHParamsOk{
Nonce: req.Nonce,
ServerNonce: serverNonce,
EncryptedAnswer: answer,
}); err != nil {
return exchange.ServerExchangeResult{}, err
}
var clientDhParams mt.SetClientDHParamsRequest
if err := s.readUnencrypted(ctx, b, &clientDhParams); err != nil {
return exchange.ServerExchangeResult{}, err
}
s.log.Debug("Received client SetClientDHParamsRequest")
decrypted, err := crypto.DecryptExchangeAnswer(clientDhParams.EncryptedData, key, iv)
if err != nil {
err = gofaster.Wrap(err, "decrypt exchange answer")
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
}
b.ResetTo(decrypted)
var clientInnerData mt.ClientDHInnerData
if err := clientInnerData.Decode(b); err != nil {
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
}
gB := big.NewInt(0).SetBytes(clientInnerData.GB)
var authKey crypto.Key
if !crypto.FillBytes(big.NewInt(0).Exp(gB, a, dhPrime), authKey[:]) {
err := gofaster.New("auth_key is too big")
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
}
s.log.Debug("Sending DhGenOk")
if err := s.writeUnencrypted(ctx, b, &mt.DhGenOk{
Nonce: req.Nonce,
ServerNonce: serverNonce,
NewNonceHash1: crypto.NonceHash1(innerData.NewNonce, authKey),
}); err != nil {
return exchange.ServerExchangeResult{}, err
}
serverSalt := crypto.ServerSalt(innerData.NewNonce, serverNonce)
return exchange.ServerExchangeResult{
Key: authKey.WithID(),
ServerSalt: serverSalt,
}, nil
}
func (s serverExchangeCompat) validatePQInnerDataDC(d mt.PQInnerDataClass) error {
switch innerDataDC := d.(type) {
case *mt.PQInnerDataDC:
if innerDataDC.DC != s.dc {
return wrongDCError(s.dc, innerDataDC.DC)
}
case *mt.PQInnerDataTempDC:
if !sameDCByAbs(innerDataDC.DC, s.dc) {
return wrongDCError(s.dc, innerDataDC.DC)
}
if innerDataDC.DC < 0 {
s.log.Warn("Accepted Android media temp auth key negative DC",
zap.Int("server_dc", s.dc),
zap.Int("client_dc", innerDataDC.DC),
zap.Int("expires_in", innerDataDC.ExpiresIn))
}
}
return nil
}
func sameDCByAbs(got, want int) bool {
g := int64(got)
if g < 0 {
g = -g
}
return g == int64(want)
}
func wrongDCError(want, got int) error {
return exchangeError(codec.CodeWrongDC, gofaster.Errorf("wrong DC ID, want %d, got %d", want, got))
}
func exchangeError(code int32, err error) error {
return &exchange.ServerExchangeError{
Code: code,
Err: err,
}
}
func (s serverExchangeCompat) writeUnencrypted(ctx context.Context, b *bin.Buffer, data bin.Encoder) error {
b.Reset()
if err := data.Encode(b); err != nil {
return err
}
msg := proto.UnencryptedMessage{
MessageID: int64(proto.NewMessageID(s.clock.Now(), proto.MessageServerResponse)),
MessageData: b.Copy(),
}
b.Reset()
if err := msg.Encode(b); err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
return s.conn.Send(ctx, b)
}
func (s serverExchangeCompat) readUnencrypted(ctx context.Context, b *bin.Buffer, data bin.Decoder) error {
b.Reset()
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
if err := s.conn.Recv(ctx, b); err != nil {
return err
}
var keyID [8]byte
if err := b.PeekN(keyID[:], len(keyID)); err == nil && keyID != ([8]byte{}) {
return &exchange.UnexpectedEncryptedError{
AuthKeyID: keyID,
Frame: append([]byte(nil), b.Buf...),
}
}
var msg proto.UnencryptedMessage
if err := msg.Decode(b); err != nil {
return err
}
if proto.MessageID(msg.MessageID).Type() != proto.MessageFromClient {
return gofaster.New("bad msg type")
}
b.ResetTo(msg.MessageData)
return data.Decode(b)
}
type compatReqPQ struct {
Type uint32
Nonce bin.Int128
}
func (r *compatReqPQ) Decode(b *bin.Buffer) error {
var (
legacy mt.ReqPqRequest
multi mt.ReqPqMultiRequest
)
id, err := b.PeekID()
if err != nil {
return err
}
r.Type = id
switch id {
case legacy.TypeID():
if err := legacy.Decode(b); err != nil {
return err
}
r.Nonce = legacy.Nonce
return nil
case multi.TypeID():
if err := multi.Decode(b); err != nil {
return err
}
r.Nonce = multi.Nonce
return nil
default:
return bin.NewUnexpectedID(id)
}
}
type compatReqOrDH struct {
Type uint32
DH mt.ReqDHParamsRequest
Req compatReqPQ
}
func (r *compatReqOrDH) Decode(b *bin.Buffer) error {
id, err := b.PeekID()
if err != nil {
return err
}
r.Type = id
switch id {
case r.DH.TypeID():
return r.DH.Decode(b)
default:
return r.Req.Decode(b)
}
}
type compatServerRNG struct {
rand io.Reader
}
func (s compatServerRNG) PQ() (*big.Int, error) {
return big.NewInt(0x17ED48941A08F981), nil
}
func (s compatServerRNG) GA(g int, dhPrime *big.Int) (a, ga *big.Int, err error) {
if err := crypto.CheckGP(g, dhPrime); err != nil {
return nil, nil, err
}
gBig := big.NewInt(int64(g))
one := big.NewInt(1)
dhPrimeMinusOne := big.NewInt(0).Sub(dhPrime, one)
safetyRangeMin := big.NewInt(0).Exp(big.NewInt(2), big.NewInt(crypto.RSAKeyBits-64), nil)
safetyRangeMax := big.NewInt(0).Sub(dhPrime, safetyRangeMin)
randMax := big.NewInt(0).SetBit(big.NewInt(0), crypto.RSAKeyBits, 1)
for {
a, err = crand.Int(s.rand, randMax)
if err != nil {
return nil, nil, err
}
ga = big.NewInt(0).Exp(gBig, a, dhPrime)
if crypto.InRange(ga, one, dhPrimeMinusOne) && crypto.InRange(ga, safetyRangeMin, safetyRangeMax) {
return a, ga, nil
}
}
}
func (s compatServerRNG) DhPrime() (*big.Int, error) {
data, err := hex.DecodeString("C71CAEB9C6B1C9048E6C522F70F13F73980D40238E3E21C14934D037563D930F" +
"48198A0AA7C14058229493D22530F4DBFA336F6E0AC925139543AED44CCE7C37" +
"20FD51F69458705AC68CD4FE6B6B13ABDC9746512969328454F18FAF8C595F64" +
"2477FE96BB2A941D5BCD1D4AC8CC49880708FA9B378E3C4F3A9060BEE67CF9A4" +
"A4A695811051907E162753B56B0F6B410DBA74D8A84B2A14B3144E0EF1284754" +
"FD17ED950D5965B4B9DD46582DB1178D169C6BC465B0D6FF9CA3928FEF5B9AE4" +
"E418FC15E83EBEA0F87FA9FF5EED70050DED2849F47BF959D956850CE929851F" +
"0D8115F635B105EE2E4E15D04B2454BF6F4FADF034B10403119CD8E3B92FCC5B")
if err != nil {
return nil, fmt.Errorf("decode dh_prime: %w", err)
}
return big.NewInt(0).SetBytes(data), nil
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"errors"
"net" "net"
"testing" "testing"
"time" "time"
@ -15,6 +16,7 @@ import (
"github.com/gotd/td/exchange" "github.com/gotd/td/exchange"
"github.com/gotd/td/mt" "github.com/gotd/td/mt"
tgproto "github.com/gotd/td/proto" tgproto "github.com/gotd/td/proto"
"github.com/gotd/td/proto/codec"
"github.com/gotd/td/transport" "github.com/gotd/td/transport"
"telesrv/internal/store" "telesrv/internal/store"
@ -104,6 +106,57 @@ func TestKeyExchange(t *testing.T) {
} }
} }
func TestKeyExchangeAcceptsAndroidMediaTempNegativeDC(t *testing.T) {
const dc = 2
addr, pub, srv := startTestServer(t, Options{DC: dc})
conn := dialTransportOnly(t, addr)
t.Cleanup(func() { _ = conn.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := exchange.NewExchanger(conn, -dc).
WithTempMode(24 * 60 * 60).
WithRand(rand.Reader).
WithLogger(logzap.New(zaptest.NewLogger(t).Named("client"))).
Client([]exchange.PublicKey{pub}).
Run(ctx)
if err != nil {
t.Fatalf("client exchange: %v", err)
}
var saved store.AuthKeyData
found := false
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
saved, found, _ = srv.authKeys.Get(context.Background(), res.AuthKey.ID)
if found {
break
}
time.Sleep(20 * time.Millisecond)
}
if !found {
t.Fatalf("server did not store media temp auth key %x", res.AuthKey.ID)
}
if saved.Value != [256]byte(res.AuthKey.Value) {
t.Fatal("server auth key value mismatch")
}
if saved.ServerSalt != res.ServerSalt {
t.Fatalf("server salt mismatch: server=%d client=%d", saved.ServerSalt, res.ServerSalt)
}
}
func TestKeyExchangeRejectsWrongNegativeTempDC(t *testing.T) {
ex := serverExchangeCompat{dc: 2, log: zaptest.NewLogger(t)}
err := ex.validatePQInnerDataDC(&mt.PQInnerDataTempDC{DC: -3})
var exErr *exchange.ServerExchangeError
if !errors.As(err, &exErr) {
t.Fatalf("err = %T %v, want ServerExchangeError", err, err)
}
if exErr.Code != codec.CodeWrongDC {
t.Fatalf("error code = %d, want %d", exErr.Code, codec.CodeWrongDC)
}
}
func TestKeyExchangeIgnoresUnencryptedMsgsAck(t *testing.T) { func TestKeyExchangeIgnoresUnencryptedMsgsAck(t *testing.T) {
const dc = 2 const dc = 2

View file

@ -121,6 +121,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full) r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full)
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full) r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full)
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full) r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
r.applyAndroidChannelReactionEditorCompat(ctx, &full, cached.canChangeInfo)
chats := append([]tg.ChatClass(nil), cached.chats...) chats := append([]tg.ChatClass(nil), cached.chats...)
r.trackChannelInterest(ctx, userID, ref.ID) r.trackChannelInterest(ctx, userID, ref.ID)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats) r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
@ -153,14 +154,17 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
// 当前频道默认已由 tgChannelFull 处理;外部频道默认(以自己拥有的别的频道身份发言)需在此投影并 // 当前频道默认已由 tgChannelFull 处理;外部频道默认(以自己拥有的别的频道身份发言)需在此投影并
// 带上该频道对象,否则客户端拿不到默认 chip。 // 带上该频道对象,否则客户端拿不到默认 chip。
r.applyForeignDefaultSendAsToFull(ctx, userID, view, full, &chats) r.applyForeignDefaultSendAsToFull(ctx, userID, view, full, &chats)
canChangeInfo := channelMemberCanChangeInfo(view.Self)
r.channelFullProjectionCache.StoreIfEpoch(userID, view.Channel.ID, channelFullProjection{ r.channelFullProjectionCache.StoreIfEpoch(userID, view.Channel.ID, channelFullProjection{
accessHash: view.Channel.AccessHash, accessHash: view.Channel.AccessHash,
full: *full, canChangeInfo: canChangeInfo,
chats: append([]tg.ChatClass(nil), chats...), full: *full,
userIDs: userIDs, chats: append([]tg.ChatClass(nil), chats...),
userIDs: userIDs,
}, loadEpoch) }, loadEpoch)
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, view.Channel.ID, full) r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, view.Channel.ID, full)
r.applyNotifySettingsToChannelFull(ctx, userID, view.Channel.ID, full) r.applyNotifySettingsToChannelFull(ctx, userID, view.Channel.ID, full)
r.applyAndroidChannelReactionEditorCompat(ctx, full, canChangeInfo)
r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats) r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats)
return &tg.MessagesChatFull{ return &tg.MessagesChatFull{
FullChat: full, FullChat: full,

View file

@ -609,6 +609,9 @@ func (r *Router) onMessagesSetChatAvailableReactions(ctx context.Context, req *t
if r.deps.Channels == nil { if r.deps.Channels == nil {
return nil, notImplementedErr() return nil, notImplementedErr()
} }
if req == nil {
return nil, tgerr400("REACTION_INVALID")
}
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
return nil, internalErr() return nil, internalErr()
@ -617,7 +620,12 @@ func (r *Router) onMessagesSetChatAvailableReactions(ctx context.Context, req *t
if err != nil { if err != nil {
return nil, err return nil, err
} }
policy, err := domainChannelReactionPolicy(req) current, err := r.deps.Channels.GetChannelForChangeInfo(ctx, userID, channelID)
if err != nil {
return nil, channelAdminErr(err)
}
_, defaultReactionDocuments := r.availableReactionDocumentMaps(ctx)
policy, err := domainChannelReactionPolicy(req, current.Channel.ReactionPolicy, defaultReactionDocuments)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -51,11 +51,12 @@ func TestLegacyChannelSettingsRPC(t *testing.T) {
t.Fatalf("private set chat theme updates = %+v, want empty compat ack", privateTheme) t.Fatalf("private set chat theme updates = %+v, want empty compat ack", privateTheme)
} }
reactionUpdates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ setReactionsReq := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer, Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}}, AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}},
ReactionsLimit: 8, }
}) setReactionsReq.SetReactionsLimit(8)
reactionUpdates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), setReactionsReq)
if err != nil { if err != nil {
t.Fatalf("set available reactions: %v", err) t.Fatalf("set available reactions: %v", err)
} }
@ -148,11 +149,12 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
for i := 0; i < catalogSize; i++ { for i := 0; i < catalogSize; i++ {
reactions = append(reactions, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%02d", i)}) reactions = append(reactions, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%02d", i)})
} }
updates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ setCatalogReq := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer, Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: reactions}, AvailableReactions: &tg.ChatReactionsSome{Reactions: reactions},
ReactionsLimit: 11, }
}) setCatalogReq.SetReactionsLimit(11)
updates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), setCatalogReq)
if err != nil { if err != nil {
t.Fatalf("set full-catalog reactions on broadcast channel: %v", err) t.Fatalf("set full-catalog reactions on broadcast channel: %v", err)
} }
@ -173,4 +175,242 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
if !ok || len(some.Reactions) != catalogSize { if !ok || len(some.Reactions) != catalogSize {
t.Fatalf("full channel reactions = %#v, want %d explicit reactions", stored, catalogSize) t.Fatalf("full channel reactions = %#v, want %d explicit reactions", stored, catalogSize)
} }
if fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag")
}
}
func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002201", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Broadcast Optional Reactions",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
initial := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsAll{AllowCustom: true},
}
initial.SetReactionsLimit(7)
initial.SetPaidEnabled(true)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), initial); err != nil {
t.Fatalf("set initial reaction policy: %v", err)
}
omitOptional := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
&tg.ReactionEmoji{Emoticon: "\U0001f44d"},
}},
}
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), omitOptional); err != nil {
t.Fatalf("set reaction policy without optional flags: %v", err)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after omitted flags: %v", err)
}
fullChannel := full.FullChat.(*tg.ChannelFull)
if fullChannel.ReactionsLimit != 7 {
t.Fatalf("reactions limit after omitted flag = %d, want preserved 7", fullChannel.ReactionsLimit)
}
if !fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("paid reactions after omitted flag = false, want preserved true")
}
disablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
&tg.ReactionEmoji{Emoticon: "\U0001f44d"},
&tg.ReactionEmoji{Emoticon: "\u2764"},
}},
}
disablePaid.SetPaidEnabled(false)
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), disablePaid); err != nil {
t.Fatalf("disable paid reactions without limit flag: %v", err)
}
full, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel after paid disable: %v", err)
}
fullChannel = full.FullChat.(*tg.ChannelFull)
if fullChannel.ReactionsLimit != 7 {
t.Fatalf("reactions limit after paid-only update = %d, want preserved 7", fullChannel.ReactionsLimit)
}
if fullChannel.GetPaidReactionsAvailable() {
t.Fatalf("paid reactions after explicit false = true, want false")
}
}
func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 111, Phone: "15550002211", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
files := &fakeFiles{reactions: []domain.AvailableReaction{
{Reaction: "\U0001f44d", ActivateAnimationID: 7101},
{Reaction: "\U0001f525", ActivateAnimationID: 7102},
}}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Files: files,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Android Reaction Projection",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
&tg.ReactionEmoji{Emoticon: "\U0001f44d"},
&tg.ReactionEmoji{Emoticon: "\U0001f525"},
}},
}); err != nil {
t.Fatalf("set reaction policy: %v", err)
}
desktopFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get desktop full channel: %v", err)
}
desktopSome := mustChannelFullSomeReactions(t, desktopFull)
if emoji, ok := desktopSome.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" {
t.Fatalf("desktop reaction[0] = %T %+v, want reactionEmoji thumbs up", desktopSome.Reactions[0], desktopSome.Reactions[0])
}
androidCtx := WithClientInfo(WithUserID(ctx, owner.ID), ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.8.1"})
androidFull, err := r.onChannelsGetFullChannel(androidCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get android full channel: %v", err)
}
androidSome := mustChannelFullSomeReactions(t, androidFull)
if doc, ok := androidSome.Reactions[0].(*tg.ReactionCustomEmoji); !ok || doc.DocumentID != 7101 {
t.Fatalf("android reaction[0] = %T %+v, want reactionCustomEmoji 7101", androidSome.Reactions[0], androidSome.Reactions[0])
}
if doc, ok := androidSome.Reactions[1].(*tg.ReactionCustomEmoji); !ok || doc.DocumentID != 7102 {
t.Fatalf("android reaction[1] = %T %+v, want reactionCustomEmoji 7102", androidSome.Reactions[1], androidSome.Reactions[1])
}
}
func TestSetChatAvailableReactionsNormalizesDefaultReactionDocuments(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 112, Phone: "15550002212", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
files := &fakeFiles{reactions: []domain.AvailableReaction{
{Reaction: "\U0001f44d", ActivateAnimationID: 7201},
}}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Files: files,
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Android Reaction Save",
Broadcast: true,
})
if err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
channel := created.(*tg.Updates).Chats[0].(*tg.Channel)
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{
Peer: peer,
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
&tg.ReactionCustomEmoji{DocumentID: 7201},
}},
}); err != nil {
t.Fatalf("set reaction policy with default document id: %v", err)
}
full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash})
if err != nil {
t.Fatalf("get full channel: %v", err)
}
some := mustChannelFullSomeReactions(t, full)
if emoji, ok := some.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" {
t.Fatalf("stored reaction[0] = %T %+v, want normalized reactionEmoji thumbs up", some.Reactions[0], some.Reactions[0])
}
}
func TestAvailableReactionDocumentMapsAreCached(t *testing.T) {
ctx := context.Background()
files := &countingAvailableReactionFiles{fakeFiles: &fakeFiles{reactions: []domain.AvailableReaction{
{Reaction: "\U0001f44d", ActivateAnimationID: 7301},
}}}
r := &Router{deps: Deps{Files: files}}
emojiToDoc, docToEmoji := r.availableReactionDocumentMaps(ctx)
if got := emojiToDoc["\U0001f44d"]; got != 7301 {
t.Fatalf("emoji->document map = %d, want 7301", got)
}
if got := docToEmoji[7301]; got != "\U0001f44d" {
t.Fatalf("document->emoji map = %q, want thumbs up", got)
}
files.fakeFiles.reactions = append(files.fakeFiles.reactions, domain.AvailableReaction{
Reaction: "\U0001f525",
ActivateAnimationID: 7302,
})
emojiToDoc, docToEmoji = r.availableReactionDocumentMaps(ctx)
if files.calls != 1 {
t.Fatalf("ListAvailableReactions calls = %d, want 1 cached load", files.calls)
}
if got := emojiToDoc["\U0001f525"]; got != 0 {
t.Fatalf("cached emoji->document map unexpectedly saw later catalog mutation: %d", got)
}
if got := docToEmoji[7302]; got != "" {
t.Fatalf("cached document->emoji map unexpectedly saw later catalog mutation: %q", got)
}
}
type countingAvailableReactionFiles struct {
*fakeFiles
calls int
}
func (f *countingAvailableReactionFiles) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) {
f.calls++
return f.fakeFiles.ListAvailableReactions(ctx)
}
func mustChannelFullSomeReactions(t *testing.T, full *tg.MessagesChatFull) *tg.ChatReactionsSome {
t.Helper()
channelFull, ok := full.FullChat.(*tg.ChannelFull)
if !ok {
t.Fatalf("full chat = %T, want *tg.ChannelFull", full.FullChat)
}
reactions, ok := channelFull.GetAvailableReactions()
if !ok {
t.Fatalf("channel full reactions missing")
}
some, ok := reactions.(*tg.ChatReactionsSome)
if !ok {
t.Fatalf("channel full reactions = %T %+v, want *tg.ChatReactionsSome", reactions, reactions)
}
if len(some.Reactions) == 0 {
t.Fatalf("channel full reactions empty")
}
return some
} }

View file

@ -83,16 +83,22 @@ func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsRead
return true, nil return true, nil
} }
func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsRequest) (domain.ChannelReactionPolicy, error) { func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsRequest, current domain.ChannelReactionPolicy, defaultReactionDocuments map[int64]string) (domain.ChannelReactionPolicy, error) {
if req == nil || req.AvailableReactions == nil { if req == nil || req.AvailableReactions == nil {
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
} }
if req.ReactionsLimit < 0 || req.ReactionsLimit > domain.MaxChannelReactionsLimit {
return domain.ChannelReactionPolicy{}, limitInvalidErr()
}
policy := domain.ChannelReactionPolicy{ policy := domain.ChannelReactionPolicy{
Limit: req.ReactionsLimit, Limit: current.Limit,
PaidEnabled: req.PaidEnabled, PaidEnabled: current.PaidEnabled,
}
if limit, ok := req.GetReactionsLimit(); ok {
if limit < 0 || limit > domain.MaxChannelReactionsLimit {
return domain.ChannelReactionPolicy{}, limitInvalidErr()
}
policy.Limit = limit
}
if paidEnabled, ok := req.GetPaidEnabled(); ok {
policy.PaidEnabled = paidEnabled
} }
switch reactions := req.AvailableReactions.(type) { switch reactions := req.AvailableReactions.(type) {
case *tg.ChatReactionsNone: case *tg.ChatReactionsNone:
@ -111,6 +117,7 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
if err != nil { if err != nil {
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
} }
parsed = normalizeDefaultReactionDocument(parsed, defaultReactionDocuments)
key := parsed.Key() key := parsed.Key()
if _, ok := seen[key]; ok { if _, ok := seen[key]; ok {
continue continue

View file

@ -156,6 +156,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup) msg.SetReplyMarkup(markup)
} }
if rich := mustTGRichMessage(m.RichMessage); rich != nil {
msg.SetRichMessage(*rich)
}
if replies := tgChannelMessageReplies(m.Replies); replies != nil { if replies := tgChannelMessageReplies(m.Replies); replies != nil {
msg.SetReplies(*replies) msg.SetReplies(*replies)
} }
@ -546,10 +549,10 @@ func tgChannelFull(view domain.ChannelView) *tg.ChannelFull {
if ch.ReactionPolicy.Limit > 0 { if ch.ReactionPolicy.Limit > 0 {
full.SetReactionsLimit(ch.ReactionPolicy.Limit) full.SetReactionsLimit(ch.ReactionPolicy.Limit)
} }
// 付费 reaction(Stars)是广播频道默认能力——官方语义下 channelFull.paid_reactions_available // paid_reactions_available reflects the saved chat policy, not mere broadcast
// 对广播频道恒真,客户端据此显示星按钮;megagroup 不支持。与 store 侧 // capability. Android counts this flag as an extra available reaction in the
// AddChannelMessagePaidReaction 仅广播频道的门槛一致。显式 PaidEnabled 也保留。 // settings row, so advertising it without paid_enabled corrupts the UI count.
if (ch.Broadcast && !ch.Megagroup) || ch.ReactionPolicy.PaidEnabled { if ch.ReactionPolicy.PaidEnabled {
full.SetPaidReactionsAvailable(true) full.SetPaidReactionsAvailable(true)
} }
if ch.Broadcast && !ch.Megagroup { if ch.Broadcast && !ch.Megagroup {

View file

@ -162,6 +162,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass {
Date: d.Date, Date: d.Date,
Effect: d.Effect, Effect: d.Effect,
} }
if rich := mustTGRichMessage(d.RichMessage); rich != nil {
out.SetRichMessage(*rich)
}
return out return out
} }

View file

@ -116,9 +116,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup) msg.SetReplyMarkup(markup)
} }
// rich_message(Layer 227 富文本消息):best-effort 投影;blocks 解码失败则略过 if rich := mustTGRichMessage(m.RichMessage); rich != nil {
// (tgMessage 无 error 返回,corrupt blob 不应拖垮整条消息投影)。
if rich, err := tgRichMessage(m.RichMessage); err == nil && rich != nil {
msg.SetRichMessage(*rich) msg.SetRichMessage(*rich)
} }
if m.TTLPeriod > 0 { if m.TTLPeriod > 0 {

View file

@ -2,6 +2,7 @@ package rpc
import ( import (
"context" "context"
"strconv"
"github.com/gotd/td/bin" "github.com/gotd/td/bin"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
@ -50,6 +51,81 @@ func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) {
return out, nil return out, nil
} }
func normalizeRichBlocksForClients(blocks []tg.PageBlockClass) {
for _, block := range blocks {
normalizeRichBlockForClients(block)
}
}
func normalizeRichBlockForClients(block tg.PageBlockClass) {
switch b := block.(type) {
case *tg.PageBlockList:
for _, item := range b.Items {
if item, ok := item.(*tg.PageListItemBlocks); ok {
normalizeRichBlocksForClients(item.Blocks)
}
}
case *tg.PageBlockCover:
normalizeRichBlockForClients(b.Cover)
case *tg.PageBlockEmbedPost:
normalizeRichBlocksForClients(b.Blocks)
case *tg.PageBlockCollage:
normalizeRichBlocksForClients(b.Items)
case *tg.PageBlockSlideshow:
normalizeRichBlocksForClients(b.Items)
case *tg.PageBlockOrderedList:
normalizeOrderedListForClients(b)
case *tg.PageBlockDetails:
normalizeRichBlocksForClients(b.Blocks)
case *tg.PageBlockBlockquoteBlocks:
normalizeRichBlocksForClients(b.Blocks)
}
}
func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) {
if list == nil {
return
}
reversed := list.Reversed || list.Flags.Has(2)
current := 1
if list.Flags.Has(0) || list.Start != 0 {
current = list.Start
} else if reversed {
current = len(list.Items)
}
step := 1
if reversed {
step = -1
}
for _, item := range list.Items {
value := current
switch i := item.(type) {
case *tg.PageListOrderedItemText:
if v, ok := i.GetValue(); ok || i.Value != 0 {
value = v
if !ok {
value = i.Value
}
}
if num, ok := i.GetNum(); !ok || num == "" {
i.SetNum(strconv.Itoa(value))
}
case *tg.PageListOrderedItemBlocks:
if v, ok := i.GetValue(); ok || i.Value != 0 {
value = v
if !ok {
value = i.Value
}
}
if num, ok := i.GetNum(); !ok || num == "" {
i.SetNum(strconv.Itoa(value))
}
normalizeRichBlocksForClients(i.Blocks)
}
current = value + step
}
}
// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照: // domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照:
// 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。 // 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。
// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。 // 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。
@ -62,9 +138,16 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR
// Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。 // Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。
return nil, mediaInvalidErr() return nil, mediaInvalidErr()
} }
if r.deps.Files == nil { if len(in.Blocks) == 0 {
if len(in.Photos) == 0 && len(in.Documents) == 0 {
return nil, nil
}
return nil, mediaInvalidErr()
}
if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil {
return nil, notImplementedErr() return nil, notImplementedErr()
} }
normalizeRichBlocksForClients(in.Blocks)
blocks, err := encodeRichBlocks(in.Blocks) blocks, err := encodeRichBlocks(in.Blocks)
if err != nil { if err != nil {
return nil, err return nil, err
@ -132,3 +215,11 @@ func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) {
} }
return out, nil return out, nil
} }
func mustTGRichMessage(m *domain.MessageRichMessage) *tg.RichMessage {
out, err := tgRichMessage(m)
if err != nil {
panic("invalid stored rich_message: " + err.Error())
}
return out
}

View file

@ -72,7 +72,7 @@ func (r *Router) langpackLanguage(ctx context.Context, langPack, langCode string
langCode = "en" langCode = "en"
} }
} }
langCode = strings.ToLower(langCode) langCode = normalizeLangpackCode(langCode)
languages := r.langpackLanguages(ctx, langPack) languages := r.langpackLanguages(ctx, langPack)
for _, lang := range languages { for _, lang := range languages {
if strings.ToLower(lang.LangCode) == langCode { if strings.ToLower(lang.LangCode) == langCode {
@ -150,3 +150,11 @@ func langPackFromClient(ctx context.Context) string {
} }
return "tdesktop" return "tdesktop"
} }
func normalizeLangpackCode(langCode string) string {
code := strings.ToLower(strings.TrimSpace(langCode))
if code == "" {
return "en"
}
return strings.TrimSuffix(code, "-raw")
}

View file

@ -68,6 +68,11 @@ func TestLangpackGetLanguage(t *testing.T) {
if lang.LangCode != "zh-hans" || lang.PluralCode != "zh" { if lang.LangCode != "zh-hans" || lang.PluralCode != "zh" {
t.Fatalf("language = %+v, want zh-hans", lang) t.Fatalf("language = %+v, want zh-hans", lang)
} }
raw := r.langpackLanguage(context.Background(), "tdesktop", "en-raw")
if raw.LangCode != "en" {
t.Fatalf("language(en-raw) = %+v, want en", raw)
}
} }
func TestLangpackAndroidPersianLanguage(t *testing.T) { func TestLangpackAndroidPersianLanguage(t *testing.T) {

View file

@ -30,9 +30,9 @@ const (
maxPollVotesOffsetLength = 128 maxPollVotesOffsetLength = 128
maxTodoItems = 30 maxTodoItems = 30
maxTodoTitleLength = 200 maxTodoTitleLength = 200
// maxTodoItemID 是清单项 id 的防御上限:协议只要求列表内唯一正整数(客户端通常 // maxTodoItemID 是清单项 id 的防御上限:协议字段是 int32,WebA 会用 8 位左右
// 顺序分配),不能用条目数上限当 id 边界,否则非顺序分配的合法 id 被误拒。 // 的稀疏本地 id,不能用条目数上限或顺序分配假设当 id 边界。
maxTodoItemID = 1 << 16 maxTodoItemID = 1<<31 - 1
maxVenueTitleLength = 256 maxVenueTitleLength = 256
maxVenueAddressLength = 512 maxVenueAddressLength = 512
maxVenueProviderLength = 64 maxVenueProviderLength = 64

View file

@ -43,7 +43,7 @@ func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDr
} }
update := &tg.UpdateDraftMessage{ update := &tg.UpdateDraftMessage{
Peer: peerTL, Peer: peerTL,
Draft: tgDraftMessageFromSaveDraft(req, date), Draft: tgDialogDraft(draft),
} }
if draft.TopMessageID > 0 { if draft.TopMessageID > 0 {
update.SetTopMsgID(draft.TopMessageID) update.SetTopMsgID(draft.TopMessageID)
@ -170,6 +170,13 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
if err != nil { if err != nil {
return domain.DialogDraft{}, err return domain.DialogDraft{}, err
} }
var richMessage *domain.MessageRichMessage
if req.RichMessage != nil {
richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage)
if err != nil {
return domain.DialogDraft{}, err
}
}
topMessageID := 0 topMessageID := 0
if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 { if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 {
topMessageID = replyTo.TopMessageID topMessageID = replyTo.TopMessageID
@ -185,6 +192,7 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee
ReplyTo: replyTo, ReplyTo: replyTo,
WebPage: webpage, WebPage: webpage,
Effect: req.Effect, Effect: req.Effect,
RichMessage: richMessage,
}, nil }, nil
} }
@ -234,7 +242,8 @@ func saveDraftIsEmpty(req *tg.MessagesSaveDraftRequest) bool {
len(req.Entities) == 0 && len(req.Entities) == 0 &&
draftInputMedia(req.Media) == nil && draftInputMedia(req.Media) == nil &&
req.Effect == 0 && req.Effect == 0 &&
req.SuggestedPost.Zero() req.SuggestedPost.Zero() &&
req.RichMessage == nil
} }
func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass { func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass {

View file

@ -29,11 +29,22 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
if len(entities) > maxMessageEntityCount { if len(entities) > maxMessageEntityCount {
return nil, entitiesTooLongErr() return nil, entitiesTooLongErr()
} }
} else {
entities = nil
}
var richMessage *domain.MessageRichMessage
hasRichMessage := req.RichMessage != nil
if hasRichMessage {
var richErr error
richMessage, richErr = r.domainRichMessageFromInput(ctx, req.RichMessage)
if richErr != nil {
return nil, richErr
}
}
if hasMessage && richMessage == nil {
// 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致; // 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致;
// 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。 // 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。
entities = augmentAutoEntities(message, entities) entities = augmentAutoEntities(message, entities)
} else {
entities = nil
} }
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
@ -50,7 +61,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) { if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) {
return nil, mediaInvalidErr() return nil, mediaInvalidErr()
} }
return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, scheduleDate) return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, hasRichMessage, scheduleDate)
} }
if media, ok := req.GetMedia(); ok { if media, ok := req.GetMedia(); ok {
// 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。 // 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。
@ -65,14 +76,15 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
return nil, mediaInvalidErr() return nil, mediaInvalidErr()
} }
} }
if !hasMessage { _, hasMediaForContent := req.GetMedia()
if !hasMessage && !hasRichMessage && !hasMediaForContent {
return nil, messageEmptyErr() return nil, messageEmptyErr()
} }
if message == "" { if message == "" && richMessage == nil {
// 编辑媒体消息时 message="" 是合法的清空 caption;当前文本-only // 编辑媒体消息时 message="" 是合法的清空 caption;当前文本-only
// 编辑模型由 store 层校验目标消息(无媒体的纯文本消息清空仍会 // 编辑模型由 store 层校验目标消息(无媒体的纯文本消息清空仍会
// 落 MESSAGE_EMPTY),RPC 层不再一刀切拒绝。 // 落 MESSAGE_EMPTY),RPC 层不再一刀切拒绝。
if _, hasMedia := req.GetMedia(); !hasMedia { if !hasMediaForContent {
return nil, messageEmptyErr() return nil, messageEmptyErr()
} }
} }
@ -107,6 +119,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
Message: message, Message: message,
Entities: domainMessageEntitiesForViewer(userID, entities), Entities: domainMessageEntitiesForViewer(userID, entities),
MentionUserIDs: mentionUserIDs, MentionUserIDs: mentionUserIDs,
SetRichMessage: hasRichMessage,
RichMessage: richMessage,
EditDate: int(r.clock.Now().Unix()), EditDate: int(r.clock.Now().Unix()),
}) })
if err != nil { if err != nil {
@ -141,6 +155,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
OriginSessionID: sessionID, OriginSessionID: sessionID,
SetReplyMarkup: setReplyMarkup, SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup, ReplyMarkup: replyMarkup,
SetRichMessage: hasRichMessage,
RichMessage: richMessage,
}) })
if err != nil { if err != nil {
return nil, messageEditErr(err) return nil, messageEditErr(err)

View file

@ -30,6 +30,9 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
if !topMsgIDSet && req.TopMsgID != 0 { if !topMsgIDSet && req.TopMsgID != 0 {
topMsgID, topMsgIDSet = req.TopMsgID, true topMsgID, topMsgIDSet = req.TopMsgID, true
} }
if topMsgIDSet && topMsgID == -1 {
topMsgID, topMsgIDSet = 0, false
}
if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) {
return nil, replyMessageIDInvalidErr() return nil, replyMessageIDInvalidErr()
} }

View file

@ -91,6 +91,52 @@ func TestMessagesForwardMessagesRecordsRequestAndReturnsUpdates(t *testing.T) {
} }
} }
func TestMessagesForwardMessagesTreatsMainThreadTopMsgSentinelAsAbsent(t *testing.T) {
const (
ownerID = int64(1000000101)
fromID = int64(1000000102)
toID = int64(1000000103)
)
ctx := context.Background()
messages := &captureMessages{list: domain.MessageList{Messages: []domain.Message{
{
ID: 8,
OwnerUserID: ownerID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID},
Date: 1700000108,
Body: "main thread source",
},
}}}
r := New(Config{}, Deps{
Messages: messages,
Users: mapUsersService{users: map[int64]domain.User{
ownerID: {ID: ownerID, FirstName: "Owner"},
fromID: {ID: fromID, FirstName: "From"},
toID: {ID: toID, FirstName: "To"},
}},
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerUser{UserID: fromID},
ToPeer: &tg.InputPeerUser{UserID: toID},
ID: []int{8},
RandomID: []int64{8001},
}
req.SetTopMsgID(-1)
updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), req)
if err != nil {
t.Fatalf("forward with main thread top_msg_id sentinel: %v", err)
}
if messages.sendReq.ReplyTo != nil {
t.Fatalf("reply = %+v, want nil for main thread sentinel", messages.sendReq.ReplyTo)
}
updates, ok := updatesClass.(*tg.Updates)
if !ok || len(updates.Updates) != 2 {
t.Fatalf("updates = %T %+v, want updateMessageID + updateNewMessage", updatesClass, updatesClass)
}
}
func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) { func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userStore := memory.NewUserStore() userStore := memory.NewUserStore()
@ -312,6 +358,30 @@ func TestMessagesForwardMessagesInputPeerEmptyRejectsBadIDsBeforeLookup(t *testi
} }
} }
func TestMessagesForwardMessagesRejectsOtherNegativeTopMsgID(t *testing.T) {
const ownerID = int64(1780243210)
ctx := context.Background()
messages := &captureMessages{}
r := New(Config{}, Deps{
Messages: messages,
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesForwardMessagesRequest{
FromPeer: &tg.InputPeerUser{UserID: 1780243211},
ToPeer: &tg.InputPeerUser{UserID: 1780243212},
ID: []int{1},
RandomID: []int64{10001},
}
req.SetTopMsgID(-2)
_, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), req)
if err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") {
t.Fatalf("forward negative top_msg_id err = %v, want REPLY_MESSAGE_ID_INVALID", err)
}
if messages.getMessagesCalls != 0 {
t.Fatalf("GetMessages calls = %d, want no source lookup for invalid top_msg_id", messages.getMessagesCalls)
}
}
func TestMessagesForwardMessagesNormalizesAndroidDuplicateIDRetry(t *testing.T) { func TestMessagesForwardMessagesNormalizesAndroidDuplicateIDRetry(t *testing.T) {
const ( const (
ownerID = int64(1780243210) ownerID = int64(1780243210)

View file

@ -765,6 +765,11 @@ func messagesSearchFilterMusic(filter tg.MessagesFilterClass) bool {
return ok return ok
} }
func messagesSearchFilterChatPhotos(filter tg.MessagesFilterClass) bool {
_, ok := filter.(*tg.InputMessagesFilterChatPhotos)
return ok
}
func searchFilterNeedsMediaStore(filter tg.MessagesFilterClass) bool { func searchFilterNeedsMediaStore(filter tg.MessagesFilterClass) bool {
switch filter.(type) { switch filter.(type) {
case nil, *tg.InputMessagesFilterEmpty: case nil, *tg.InputMessagesFilterEmpty:

View file

@ -154,6 +154,61 @@ func TestMessagesSearchChannelPeerReturnsSingleCopyMessages(t *testing.T) {
} }
} }
func TestMessagesSearchChatPhotosDoesNotReturnOrdinaryChannelHistory(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 93500, Phone: "15550093500", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Channels: channelService,
}, zaptest.NewLogger(t), clock.System)
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
Title: "Chat Photos Count Guard", Megagroup: true, Date: 1700034000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channel := created.Channel
for i := 0; i < 7; i++ {
if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{
ChannelID: channel.ID,
RandomID: int64(1700034000 + i),
Message: "ordinary channel message",
Date: 1700034001 + i,
}); err != nil {
t.Fatalf("send ordinary message %d: %v", i, err)
}
}
req := &tg.MessagesSearchRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.InputMessagesFilterChatPhotos{},
Limit: 80,
}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode chat photos search: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in)
if err != nil {
t.Fatalf("dispatch chat photos search: %v", err)
}
if box, ok := enc.(*tg.MessagesMessagesBox); ok {
enc = box.Messages
}
got, ok := enc.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("chat photos search result = %T, want messages.channelMessages", enc)
}
if got.Count != 0 || len(got.Messages) != 0 {
t.Fatalf("chat photos search = count %d messages %d, want explicit empty stub", got.Count, len(got.Messages))
}
if len(got.Chats) != 1 {
t.Fatalf("chat photos search chats = %d, want current channel projection", len(got.Chats))
}
}
func TestMessagesGetSearchCountersUsesMediaCategoryCounts(t *testing.T) { func TestMessagesGetSearchCountersUsesMediaCategoryCounts(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userStore := memory.NewUserStore() userStore := memory.NewUserStore()

View file

@ -195,6 +195,34 @@ func TestSendReactionAllowsCustomEmojiFromChannelPolicy(t *testing.T) {
} }
} }
func TestSendReactionNormalizesDefaultReactionDocumentID(t *testing.T) {
f := newReactionPolicyFixture(t, false)
f.router.deps.Files = &fakeFiles{reactions: []domain.AvailableReaction{
{Reaction: "\U0001f44d", ActivateAnimationID: 8801},
}}
ctx := context.Background()
if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{
Type: domain.ChannelReactionPolicySome,
Emoticons: []string{"\U0001f44d"},
}); err != nil {
t.Fatalf("set emoji whitelist policy: %v", err)
}
updates, err := f.sendTLReactions(t, f.memberID, &tg.ReactionCustomEmoji{DocumentID: 8801})
if err != nil {
t.Fatalf("send default reaction by document id: %v", err)
}
update := reactionUpdateFromUpdates(t, updates)
if len(update.Reactions.Results) != 1 {
t.Fatalf("reaction results = %+v, want one normalized emoji result", update.Reactions.Results)
}
emoji, ok := update.Reactions.Results[0].Reaction.(*tg.ReactionEmoji)
if !ok || emoji.Emoticon != "\U0001f44d" {
t.Fatalf("normalized reaction = %T %+v, want reactionEmoji thumbs up", update.Reactions.Results[0].Reaction, update.Reactions.Results[0].Reaction)
}
}
func TestSendReactionEnforcesUniqueReactionsLimit(t *testing.T) { func TestSendReactionEnforcesUniqueReactionsLimit(t *testing.T) {
f := newReactionPolicyFixture(t, false) f := newReactionPolicyFixture(t, false)
ctx := context.Background() ctx := context.Background()

View file

@ -23,6 +23,7 @@ func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSen
if err != nil { if err != nil {
return nil, err return nil, err
} }
reactions = r.normalizeDefaultReactionDocuments(ctx, reactions)
// 官方语义(reactions_user_max_default/premium):向量尾部是最新选择, // 官方语义(reactions_user_max_default/premium):向量尾部是最新选择,
// 超出每用户上限丢弃旧的而非报错;premium viewer 用 premium 档(appConfig // 超出每用户上限丢弃旧的而非报错;premium viewer 用 premium 档(appConfig
// reactions_user_max_premium=3),否则客户端允许的多 reaction 会被静默裁剪。 // reactions_user_max_premium=3),否则客户端允许的多 reaction 会被静默裁剪。

View file

@ -363,6 +363,21 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
if isLegacyInputPeerChat(req.Peer) { if isLegacyInputPeerChat(req.Peer) {
return &tg.MessagesMessages{}, nil return &tg.MessagesMessages{}, nil
} }
if messagesSearchFilterChatPhotos(req.Filter) {
view, err := r.resolveInputPeerChannelView(ctx, userID, req.Peer, filter.Peer.ID)
if err != nil {
return nil, channelInvalidErr(err)
}
out := &tg.MessagesChannelMessages{
Pts: view.Channel.Pts,
Count: 0,
Messages: []tg.MessageClass{},
Chats: []tg.ChatClass{tgChannelChatForView(userID, view)},
Users: []tg.UserClass{},
}
r.applyStoryMaxIDsToMessages(ctx, userID, out)
return out, nil
}
if searchFilterNeedsMediaStore(req.Filter) { if searchFilterNeedsMediaStore(req.Filter) {
if mediaSearchCountOnlyRequest(req) { if mediaSearchCountOnlyRequest(req) {
view, err := r.resolveInputPeerChannelView(ctx, userID, req.Peer, filter.Peer.ID) view, err := r.resolveInputPeerChannelView(ctx, userID, req.Peer, filter.Peer.ID)
@ -445,6 +460,12 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
} }
return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil
} }
if messagesSearchFilterChatPhotos(req.Filter) {
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
return nil, err
}
return r.tgMessagesMessages(ctx, userID, domain.MessageList{}), nil
}
if r.deps.Messages == nil { if r.deps.Messages == nil {
return messagesNotModifiedOrEmpty(req.Hash), nil return messagesNotModifiedOrEmpty(req.Hash), nil
} }

View file

@ -4,19 +4,149 @@ import (
"context" "context"
"testing" "testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store/memory"
) )
// richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。 // richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。
func richTextBlocks() []tg.PageBlockClass { func richTextBlocks() []tg.PageBlockClass {
return richTextBlocksWith("Rich Title", "First paragraph.")
}
func richTextBlocksWith(title, paragraph string) []tg.PageBlockClass {
return []tg.PageBlockClass{ return []tg.PageBlockClass{
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: "Rich Title"}}, &tg.PageBlockTitle{Text: &tg.TextPlain{Text: title}},
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "First paragraph."}}, &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: paragraph}},
} }
} }
func richEmptyCaption() tg.PageCaption {
return tg.PageCaption{
Text: &tg.TextEmpty{},
Credit: &tg.TextEmpty{},
}
}
func richOrderedListWithoutNums() []tg.PageBlockClass {
return []tg.PageBlockClass{
&tg.PageBlockOrderedList{
Items: []tg.PageListOrderedItemClass{
&tg.PageListOrderedItemText{Text: &tg.TextPlain{Text: "one"}},
&tg.PageListOrderedItemBlocks{
Blocks: []tg.PageBlockClass{
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "two"}},
},
},
},
},
}
}
func richNestedOrderedListBlock() tg.PageBlockClass {
return richOrderedListWithoutNums()[0]
}
func assertOrderedListNums(t *testing.T, label string, blocks []tg.PageBlockClass, want ...string) {
t.Helper()
if len(blocks) != 1 {
t.Fatalf("%s: blocks = %d, want 1", label, len(blocks))
}
list, ok := blocks[0].(*tg.PageBlockOrderedList)
if !ok {
t.Fatalf("%s: block[0] = %T, want *tg.PageBlockOrderedList", label, blocks[0])
}
if len(list.Items) != len(want) {
t.Fatalf("%s: items = %d, want %d", label, len(list.Items), len(want))
}
for idx, item := range list.Items {
var (
num string
ok bool
)
switch i := item.(type) {
case *tg.PageListOrderedItemText:
num, ok = i.GetNum()
case *tg.PageListOrderedItemBlocks:
num, ok = i.GetNum()
default:
t.Fatalf("%s: item[%d] = %T, want ordered text/blocks", label, idx, item)
}
if !ok || num != want[idx] {
t.Fatalf("%s: item[%d].num = %q, ok=%v, want %q", label, idx, num, ok, want[idx])
}
}
}
func collectOrderedListNums(blocks []tg.PageBlockClass) []string {
var nums []string
var walk func(tg.PageBlockClass)
walk = func(block tg.PageBlockClass) {
switch b := block.(type) {
case *tg.PageBlockList:
for _, item := range b.Items {
if item, ok := item.(*tg.PageListItemBlocks); ok {
for _, child := range item.Blocks {
walk(child)
}
}
}
case *tg.PageBlockCover:
walk(b.Cover)
case *tg.PageBlockEmbedPost:
for _, child := range b.Blocks {
walk(child)
}
case *tg.PageBlockCollage:
for _, child := range b.Items {
walk(child)
}
case *tg.PageBlockSlideshow:
for _, child := range b.Items {
walk(child)
}
case *tg.PageBlockDetails:
for _, child := range b.Blocks {
walk(child)
}
case *tg.PageBlockBlockquoteBlocks:
for _, child := range b.Blocks {
walk(child)
}
case *tg.PageBlockOrderedList:
for _, item := range b.Items {
switch i := item.(type) {
case *tg.PageListOrderedItemText:
if num, ok := i.GetNum(); ok {
nums = append(nums, num)
} else {
nums = append(nums, "")
}
case *tg.PageListOrderedItemBlocks:
if num, ok := i.GetNum(); ok {
nums = append(nums, num)
} else {
nums = append(nums, "")
}
for _, child := range i.Blocks {
walk(child)
}
}
}
}
}
for _, block := range blocks {
walk(block)
}
return nums
}
// assertRichTextBlocks 校验投影出的 RichMessage 携带 richTextBlocks 的两个块(标题+段落)。 // assertRichTextBlocks 校验投影出的 RichMessage 携带 richTextBlocks 的两个块(标题+段落)。
func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) { func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) {
t.Helper() t.Helper()
@ -42,6 +172,198 @@ func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) {
} }
} }
func assertRichTitle(t *testing.T, label string, rich tg.RichMessage, want string) {
t.Helper()
if len(rich.Blocks) == 0 {
t.Fatalf("%s: missing rich blocks", label)
}
title, ok := rich.Blocks[0].(*tg.PageBlockTitle)
if !ok {
t.Fatalf("%s: block[0] = %T, want *tg.PageBlockTitle", label, rich.Blocks[0])
}
if tp, ok := title.Text.(*tg.TextPlain); !ok || tp.Text != want {
t.Fatalf("%s: title text = %+v, want plain %q", label, title.Text, want)
}
}
func TestRichMessageOrderedListNumsNormalized(t *testing.T) {
ctx := context.Background()
r := &Router{}
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
Blocks: richOrderedListWithoutNums(),
})
if err != nil {
t.Fatalf("domain rich message: %v", err)
}
got, err := tgRichMessage(rich)
if err != nil {
t.Fatalf("tg rich message: %v", err)
}
assertOrderedListNums(t, "new input", got.Blocks, "1", "2")
}
func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) {
ctx := context.Background()
r := &Router{}
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{})
if err != nil {
t.Fatalf("empty input rich message: %v", err)
}
if rich != nil {
t.Fatalf("empty input rich message = %+v, want nil", rich)
}
if _, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
Photos: []tg.InputPhotoClass{&tg.InputPhoto{ID: 1, AccessHash: 2}},
}); err == nil {
t.Fatalf("orphan rich photos without blocks accepted")
}
if _, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{
Documents: []tg.InputDocumentClass{&tg.InputDocument{ID: 1, AccessHash: 2}},
}); err == nil {
t.Fatalf("orphan rich documents without blocks accepted")
}
}
func TestRichMessageNormalizesNestedOrderedListContainers(t *testing.T) {
ctx := context.Background()
r := &Router{}
caption := richEmptyCaption()
blocks := []tg.PageBlockClass{
&tg.PageBlockList{Items: []tg.PageListItemClass{
&tg.PageListItemBlocks{Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()}},
}},
&tg.PageBlockCover{Cover: richNestedOrderedListBlock()},
&tg.PageBlockEmbedPost{
URL: "https://example.test/post",
Author: "author",
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
Caption: caption,
WebpageID: 1,
},
&tg.PageBlockCollage{Items: []tg.PageBlockClass{richNestedOrderedListBlock()}, Caption: caption},
&tg.PageBlockSlideshow{Items: []tg.PageBlockClass{richNestedOrderedListBlock()}, Caption: caption},
&tg.PageBlockDetails{
Title: &tg.TextPlain{Text: "details"},
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
},
&tg.PageBlockBlockquoteBlocks{
Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()},
Caption: &tg.TextEmpty{},
},
}
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks})
if err != nil {
t.Fatalf("domain rich message: %v", err)
}
got, err := tgRichMessage(rich)
if err != nil {
t.Fatalf("tg rich message: %v", err)
}
nums := collectOrderedListNums(got.Blocks)
want := []string{"1", "2", "1", "2", "1", "2", "1", "2", "1", "2", "1", "2", "1", "2"}
if len(nums) != len(want) {
t.Fatalf("ordered nums = %v, want %v", nums, want)
}
for i := range want {
if nums[i] != want[i] {
t.Fatalf("ordered nums = %v, want %v", nums, want)
}
}
}
func TestRichMessageBlockFormatsEncodeDecode(t *testing.T) {
caption := richEmptyCaption()
blocks := []tg.PageBlockClass{
&tg.PageBlockTitle{Text: &tg.TextPlain{Text: "title"}},
&tg.PageBlockSubtitle{Text: &tg.TextPlain{Text: "subtitle"}},
&tg.PageBlockAuthorDate{Author: &tg.TextPlain{Text: "author"}, PublishedDate: 1},
&tg.PageBlockHeader{Text: &tg.TextPlain{Text: "header"}},
&tg.PageBlockSubheader{Text: &tg.TextPlain{Text: "subheader"}},
&tg.PageBlockParagraph{Text: &tg.TextConcat{Texts: []tg.RichTextClass{
&tg.TextPlain{Text: "plain"},
&tg.TextBold{Text: &tg.TextPlain{Text: "bold"}},
&tg.TextItalic{Text: &tg.TextPlain{Text: "italic"}},
&tg.TextUnderline{Text: &tg.TextPlain{Text: "underline"}},
&tg.TextStrike{Text: &tg.TextPlain{Text: "strike"}},
&tg.TextFixed{Text: &tg.TextPlain{Text: "fixed"}},
&tg.TextSpoiler{Text: &tg.TextPlain{Text: "spoiler"}},
&tg.TextURL{Text: &tg.TextPlain{Text: "url"}, URL: "https://example.test"},
&tg.TextEmail{Text: &tg.TextPlain{Text: "email"}, Email: "a@example.test"},
&tg.TextPhone{Text: &tg.TextPlain{Text: "phone"}, Phone: "+10000000000"},
&tg.TextMath{Source: "x"},
}}},
&tg.PageBlockPreformatted{Text: &tg.TextPlain{Text: "pre"}, Language: "go"},
&tg.PageBlockFooter{Text: &tg.TextPlain{Text: "footer"}},
&tg.PageBlockDivider{},
&tg.PageBlockAnchor{Name: "anchor"},
&tg.PageBlockList{Items: []tg.PageListItemClass{
&tg.PageListItemText{Text: &tg.TextPlain{Text: "item"}},
&tg.PageListItemBlocks{Blocks: []tg.PageBlockClass{
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "nested"}},
}},
}},
&tg.PageBlockBlockquote{Text: &tg.TextPlain{Text: "quote"}, Caption: &tg.TextEmpty{}},
&tg.PageBlockPullquote{Text: &tg.TextPlain{Text: "pull"}, Caption: &tg.TextEmpty{}},
&tg.PageBlockPhoto{PhotoID: 1, Caption: caption},
&tg.PageBlockVideo{VideoID: 2, Caption: caption},
&tg.PageBlockCover{Cover: &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "cover"}}},
&tg.PageBlockEmbedPost{
URL: "https://example.test/post",
Author: "author",
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "post"}}},
Caption: caption,
WebpageID: 3,
},
&tg.PageBlockCollage{Items: []tg.PageBlockClass{&tg.PageBlockPhoto{PhotoID: 4, Caption: caption}}, Caption: caption},
&tg.PageBlockSlideshow{Items: []tg.PageBlockClass{&tg.PageBlockVideo{VideoID: 5, Caption: caption}}, Caption: caption},
&tg.PageBlockAudio{AudioID: 6, Caption: caption},
&tg.PageBlockKicker{Text: &tg.TextPlain{Text: "kicker"}},
&tg.PageBlockTable{Title: &tg.TextPlain{Text: "table"}},
&tg.PageBlockOrderedList{Items: []tg.PageListOrderedItemClass{
&tg.PageListOrderedItemText{Text: &tg.TextPlain{Text: "one"}},
}},
&tg.PageBlockDetails{Title: &tg.TextPlain{Text: "details"}, Blocks: []tg.PageBlockClass{
&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "inside"}},
}},
&tg.PageBlockRelatedArticles{Title: &tg.TextPlain{Text: "related"}, Articles: []tg.PageRelatedArticle{
{URL: "https://example.test/a", WebpageID: 7},
}},
&tg.PageBlockMap{Geo: &tg.GeoPointEmpty{}, Zoom: 13, W: 64, H: 64, Caption: caption},
&tg.PageBlockHeading1{Text: &tg.TextPlain{Text: "h1"}},
&tg.PageBlockHeading2{Text: &tg.TextPlain{Text: "h2"}},
&tg.PageBlockHeading3{Text: &tg.TextPlain{Text: "h3"}},
&tg.PageBlockHeading4{Text: &tg.TextPlain{Text: "h4"}},
&tg.PageBlockHeading5{Text: &tg.TextPlain{Text: "h5"}},
&tg.PageBlockHeading6{Text: &tg.TextPlain{Text: "h6"}},
&tg.PageBlockMath{Source: "x^2"},
&tg.PageBlockThinking{Text: &tg.TextPlain{Text: "thinking"}},
&tg.PageBlockBlockquoteBlocks{
Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "blocks"}}},
Caption: &tg.TextEmpty{},
},
&tg.PageBlockUnsupported{},
}
ctx := context.Background()
r := &Router{}
rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks})
if err != nil {
t.Fatalf("domain rich message: %v", err)
}
got, err := tgRichMessage(rich)
if err != nil {
t.Fatalf("tg rich message: %v", err)
}
if len(got.Blocks) != len(blocks) {
t.Fatalf("blocks = %d, want %d", len(got.Blocks), len(blocks))
}
nums := collectOrderedListNums(got.Blocks)
if len(nums) != 1 || nums[0] != "1" {
t.Fatalf("ordered nums = %v, want [1]", nums)
}
}
// TestSendMessageRichMessageTextBlocksRoundTrip 验证 Layer 227 富文本(inputRichMessage 的 // TestSendMessageRichMessageTextBlocksRoundTrip 验证 Layer 227 富文本(inputRichMessage 的
// blocks 形态)经 send → 发送方 echo / getMessages / getRichMessage 全链路原样往返。 // blocks 形态)经 send → 发送方 echo / getMessages / getRichMessage 全链路原样往返。
func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) { func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
@ -95,6 +417,154 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) {
assertRichTextBlocks(t, "getRichMessage", rich) assertRichTextBlocks(t, "getRichMessage", rich)
} }
// TestSendMessageRichOnlyTextBlocksRoundTrip 覆盖 TDesktop rich editor 的真实发送形态:
// messages.sendMessage 带 f_rich_message,但 message:string 为空。
func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
RandomID: 7101,
RichMessage: &tg.InputRichMessage{
Rtl: true,
Blocks: richTextBlocks(),
},
})
if err != nil {
t.Fatalf("send rich-only message: %v", err)
}
echo := newMessageFromUpdates(t, updates)
if echo.Message != "" {
t.Fatalf("rich-only echo message = %q, want empty fallback text", echo.Message)
}
rich, ok := echo.GetRichMessage()
if !ok {
t.Fatalf("rich-only echo missing rich message")
}
assertRichTextBlocks(t, "rich-only echo", rich)
}
func TestEditMessageRichOnlyPrivateRoundTrip(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
RandomID: 7102,
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
})
if err != nil {
t.Fatalf("send rich-only message: %v", err)
}
msg := newMessageFromUpdates(t, updates)
editReq := &tg.MessagesEditMessageRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
ID: msg.ID,
}
editReq.SetRichMessage(&tg.InputRichMessage{
Rtl: true,
Blocks: richTextBlocksWith("Edited Title", "Edited paragraph."),
})
edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq)
if err != nil {
t.Fatalf("edit rich-only private message: %v", err)
}
editedMsg := editMessageFromUpdates(t, edited)
rich, ok := editedMsg.GetRichMessage()
if !ok {
t.Fatalf("edited private message missing rich message")
}
assertRichTitle(t, "edited private", rich, "Edited Title")
}
func TestChannelRichMessageSendEditHistoryRoundTrip(t *testing.T) {
ctx := context.Background()
r, owner, channel := newRichChannelTestRouter(t)
peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{
Peer: peer,
RandomID: 7201,
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
})
if err != nil {
t.Fatalf("send channel rich-only message: %v", err)
}
echo := newMessageFromUpdates(t, updates)
rich, ok := echo.GetRichMessage()
if !ok {
t.Fatalf("channel echo missing rich message")
}
assertRichTextBlocks(t, "channel echo", rich)
historyList, err := r.deps.Channels.GetHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
ChannelID: channel.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("channel get history: %v", err)
}
history := r.tgChannelHistoryMessages(WithUserID(ctx, owner.ID), owner.ID, historyList)
stored := singleChannelStoredMessage(t, history)
rich, ok = stored.GetRichMessage()
if !ok {
t.Fatalf("channel history missing rich message")
}
assertRichTextBlocks(t, "channel history", rich)
editReq := &tg.MessagesEditMessageRequest{Peer: peer, ID: echo.ID}
editReq.SetRichMessage(&tg.InputRichMessage{
Rtl: true,
Blocks: richTextBlocksWith("Edited Channel", "Edited channel paragraph."),
})
edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq)
if err != nil {
t.Fatalf("edit channel rich-only message: %v", err)
}
editedMsg := editChannelMessageFromUpdates(t, edited)
rich, ok = editedMsg.GetRichMessage()
if !ok {
t.Fatalf("edited channel message missing rich message")
}
assertRichTitle(t, "edited channel", rich, "Edited Channel")
}
func TestSaveDraftRichMessageRoundTrip(t *testing.T) {
ctx := context.Background()
r, owner, friend := newRichDraftTestRouter(t)
ok, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()},
})
if err != nil || !ok {
t.Fatalf("save rich draft = %v, %v", ok, err)
}
got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, owner.ID))
if err != nil {
t.Fatalf("get all drafts: %v", err)
}
updates := got.(*tg.Updates)
if len(updates.Updates) != 1 {
t.Fatalf("draft updates = %+v, want one", updates.Updates)
}
update, ok := updates.Updates[0].(*tg.UpdateDraftMessage)
if !ok {
t.Fatalf("draft update = %T", updates.Updates[0])
}
draft, ok := update.Draft.(*tg.DraftMessage)
if !ok {
t.Fatalf("draft = %T, want *tg.DraftMessage", update.Draft)
}
rich, ok := draft.GetRichMessage()
if !ok {
t.Fatalf("draft missing rich message")
}
assertRichTextBlocks(t, "draft", rich)
}
// TestGetRichMessageWrongPeerReturnsEmpty 验证 getRichMessage 的 peer 校验:用不匹配的 peer // TestGetRichMessageWrongPeerReturnsEmpty 验证 getRichMessage 的 peer 校验:用不匹配的 peer
// 拉取应返回 messageEmpty(不跨会话泄漏)。 // 拉取应返回 messageEmpty(不跨会话泄漏)。
func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) { func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) {
@ -182,3 +652,104 @@ func singleStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message
} }
return msg return msg
} }
func singleChannelStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message {
t.Helper()
box, ok := res.(*tg.MessagesChannelMessages)
if !ok {
t.Fatalf("channel messages = %T %+v, want messages.channelMessages", res, res)
}
var got *tg.Message
for _, item := range box.Messages {
msg, ok := item.(*tg.Message)
if !ok {
continue
}
if got != nil {
t.Fatalf("channel messages = %+v, want one regular message", box.Messages)
}
got = msg
}
if got == nil {
t.Fatalf("channel messages = %+v, want one regular message", box.Messages)
}
return got
}
func editMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message {
t.Helper()
upd, ok := updates.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", updates)
}
for _, u := range upd.Updates {
if edit, ok := u.(*tg.UpdateEditMessage); ok {
msg, ok := edit.Message.(*tg.Message)
if !ok {
t.Fatalf("edit message = %T, want *tg.Message", edit.Message)
}
return msg
}
}
t.Fatal("no updateEditMessage found")
return nil
}
func editChannelMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message {
t.Helper()
upd, ok := updates.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", updates)
}
for _, u := range upd.Updates {
if edit, ok := u.(*tg.UpdateEditChannelMessage); ok {
msg, ok := edit.Message.(*tg.Message)
if !ok {
t.Fatalf("edit channel message = %T, want *tg.Message", edit.Message)
}
return msg
}
}
t.Fatal("no updateEditChannelMessage found")
return nil
}
func newRichChannelTestRouter(t *testing.T) (*Router, domain.User, domain.Channel) {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550009101", FirstName: "Owner"})
channelStore := memory.NewChannelStore()
channelSvc := appchannels.NewService(channelStore)
created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Rich Channel",
Date: 1700000000,
})
if err != nil {
t.Fatalf("create rich channel: %v", err)
}
dialogStore := memory.NewDialogStore()
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(userStore),
Channels: channelSvc,
Dialogs: appdialogs.NewService(dialogStore, channelStore),
Files: &fakeFiles{docs: map[int64]domain.Document{}, photos: map[int64]domain.Photo{}},
}, zaptest.NewLogger(t), clock.System)
return r, owner, created.Channel
}
func newRichDraftTestRouter(t *testing.T) (*Router, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
userStore := memory.NewUserStore()
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550009201", FirstName: "Owner"})
friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550009202", FirstName: "Friend"})
dialogStore := memory.NewDialogStore()
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Users: appusers.NewService(userStore),
Dialogs: appdialogs.NewService(dialogStore, memory.NewChannelStore()),
Files: &fakeFiles{docs: map[int64]domain.Document{}, photos: map[int64]domain.Photo{}},
}, zaptest.NewLogger(t), clock.System)
return r, owner, friend
}

View file

@ -21,9 +21,13 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages
if parent, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil { if parent, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil {
return nil, err return nil, err
} else if hasParent { } else if hasParent {
if req.Limit < 0 || req.OffsetID < 0 { if req.Limit < 0 {
return nil, limitInvalidErr() return nil, limitInvalidErr()
} }
offsetID := req.OffsetID
if offsetID < 0 {
offsetID = 0
}
mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parent) mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parent)
if err != nil { if err != nil {
return nil, err return nil, err
@ -38,15 +42,23 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages
}, nil }, nil
} }
// parent_peer = monoforum:返回该频道私信的订阅者子会话列表(管理员视角)。 // parent_peer = monoforum:返回该频道私信的订阅者子会话列表(管理员视角)。
return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, req.OffsetID) return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, offsetID)
} }
if req.Limit < 0 || req.OffsetID < 0 || req.OffsetDate < 0 { if req.Limit < 0 {
return nil, limitInvalidErr() return nil, limitInvalidErr()
} }
offsetID := req.OffsetID
if offsetID < 0 {
offsetID = 0
}
offsetDate := req.OffsetDate
if offsetDate < 0 {
offsetDate = 0
}
filter := domain.SavedDialogsFilter{ filter := domain.SavedDialogsFilter{
ExcludePinned: req.ExcludePinned, ExcludePinned: req.ExcludePinned,
OffsetID: req.OffsetID, OffsetID: offsetID,
OffsetDate: req.OffsetDate, OffsetDate: offsetDate,
Limit: req.Limit, Limit: req.Limit,
} }
if req.OffsetPeer != nil { if req.OffsetPeer != nil {

View file

@ -7,6 +7,7 @@ import (
"github.com/gotd/td/bin" "github.com/gotd/td/bin"
"github.com/gotd/td/clock" "github.com/gotd/td/clock"
"github.com/gotd/td/tg" "github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
appcontacts "telesrv/internal/app/contacts" appcontacts "telesrv/internal/app/contacts"
@ -252,6 +253,34 @@ func TestMessagesGetSavedDialogsPagination(t *testing.T) {
} }
} }
// TestMessagesGetSavedDialogsNegativePendingOffset 验证 DrKLO Android self Saved
// Messages 分组里出现本地 pending 负消息 id 时,下一页请求不会被误判为
// LIMIT_INVALID。服务端持久化 top_message 仍保持正数;负 offset 仅是客户端本地
// 游标噪声,按首页/无 offset 处理。
func TestMessagesGetSavedDialogsNegativePendingOffset(t *testing.T) {
ctx := context.Background()
r, _, alice, _, _, _ := savedDialogsFixture(t)
res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{
Limit: 20,
OffsetID: -1001,
OffsetDate: -1,
OffsetPeer: &tg.InputPeerSelf{},
})
if err != nil {
t.Fatalf("negative pending offset: %v", err)
}
dialogs, _, _, _, full := savedDialogPage(t, res)
if !full || len(dialogs) != 2 {
t.Fatalf("negative pending offset page = %d full %v, want first page 2 full", len(dialogs), full)
}
_, err = r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: -1})
if !tgerr.Is(err, "LIMIT_INVALID") {
t.Fatalf("negative limit err = %v, want LIMIT_INVALID", err)
}
}
// TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中 // TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中
// 返回 savedDialogsNotModified{count}。 // 返回 savedDialogsNotModified{count}。
func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) { func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) {

View file

@ -193,6 +193,7 @@ func (r *Router) scheduleOutgoing(ctx context.Context, userID int64, peer domain
Message: p.message, Message: p.message,
Entities: domainMessageEntitiesForViewer(userID, p.entities), Entities: domainMessageEntitiesForViewer(userID, p.entities),
Media: p.media, Media: p.media,
RichMessage: p.richMessage,
Silent: p.silent, Silent: p.silent,
NoForwards: p.noforwards, NoForwards: p.noforwards,
ReplyTo: replyTo, ReplyTo: replyTo,
@ -227,12 +228,13 @@ func (r *Router) sendClaimedScheduledMessages(ctx context.Context, userID int64,
sentIDs := make([]int, 0, len(claimed)) sentIDs := make([]int, 0, len(claimed))
for _, scheduled := range claimed { for _, scheduled := range claimed {
updates, _, err := r.sendOutgoing(ctx, userID, scheduled.Peer, outgoingSend{ updates, _, err := r.sendOutgoing(ctx, userID, scheduled.Peer, outgoingSend{
randomID: scheduled.RandomID, randomID: scheduled.RandomID,
message: scheduled.Message, message: scheduled.Message,
entities: tgInputMessageEntities(scheduled.Entities), entities: tgInputMessageEntities(scheduled.Entities),
media: scheduled.Media, media: scheduled.Media,
silent: scheduled.Silent, richMessage: scheduled.RichMessage,
noforwards: scheduled.NoForwards, silent: scheduled.Silent,
noforwards: scheduled.NoForwards,
}) })
if err != nil { if err != nil {
if scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService); ok { if scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService); ok {
@ -340,6 +342,7 @@ func scheduledMessageAsDomainMessage(msg domain.ScheduledMessage, viewerUserID i
ReplyTo: msg.ReplyTo, ReplyTo: msg.ReplyTo,
Forward: msg.Forward, Forward: msg.Forward,
Media: msg.Media, Media: msg.Media,
RichMessage: msg.RichMessage,
} }
} }
@ -427,7 +430,7 @@ func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, from
return updates, nil return updates, nil
} }
func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, scheduleDate int) (tg.UpdatesClass, error) { func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, richMessage *domain.MessageRichMessage, setRichMessage bool, scheduleDate int) (tg.UpdatesClass, error) {
if r.deps.Messages == nil { if r.deps.Messages == nil {
return nil, messageIDInvalidErr() return nil, messageIDInvalidErr()
} }
@ -440,14 +443,16 @@ func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer do
return nil, scheduleDateInvalidErr() return nil, scheduleDateInvalidErr()
} }
msg, err := scheduledSvc.EditScheduledMessage(ctx, userID, domain.EditScheduledMessageRequest{ msg, err := scheduledSvc.EditScheduledMessage(ctx, userID, domain.EditScheduledMessageRequest{
OwnerUserID: userID, OwnerUserID: userID,
Peer: peer, Peer: peer,
ID: id, ID: id,
SetMessage: setMessage, SetMessage: setMessage,
Message: message, Message: message,
Entities: domainMessageEntitiesForViewer(userID, entities), Entities: domainMessageEntitiesForViewer(userID, entities),
ScheduleDate: scheduleDate, SetRichMessage: setRichMessage,
Date: now, RichMessage: richMessage,
ScheduleDate: scheduleDate,
Date: now,
}) })
if err != nil { if err != nil {
return nil, messageEditErr(err) return nil, messageEditErr(err)

View file

@ -16,10 +16,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
defer func() { defer func() {
r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr) r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr)
}() }()
if req.Message == "" {
sendErr = messageEmptyErr()
return nil, messageEmptyErr()
}
if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength {
sendErr = messageTooLongErr() sendErr = messageTooLongErr()
return nil, sendErr return nil, sendErr
@ -101,6 +97,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
return nil, sendErr return nil, sendErr
} }
} }
if req.Message == "" && richMessage == nil {
sendErr = messageEmptyErr()
return nil, sendErr
}
// 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端 // 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端
// 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。 // 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。
if richMessage == nil { if richMessage == nil {
@ -123,6 +123,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
replyToInput: req.ReplyTo, replyToInput: req.ReplyTo,
sendAsInput: req.SendAs, sendAsInput: req.SendAs,
clearDraft: req.ClearDraft, clearDraft: req.ClearDraft,
richMessage: richMessage,
}, req.ScheduleDate, req.ScheduleRepeatPeriod) }, req.ScheduleDate, req.ScheduleRepeatPeriod)
if err != nil { if err != nil {
sendErr = err sendErr = err
@ -201,6 +202,9 @@ func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer d
return nil, inputConstructorInvalidErr() return nil, inputConstructorInvalidErr()
} }
} }
if reply.Zero() {
return nil, nil
}
if _, ok := reply.GetMonoforumPeerID(); ok { if _, ok := reply.GetMonoforumPeerID(); ok {
return nil, replyToMonoforumPeerInvalidErr() return nil, replyToMonoforumPeerInvalidErr()
} }

View file

@ -244,6 +244,37 @@ func TestMessageReplyFromInputStorySucceedsAndProjectsStoryHeader(t *testing.T)
} }
} }
func TestMessageReplyFromInputEmptyMessageIsAbsent(t *testing.T) {
const userID = int64(1000000001)
ctx := WithUserID(context.Background(), userID)
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
reply, err := r.messageReplyFromInput(ctx, userID, peer, &tg.InputReplyToMessage{})
if err != nil {
t.Fatalf("empty reply err = %v, want nil", err)
}
if reply != nil {
t.Fatalf("empty reply = %+v, want nil", reply)
}
topicReply := &tg.InputReplyToMessage{}
topicReply.SetTopMsgID(123)
reply, err = r.messageReplyFromInput(ctx, userID, peer, topicReply)
if err != nil {
t.Fatalf("topic-only reply err = %v, want nil", err)
}
if reply == nil || reply.MessageID != 0 || reply.TopMessageID != 123 {
t.Fatalf("topic-only reply = %+v, want top_msg_id=123", reply)
}
quoteOnly := &tg.InputReplyToMessage{}
quoteOnly.SetQuoteText("orphan quote")
if _, err := r.messageReplyFromInput(ctx, userID, peer, quoteOnly); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") {
t.Fatalf("quote-only reply err = %v, want REPLY_MESSAGE_ID_INVALID", err)
}
}
func TestMessageReplyFromInputUnsupportedShapesReturnExplicitErrors(t *testing.T) { func TestMessageReplyFromInputUnsupportedShapesReturnExplicitErrors(t *testing.T) {
const userID = int64(1000000001) const userID = int64(1000000001)
ctx := WithUserID(context.Background(), userID) ctx := WithUserID(context.Background(), userID)

View file

@ -53,6 +53,59 @@ func TestSendMediaTodoEcho(t *testing.T) {
} }
} }
func TestSendMediaTodoWithEmptyReplyToTreatsReplyAsAbsent(t *testing.T) {
r, owner, friend := newMediaTestRouter(t)
req := &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaTodo{Todo: tg.TodoList{
Title: tg.TextWithEntities{Text: "web checklist", Entities: []tg.MessageEntityClass{}},
List: []tg.TodoItem{
{ID: 1, Title: tg.TextWithEntities{Text: "send item", Entities: []tg.MessageEntityClass{}}},
},
}},
RandomID: 7005,
}
req.SetReplyTo(&tg.InputReplyToMessage{})
updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), owner.ID), req)
if err != nil {
t.Fatalf("sendMedia todo with empty reply: %v", err)
}
msg := newMessageFromUpdates(t, updates)
if msg.ReplyTo != nil {
t.Fatalf("reply_to = %T, want nil for empty input reply", msg.ReplyTo)
}
if media, ok := msg.Media.(*tg.MessageMediaToDo); !ok || media.Todo.Title.Text != "web checklist" {
t.Fatalf("media = %#v, want todo checklist", msg.Media)
}
}
func TestSendMediaTodoAcceptsSparseLargeItemIDs(t *testing.T) {
r, owner, friend := newMediaTestRouter(t)
updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), owner.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaTodo{Todo: tg.TodoList{
Title: tg.TextWithEntities{Text: "web sparse ids", Entities: []tg.MessageEntityClass{}},
List: []tg.TodoItem{
{ID: 56151290, Title: tg.TextWithEntities{Text: "first", Entities: []tg.MessageEntityClass{}}},
{ID: 56151305, Title: tg.TextWithEntities{Text: "second", Entities: []tg.MessageEntityClass{}}},
},
}},
RandomID: 7006,
})
if err != nil {
t.Fatalf("sendMedia todo sparse ids: %v", err)
}
msg := newMessageFromUpdates(t, updates)
media, ok := msg.Media.(*tg.MessageMediaToDo)
if !ok {
t.Fatalf("media = %T, want MessageMediaToDo", msg.Media)
}
if got := media.Todo.List[0].ID; got != 56151290 {
t.Fatalf("first todo id = %d, want 56151290", got)
}
}
func TestToggleTodoCompletedAndAppend(t *testing.T) { func TestToggleTodoCompletedAndAppend(t *testing.T) {
ctx := context.Background() ctx := context.Background()
r, owner, friend := newMediaTestRouter(t) r, owner, friend := newMediaTestRouter(t)

View file

@ -0,0 +1,156 @@
package rpc
import (
"context"
"strings"
"sync"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// availableReactionDocumentMapCache caches the global reaction catalog mapping.
// Returned maps are shared and must be treated as read-only.
type availableReactionDocumentMapCache struct {
mu sync.RWMutex
loaded bool
emojiToDocumentID map[string]int64
documentIDToEmoji map[int64]string
}
func channelMemberCanChangeInfo(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator ||
(member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo)
}
func (r *Router) applyAndroidChannelReactionEditorCompat(ctx context.Context, full *tg.ChannelFull, canChangeInfo bool) {
if full == nil || !canChangeInfo || ClientTypeFrom(ctx) != ClientTypeAndroid {
return
}
raw, ok := full.GetAvailableReactions()
if !ok {
return
}
some, ok := raw.(*tg.ChatReactionsSome)
if !ok || len(some.Reactions) == 0 {
return
}
emojiToDocumentID, _ := r.availableReactionDocumentMaps(ctx)
if len(emojiToDocumentID) == 0 {
return
}
out := &tg.ChatReactionsSome{Reactions: make([]tg.ReactionClass, 0, len(some.Reactions))}
changed := false
for _, reaction := range some.Reactions {
if emoji, ok := reaction.(*tg.ReactionEmoji); ok && emoji != nil {
if documentID := emojiToDocumentID[strings.TrimSpace(emoji.Emoticon)]; documentID > 0 {
out.Reactions = append(out.Reactions, &tg.ReactionCustomEmoji{DocumentID: documentID})
changed = true
continue
}
}
out.Reactions = append(out.Reactions, reaction)
}
if changed {
full.SetAvailableReactions(out)
}
}
func (r *Router) normalizeDefaultReactionDocuments(ctx context.Context, reactions []domain.MessageReaction) []domain.MessageReaction {
needsCatalog := false
for _, reaction := range reactions {
if reaction.Type == domain.MessageReactionCustomEmoji && reaction.DocumentID > 0 {
needsCatalog = true
break
}
}
if !needsCatalog {
return reactions
}
_, documentIDToEmoji := r.availableReactionDocumentMaps(ctx)
if len(documentIDToEmoji) == 0 {
return reactions
}
out := make([]domain.MessageReaction, 0, len(reactions))
seen := make(map[string]struct{}, len(reactions))
for _, reaction := range reactions {
normalized := normalizeDefaultReactionDocument(reaction, documentIDToEmoji)
key := normalized.Key()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, normalized)
}
return out
}
func normalizeDefaultReactionDocument(reaction domain.MessageReaction, documentIDToEmoji map[int64]string) domain.MessageReaction {
if reaction.Type != domain.MessageReactionCustomEmoji || reaction.DocumentID <= 0 {
return reaction
}
if emoticon := documentIDToEmoji[reaction.DocumentID]; emoticon != "" {
return domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}
}
return reaction
}
func (r *Router) availableReactionDocumentMaps(ctx context.Context) (map[string]int64, map[int64]string) {
if r == nil || r.deps.Files == nil {
return nil, nil
}
return r.availableReactionDocuments.get(ctx, r.deps.Files)
}
func (c *availableReactionDocumentMapCache) get(ctx context.Context, files FilesService) (map[string]int64, map[int64]string) {
if files == nil {
return nil, nil
}
c.mu.RLock()
if c.loaded {
emojiToDocumentID, documentIDToEmoji := c.emojiToDocumentID, c.documentIDToEmoji
c.mu.RUnlock()
return emojiToDocumentID, documentIDToEmoji
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.loaded {
return c.emojiToDocumentID, c.documentIDToEmoji
}
catalog, err := files.ListAvailableReactions(ctx)
if err != nil {
return nil, nil
}
c.emojiToDocumentID, c.documentIDToEmoji = buildAvailableReactionDocumentMaps(catalog)
c.loaded = true
return c.emojiToDocumentID, c.documentIDToEmoji
}
func (c *availableReactionDocumentMapCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.loaded = false
c.emojiToDocumentID = nil
c.documentIDToEmoji = nil
}
func buildAvailableReactionDocumentMaps(catalog []domain.AvailableReaction) (map[string]int64, map[int64]string) {
emojiToDocumentID := make(map[string]int64, len(catalog))
documentIDToEmoji := make(map[int64]string, len(catalog))
for _, item := range catalog {
emoticon := strings.TrimSpace(item.Reaction)
if item.Inactive || emoticon == "" || item.ActivateAnimationID <= 0 {
continue
}
if _, exists := emojiToDocumentID[emoticon]; !exists {
emojiToDocumentID[emoticon] = item.ActivateAnimationID
}
if _, exists := documentIDToEmoji[item.ActivateAnimationID]; !exists {
documentIDToEmoji[item.ActivateAnimationID] = emoticon
}
}
return emojiToDocumentID, documentIDToEmoji
}

View file

@ -125,6 +125,7 @@ type Router struct {
userFullProjectionCache *userFullProjectionCache userFullProjectionCache *userFullProjectionCache
peerSettingsProjectionCache *peerSettingsProjectionCache peerSettingsProjectionCache *peerSettingsProjectionCache
channelFullProjectionCache *channelFullProjectionCache channelFullProjectionCache *channelFullProjectionCache
availableReactionDocuments availableReactionDocumentMapCache
emojiStickers *emojiStickerIndex emojiStickers *emojiStickerIndex
notifySettings *notifySettingsCache notifySettings *notifySettingsCache
stickerCatalog *stickerCatalogCache stickerCatalog *stickerCatalogCache

View file

@ -126,10 +126,11 @@ type channelFullProjectionKey struct {
} }
type channelFullProjection struct { type channelFullProjection struct {
accessHash int64 accessHash int64
full tg.ChannelFull canChangeInfo bool
chats []tg.ChatClass full tg.ChannelFull
userIDs []int64 chats []tg.ChatClass
userIDs []int64
} }
type channelFullProjectionCache struct { type channelFullProjectionCache struct {
@ -180,10 +181,11 @@ func (c *channelFullProjectionCache) DeletePair(viewerUserID, channelID int64) {
func cloneChannelFullProjection(in channelFullProjection) channelFullProjection { func cloneChannelFullProjection(in channelFullProjection) channelFullProjection {
return channelFullProjection{ return channelFullProjection{
accessHash: in.accessHash, accessHash: in.accessHash,
full: cloneChannelFull(in.full), canChangeInfo: in.canChangeInfo,
chats: cloneChatClasses(in.chats), full: cloneChannelFull(in.full),
userIDs: cloneInt64s(in.userIDs), chats: cloneChatClasses(in.chats),
userIDs: cloneInt64s(in.userIDs),
} }
} }

View file

@ -39,7 +39,6 @@ type outgoingSend struct {
replyMarkup *domain.MessageReplyMarkup replyMarkup *domain.MessageReplyMarkup
viaBotID int64 viaBotID int64
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。 // richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。
// Phase 1 仅接入私聊;频道侧留 Phase 2。
richMessage *domain.MessageRichMessage richMessage *domain.MessageRichMessage
// groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染 // groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染
// 成一个相册组);单条发送恒 0。 // 成一个相册组);单条发送恒 0。
@ -92,6 +91,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
ViaBotID: p.viaBotID, ViaBotID: p.viaBotID,
GroupedID: p.groupedID, GroupedID: p.groupedID,
ReplyMarkup: p.replyMarkup, ReplyMarkup: p.replyMarkup,
RichMessage: p.richMessage,
SendAs: sendAs, SendAs: sendAs,
Date: int(r.clock.Now().Unix()), Date: int(r.clock.Now().Unix()),
}) })

View file

@ -77,6 +77,13 @@ func (r *Router) onStoriesGetAllStories(ctx context.Context, req *tg.StoriesGetA
now := int(r.clock.Now().Unix()) now := int(r.clock.Now().Unix())
var cursor domain.StoryListCursor var cursor domain.StoryListCursor
if next { if next {
if digest, ok := storyAllStoriesDigestFromCompleteState(requestState, hidden); ok {
list := domain.StoryList{Hidden: hidden, Count: digest.Count, State: requestState}
if r.deps.Stories == nil || userID == 0 {
return tgStoriesAllStories(userID, list), nil
}
return r.tgStoriesAllStories(ctx, userID, list), nil
}
cursor, err = storyAllStoriesCursorFromState(requestState, hidden) cursor, err = storyAllStoriesCursorFromState(requestState, hidden)
if err != nil { if err != nil {
return nil, offsetInvalidErr() return nil, offsetInvalidErr()
@ -135,7 +142,16 @@ func validateStoriesGetAllStoriesRequest(req *tg.StoriesGetAllStoriesRequest) er
next := storyAllStoriesNext(req) next := storyAllStoriesNext(req)
hidden := storyAllStoriesHidden(req) hidden := storyAllStoriesHidden(req)
if next { if next {
if !hasState || state == "" || !storyAllStoriesCursorStateToken(state) { if !hasState || state == "" {
return offsetInvalidErr()
}
if storyAllStoriesCompleteState(state) {
if !storyAllStoriesCompleteStateForHidden(state, hidden) {
return offsetInvalidErr()
}
return nil
}
if !storyAllStoriesCursorStateToken(state) {
return offsetInvalidErr() return offsetInvalidErr()
} }
if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil { if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil {
@ -172,6 +188,22 @@ func storyAllStoriesCompleteState(state string) bool {
return strings.HasPrefix(state, "ts1:") return strings.HasPrefix(state, "ts1:")
} }
func storyAllStoriesDigestFromCompleteState(state string, hidden bool) (domain.StoryListDigest, bool) {
if !storyAllStoriesCompleteStateForHidden(state, hidden) {
return domain.StoryListDigest{}, false
}
parts := strings.Split(state, ":")
count, err := strconv.Atoi(parts[2])
if err != nil || count < 0 {
return domain.StoryListDigest{}, false
}
hash, err := strconv.ParseUint(parts[3], 16, 64)
if err != nil {
return domain.StoryListDigest{}, false
}
return domain.StoryListDigest{Count: count, Hash: hash}, true
}
func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool { func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool {
parts := strings.Split(state, ":") parts := strings.Split(state, ":")
if len(parts) != 4 || parts[0] != "ts1" { if len(parts) != 4 || parts[0] != "ts1" {

View file

@ -298,8 +298,16 @@ func TestStoriesGetAllStoriesPaginatesByPeerState(t *testing.T) {
finalAsCursor := &tg.StoriesGetAllStoriesRequest{} finalAsCursor := &tg.StoriesGetAllStoriesRequest{}
finalAsCursor.SetState(next.State) finalAsCursor.SetState(next.State)
finalAsCursor.SetNext(true) finalAsCursor.SetNext(true)
if _, err := r.onStoriesGetAllStories(ctx, finalAsCursor); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { finalAsCursorClass, err := r.onStoriesGetAllStories(reqCtx, finalAsCursor)
t.Fatalf("final state as cursor err = %v, want OFFSET_INVALID", err) if err != nil {
t.Fatalf("final state as cursor: %v", err)
}
finalAsCursorPage, ok := finalAsCursorClass.(*tg.StoriesAllStories)
if !ok {
t.Fatalf("final state as cursor response = %T, want stories.allStories terminal page", finalAsCursorClass)
}
if finalAsCursorPage.HasMore || len(finalAsCursorPage.PeerStories) != 0 || finalAsCursorPage.Count != domain.MaxStoryListLimit+1 || finalAsCursorPage.State != next.State {
t.Fatalf("final state as cursor page = %+v, want empty terminal page preserving request count/state", finalAsCursorPage)
} }
} }

View file

@ -9,8 +9,7 @@ import (
) )
func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
// 空文本只在媒体替换(live location 续报/停止)时合法。 if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 {
if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
} }
s.mu.Lock() s.mu.Lock()
@ -26,6 +25,17 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
} }
prevMsg := s.messages[req.ChannelID][idx] prevMsg := s.messages[req.ChannelID][idx]
msg := prevMsg msg := prevMsg
finalMedia := msg.Media
if req.Media != nil {
finalMedia = req.Media
}
finalRich := msg.RichMessage
if req.SetRichMessage {
finalRich = req.RichMessage
}
if strings.TrimSpace(req.Message) == "" && finalMedia.IsZero() && finalRich.IsZero() {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
}
// WebPageResolve:频道链接预览就地替换(服务端内部,幂等守卫即授权)。只换 media、 // WebPageResolve:频道链接预览就地替换(服务端内部,幂等守卫即授权)。只换 media、
// 不碰 body/entities/edit_date,事件为 channel_web_page。 // 不碰 body/entities/edit_date,事件为 channel_web_page。
if req.WebPageResolve { if req.WebPageResolve {
@ -67,7 +77,8 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
if !viaBotEdit && msg.SenderUserID != req.UserID && !canEditChannelMessage(member) && !participantTodoEdit { if !viaBotEdit && msg.SenderUserID != req.UserID && !canEditChannelMessage(member) && !participantTodoEdit {
return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired
} }
if req.Media == nil && !req.SetReplyMarkup && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { richChanged := req.SetRichMessage && !richMessagesEqual(msg.RichMessage, req.RichMessage)
if req.Media == nil && !req.SetReplyMarkup && !richChanged && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) {
return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified
} }
pts := s.nextChannelPtsLocked(req.ChannelID) pts := s.nextChannelPtsLocked(req.ChannelID)
@ -80,6 +91,9 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
if req.SetReplyMarkup { if req.SetReplyMarkup {
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
} }
if req.SetRichMessage {
msg.RichMessage = cloneRichMessage(req.RichMessage)
}
msg.EditDate = req.EditDate msg.EditDate = req.EditDate
msg.Pts = pts msg.Pts = pts
s.messages[req.ChannelID][idx] = msg s.messages[req.ChannelID][idx] = msg

View file

@ -30,6 +30,7 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage {
in.ReplyTo = cloneMessageReply(in.ReplyTo) in.ReplyTo = cloneMessageReply(in.ReplyTo)
in.Forward = cloneMessageForward(in.Forward) in.Forward = cloneMessageForward(in.Forward)
in.ReplyMarkup = cloneReplyMarkup(in.ReplyMarkup) in.ReplyMarkup = cloneReplyMarkup(in.ReplyMarkup)
in.RichMessage = cloneRichMessage(in.RichMessage)
in.Discussion = cloneChannelDiscussionRef(in.Discussion) in.Discussion = cloneChannelDiscussionRef(in.Discussion)
in.Replies = cloneChannelMessageReplies(in.Replies) in.Replies = cloneChannelMessageReplies(in.Replies)
in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions)

View file

@ -11,7 +11,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
if req.UserID == 0 || req.ChannelID == 0 { if req.UserID == 0 || req.ChannelID == 0 {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() { if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero() {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
s.mu.Lock() s.mu.Lock()
@ -83,6 +83,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards,
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
RichMessage: cloneRichMessage(req.RichMessage),
Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID},
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID, GroupedID: req.GroupedID,
@ -131,6 +132,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media, Media: req.Media,
RichMessage: cloneRichMessage(req.RichMessage),
ReplyTo: replyTo, ReplyTo: replyTo,
Forward: cloneMessageForward(req.Forward), Forward: cloneMessageForward(req.Forward),
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,

View file

@ -840,6 +840,7 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft {
webpage := *draft.WebPage webpage := *draft.WebPage
draft.WebPage = &webpage draft.WebPage = &webpage
} }
draft.RichMessage = cloneRichMessage(draft.RichMessage)
return draft return draft
} }

View file

@ -41,10 +41,19 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ
return res, domain.ErrMessageNotModified return res, domain.ErrMessageNotModified
} }
} }
if req.Message == "" && req.Media == nil && target.Media.IsZero() { finalMedia := target.Media
if req.Media != nil {
finalMedia = req.Media
}
finalRich := target.RichMessage
if req.SetRichMessage {
finalRich = req.RichMessage
}
if req.Message == "" && finalMedia.IsZero() && finalRich.IsZero() {
return res, domain.ErrMessageEmpty return res, domain.ErrMessageEmpty
} }
if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && target.HideEdited == req.HideEdited && equalMessageEntities(target.Entities, req.Entities) { richChanged := req.SetRichMessage && !richMessagesEqual(target.RichMessage, req.RichMessage)
if req.Media == nil && !req.SetReplyMarkup && !richChanged && target.Body == req.Message && target.HideEdited == req.HideEdited && equalMessageEntities(target.Entities, req.Entities) {
return res, domain.ErrMessageNotModified return res, domain.ErrMessageNotModified
} }
messageSenderID := target.From.ID messageSenderID := target.From.ID
@ -73,6 +82,9 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ
// 替换 markup(nil/空 = 清空键盘);双盒一致。 // 替换 markup(nil/空 = 清空键盘);双盒一致。
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
} }
if req.SetRichMessage {
msg.RichMessage = cloneRichMessage(req.RichMessage)
}
msg.EditDate = req.EditDate msg.EditDate = req.EditDate
msg.HideEdited = req.HideEdited msg.HideEdited = req.HideEdited
msg.Pts = s.nextPtsLocked(userID) msg.Pts = s.nextPtsLocked(userID)

View file

@ -3,6 +3,7 @@ package memory
import ( import (
"encoding/binary" "encoding/binary"
"hash/fnv" "hash/fnv"
"reflect"
"sort" "sort"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -118,6 +119,13 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage {
return &clone return &clone
} }
func richMessagesEqual(a, b *domain.MessageRichMessage) bool {
if a.IsZero() && b.IsZero() {
return true
}
return reflect.DeepEqual(a, b)
}
func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply {
if reply == nil { if reply == nil {
return nil return nil

View file

@ -9,6 +9,12 @@ import (
"telesrv/internal/domain" "telesrv/internal/domain"
) )
var validRichMessageBlocks = []byte{
0x15, 0xc4, 0xb5, 0x1c, // vector
0x01, 0x00, 0x00, 0x00, // count
0x8a, 0x7e, 0x56, 0x13, // pageBlockUnsupported
}
func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
ctx := context.Background() ctx := context.Background()
dialogs := NewDialogStore() dialogs := NewDialogStore()
@ -84,6 +90,41 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
} }
} }
func TestMessageStoreEditRichOnlyMessageUsesFinalContentState(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 201,
Date: 1700000200,
RichMessage: &domain.MessageRichMessage{Blocks: validRichMessageBlocks},
})
if err != nil {
t.Fatalf("SendPrivateText rich-only: %v", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: 1000000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
ID: sent.SenderMessage.ID,
Message: "",
HideEdited: true,
EditDate: 1700000210,
})
if err != nil {
t.Fatalf("EditMessage rich-only hide-edit: %v", err)
}
if len(edited.Edited) != 2 {
t.Fatalf("edited boxes = %d, want 2", len(edited.Edited))
}
for _, item := range edited.Edited {
if item.Message.RichMessage.IsZero() {
t.Fatalf("edited rich-only box for user %d lost rich message: %+v", item.UserID, item.Message)
}
}
}
func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
messages := NewMessageStore() messages := NewMessageStore()

View file

@ -12,8 +12,7 @@ import (
) )
func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
// 空文本只在媒体替换(live location 续报/停止)时合法。 if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 {
if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
} }
beginner, ok := s.db.(txBeginner) beginner, ok := s.db.(txBeginner)
@ -31,6 +30,10 @@ func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditCh
if err != nil { if err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit reply markup: %w", err) return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit reply markup: %w", err)
} }
richMessageJSON, err := encodeRichMessage(req.RichMessage)
if err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit rich message: %w", err)
}
tx, err := beginner.Begin(ctx) tx, err := beginner.Begin(ctx)
if err != nil { if err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err) return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err)
@ -52,6 +55,17 @@ func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditCh
if msg.Deleted || msg.Action != nil { if msg.Deleted || msg.Action != nil {
return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid
} }
finalMedia := msg.Media
if req.Media != nil {
finalMedia = req.Media
}
finalRich := msg.RichMessage
if req.SetRichMessage {
finalRich = req.RichMessage
}
if strings.TrimSpace(req.Message) == "" && finalMedia.IsZero() && finalRich.IsZero() {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
}
if req.WebPageResolve { if req.WebPageResolve {
// 频道链接预览就地替换:只换 media(不碰 body/entities/edit_date)+ reserve 频道 pts + // 频道链接预览就地替换:只换 media(不碰 body/entities/edit_date)+ reserve 频道 pts +
// channel_web_page 事件。幂等守卫:仅当前 media 仍是匹配 id 的 pending 占位才换。 // channel_web_page 事件。幂等守卫:仅当前 media 仍是匹配 id 的 pending 占位才换。
@ -110,7 +124,8 @@ WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.ID, mediaJSON, pts); err
if !canWriteEdit { if !canWriteEdit {
return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired
} }
if req.Media == nil && !req.SetReplyMarkup && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { richChanged := req.SetRichMessage && !richMessagesEqual(msg.RichMessage, req.RichMessage)
if req.Media == nil && !req.SetReplyMarkup && !richChanged && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) {
return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified
} }
ptsCount := 1 ptsCount := 1
@ -130,9 +145,10 @@ SET body = $4,
edit_date = $6, edit_date = $6,
pts = $7, pts = $7,
reply_markup = CASE WHEN $9 THEN $10::jsonb ELSE reply_markup END, reply_markup = CASE WHEN $9 THEN $10::jsonb ELSE reply_markup END,
rich_message = CASE WHEN $11 THEN $12::jsonb ELSE rich_message END,
updated_at = now() updated_at = now()
WHERE channel_id = $1 AND id = $2 AND NOT deleted AND (sender_user_id = $3 OR $8)`, WHERE channel_id = $1 AND id = $2 AND NOT deleted AND (sender_user_id = $3 OR $8)`,
req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, editPts, canWriteEdit, req.SetReplyMarkup, string(replyMarkupJSON)); err != nil { req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, editPts, canWriteEdit, req.SetReplyMarkup, string(replyMarkupJSON), req.SetRichMessage, string(richMessageJSON)); err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err) return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err)
} }
if req.Media != nil { if req.Media != nil {
@ -160,6 +176,12 @@ WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.ID, mediaJSON); err != ni
return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit reply markup: %w", err) return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit reply markup: %w", err)
} }
} }
if req.SetRichMessage {
msg.RichMessage, err = decodeRichMessage(string(richMessageJSON))
if err != nil {
return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit rich message: %w", err)
}
}
msg.EditDate = req.EditDate msg.EditDate = req.EditDate
msg.Pts = editPts msg.Pts = editPts
event := domain.ChannelUpdateEvent{ event := domain.ChannelUpdateEvent{

View file

@ -30,6 +30,7 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
var entities, reply, forward, action string var entities, reply, forward, action string
var mediaJSON string var mediaJSON string
var replyMarkupJSON string var replyMarkupJSON string
var richMessageJSON string
var savedPeerType string var savedPeerType string
var savedPeerID int64 var savedPeerID int64
if err := row.Scan( if err := row.Scan(
@ -37,7 +38,7 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
&replyMarkupJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID,
); err != nil { ); err != nil {
return domain.ChannelMessage{}, err return domain.ChannelMessage{}, err
} }
@ -65,6 +66,10 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
if err != nil { if err != nil {
return domain.ChannelMessage{}, err return domain.ChannelMessage{}, err
} }
msg.RichMessage, err = decodeRichMessage(richMessageJSON)
if err != nil {
return domain.ChannelMessage{}, err
}
return msg, nil return msg, nil
} }
@ -82,6 +87,7 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
var count int var count int
var mediaJSON string var mediaJSON string
var replyMarkupJSON string var replyMarkupJSON string
var richMessageJSON string
var savedPeerType string var savedPeerType string
var savedPeerID int64 var savedPeerID int64
if err := row.Scan( if err := row.Scan(
@ -89,7 +95,7 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
&replyMarkupJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count, &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count,
); err != nil { ); err != nil {
return domain.ChannelMessage{}, 0, err return domain.ChannelMessage{}, 0, err
} }
@ -117,6 +123,10 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
if err != nil { if err != nil {
return domain.ChannelMessage{}, 0, err return domain.ChannelMessage{}, 0, err
} }
msg.RichMessage, err = decodeRichMessage(richMessageJSON)
if err != nil {
return domain.ChannelMessage{}, 0, err
}
return msg, count, nil return msg, count, nil
} }

View file

@ -11,7 +11,7 @@ import (
) )
func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero()) { if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
if req.Date == 0 { if req.Date == 0 {
@ -123,6 +123,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media, Media: req.Media,
RichMessage: req.RichMessage,
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID, GroupedID: req.GroupedID,
ReplyMarkup: req.ReplyMarkup, ReplyMarkup: req.ReplyMarkup,
@ -180,6 +181,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media, Media: req.Media,
RichMessage: req.RichMessage,
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID, GroupedID: req.GroupedID,
ReplyMarkup: req.ReplyMarkup, ReplyMarkup: req.ReplyMarkup,
@ -422,6 +424,10 @@ func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMe
if err != nil { if err != nil {
return err return err
} }
richMessage, err := encodeRichMessage(msg.RichMessage)
if err != nil {
return err
}
var sendAsType sql.NullString var sendAsType sql.NullString
var sendAsID sql.NullInt64 var sendAsID sql.NullInt64
if msg.SendAs != nil && msg.SendAs.ID != 0 { if msg.SendAs != nil && msg.SendAs.ID != 0 {
@ -450,12 +456,12 @@ INSERT INTO channel_messages (
channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id,
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards,
body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36)`, ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37)`,
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID, msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID,
sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards, sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards,
msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID,
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID); err != nil { forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID); err != nil {
return fmt.Errorf("insert channel message: %w", err) return fmt.Errorf("insert channel message: %w", err)
} }
// 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。 // 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。

View file

@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body,
entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text, fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text,
reply_markup::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id` reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id`
const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id,
title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id, title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id,

View file

@ -2,6 +2,7 @@ package postgres
import ( import (
"encoding/json" "encoding/json"
"reflect"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -93,6 +94,13 @@ func decodeRichMessage(s string) (*domain.MessageRichMessage, error) {
return &m, nil return &m, nil
} }
func richMessagesEqual(a, b *domain.MessageRichMessage) bool {
if a.IsZero() && b.IsZero() {
return true
}
return reflect.DeepEqual(a, b)
}
func decodePhotoSizes(s string) ([]domain.PhotoSize, error) { func decodePhotoSizes(s string) ([]domain.PhotoSize, error) {
if s == "" || s == "[]" || s == "null" { if s == "" || s == "[]" || s == "null" {
return nil, nil return nil, nil

View file

@ -54,17 +54,26 @@ func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRe
PeerType: string(req.Peer.Type), PeerType: string(req.Peer.Type),
PeerID: req.Peer.ID, PeerID: req.Peer.ID,
}) })
// 空文本只在目标消息携带媒体(或本次写入媒体)时合法(清空 caption);
// 纯文本消息清空会留下既无 body 也无 media 的空壳。
if err == nil && req.Message == "" && req.Media == nil && (target.MediaJson == "" || target.MediaJson == "{}") {
return res, domain.ErrMessageEmpty
}
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return res, domain.ErrMessageIDInvalid return res, domain.ErrMessageIDInvalid
} }
return res, fmt.Errorf("get message for edit: %w", err) return res, fmt.Errorf("get message for edit: %w", err)
} }
targetRich, err := decodeRichMessage(target.RichMessageJson)
if err != nil {
return res, fmt.Errorf("decode target rich message: %w", err)
}
// 空文本只在目标消息携带媒体/rich(或本次写入媒体/rich)时合法(清空 caption)。
if req.Message == "" && req.Media == nil && (target.MediaJson == "" || target.MediaJson == "{}") {
if req.SetRichMessage {
if req.RichMessage.IsZero() {
return res, domain.ErrMessageEmpty
}
} else if targetRich.IsZero() {
return res, domain.ErrMessageEmpty
}
}
oldEntities, err := decodeMessageEntities(target.EntitiesJson) oldEntities, err := decodeMessageEntities(target.EntitiesJson)
if err != nil { if err != nil {
return res, fmt.Errorf("decode target entities: %w", err) return res, fmt.Errorf("decode target entities: %w", err)
@ -74,13 +83,18 @@ func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRe
if !authorEdit && !viaBotEdit && !req.WebPageResolve && !validTodoParticipantEdit(req, target, oldEntities) { if !authorEdit && !viaBotEdit && !req.WebPageResolve && !validTodoParticipantEdit(req, target, oldEntities) {
return res, domain.ErrMessageAuthorRequired return res, domain.ErrMessageAuthorRequired
} }
if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && target.HideEdited == req.HideEdited && sameMessageEntities(oldEntities, req.Entities) { richChanged := req.SetRichMessage && !richMessagesEqual(targetRich, req.RichMessage)
if req.Media == nil && !req.SetReplyMarkup && !richChanged && target.Body == req.Message && target.HideEdited == req.HideEdited && sameMessageEntities(oldEntities, req.Entities) {
return res, domain.ErrMessageNotModified return res, domain.ErrMessageNotModified
} }
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
if err != nil { if err != nil {
return res, fmt.Errorf("encode edit reply markup: %w", err) return res, fmt.Errorf("encode edit reply markup: %w", err)
} }
richMessageJSON, err := encodeRichMessage(req.RichMessage)
if err != nil {
return res, fmt.Errorf("encode edit rich message: %w", err)
}
messageSenderID := target.MessageSenderID messageSenderID := target.MessageSenderID
boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
OwnerUserIds: privateMessageOwnerIDs(req.OwnerUserID, req.Peer.ID), OwnerUserIds: privateMessageOwnerIDs(req.OwnerUserID, req.Peer.ID),
@ -173,6 +187,8 @@ WHERE owner_user_id = $1 AND box_id = $2`, box.OwnerUserID, box.BoxID, int32(pts
HideEdited: req.HideEdited, HideEdited: req.HideEdited,
SetReplyMarkup: req.SetReplyMarkup, SetReplyMarkup: req.SetReplyMarkup,
ReplyMarkupJson: replyMarkupJSON, ReplyMarkupJson: replyMarkupJSON,
SetRichMessage: req.SetRichMessage,
RichMessageJson: richMessageJSON,
}); err != nil { }); err != nil {
return res, fmt.Errorf("update private message edit: %w", err) return res, fmt.Errorf("update private message edit: %w", err)
} }
@ -210,6 +226,8 @@ WHERE message_sender_id = $1 AND private_message_id = $2`, messageSenderID, targ
Pts: int32(pts), Pts: int32(pts),
SetReplyMarkup: req.SetReplyMarkup, SetReplyMarkup: req.SetReplyMarkup,
ReplyMarkupJson: replyMarkupJSON, ReplyMarkupJson: replyMarkupJSON,
SetRichMessage: req.SetRichMessage,
RichMessageJson: richMessageJSON,
}) })
if err != nil { if err != nil {
return res, fmt.Errorf("update message box edit: %w", err) return res, fmt.Errorf("update message box edit: %w", err)

View file

@ -204,6 +204,115 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
assertWebViewData("recipient event", events[0].Message) assertWebViewData("recipient event", events[0].Message)
} }
func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
caller := createTestUser(t, ctx, users, "+1666"+suffix+"41", "CallFirstSender", "")
callee := createTestUser(t, ctx, users, "+1666"+suffix+"42", "CallFirstRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{caller.ID, callee.ID})
})
const callID int64 = 0x1020304050607080
messages := NewMessageStore(pool)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: caller.ID,
RecipientUserID: callee.ID,
RandomID: 4041001,
Date: 1700000410,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionPhoneCall,
Call: &domain.MessagePhoneCallAction{
CallID: callID,
Reason: string(domain.PhoneCallDiscardReasonMissed),
},
},
},
})
if err != nil {
t.Fatalf("SendPrivateText phone call service: %v", err)
}
if sent.SenderMessage.ID != 1 || sent.RecipientMessage.ID != 1 {
t.Fatalf("first phone call boxes = sender %d recipient %d, want both first message", sent.SenderMessage.ID, sent.RecipientMessage.ID)
}
assertPhoneCallMessage := func(name string, msg domain.Message, ownerID, peerID, fromID int64, out bool) {
t.Helper()
if msg.OwnerUserID != ownerID || msg.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: peerID}) ||
msg.From != (domain.Peer{Type: domain.PeerTypeUser, ID: fromID}) || msg.Out != out {
t.Fatalf("%s identity = owner %d peer %+v from %+v out %v", name, msg.OwnerUserID, msg.Peer, msg.From, msg.Out)
}
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindService ||
msg.Media.ServiceAction == nil || msg.Media.ServiceAction.Kind != domain.MessageServiceActionPhoneCall ||
msg.Media.ServiceAction.Call == nil {
t.Fatalf("%s media = %+v, want phone_call service action", name, msg.Media)
}
call := msg.Media.ServiceAction.Call
if call.CallID != callID || call.Reason != string(domain.PhoneCallDiscardReasonMissed) || call.Duration != 0 {
t.Fatalf("%s phone call action = %+v", name, call)
}
}
assertPhoneCallMessage("sender box", sent.SenderMessage, caller.ID, callee.ID, caller.ID, true)
assertPhoneCallMessage("recipient box", sent.RecipientMessage, callee.ID, caller.ID, caller.ID, false)
dialogs := NewDialogStore(pool)
calleeDialogs, err := dialogs.ListByUser(ctx, callee.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("callee dialogs: %v", err)
}
if len(calleeDialogs.Dialogs) != 1 || calleeDialogs.Dialogs[0].TopMessage != sent.RecipientMessage.ID ||
calleeDialogs.Dialogs[0].UnreadCount != 1 || calleeDialogs.Dialogs[0].ReadInboxMaxID != 0 {
t.Fatalf("callee dialogs = %+v, want unread first call service as top", calleeDialogs.Dialogs)
}
if len(calleeDialogs.Messages) != 1 {
t.Fatalf("callee dialog messages = %+v, want top message payload", calleeDialogs.Messages)
}
assertPhoneCallMessage("callee dialog top", calleeDialogs.Messages[0], callee.ID, caller.ID, caller.ID, false)
if _, ok := findDialogUserByID(calleeDialogs.Users, caller.ID); !ok {
t.Fatalf("callee dialog users = %+v, want caller snapshot", calleeDialogs.Users)
}
calleeHistory, err := messages.ListByUser(ctx, callee.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: caller.ID},
Limit: 10,
})
if err != nil || len(calleeHistory.Messages) != 1 {
t.Fatalf("callee history = %+v err=%v, want one first call service", calleeHistory, err)
}
assertPhoneCallMessage("callee history", calleeHistory.Messages[0], callee.ID, caller.ID, caller.ID, false)
if _, ok := findDialogUserByID(calleeHistory.Users, caller.ID); !ok {
t.Fatalf("callee history users = %+v, want caller snapshot", calleeHistory.Users)
}
updates := NewUpdateEventStore(pool)
calleeEvents, err := updates.ListAfter(ctx, callee.ID, 0, 10)
if err != nil || len(calleeEvents) != 1 {
t.Fatalf("callee events = %+v err=%v, want one new_message", calleeEvents, err)
}
if calleeEvents[0].Type != domain.UpdateEventNewMessage || calleeEvents[0].Pts != sent.RecipientMessage.Pts {
t.Fatalf("callee event = %+v, want new_message pts %d", calleeEvents[0], sent.RecipientMessage.Pts)
}
assertPhoneCallMessage("callee difference event", calleeEvents[0].Message, callee.ID, caller.ID, caller.ID, false)
if _, ok := findDialogUserByID(calleeEvents[0].Users, caller.ID); !ok {
t.Fatalf("callee event users = %+v, want caller snapshot", calleeEvents[0].Users)
}
batch, err := updates.BatchByCursor(ctx, []store.EventCursor{{UserID: callee.ID, Pts: calleeEvents[0].Pts}})
if err != nil || len(batch) != 1 {
t.Fatalf("callee batch events = %+v err=%v, want one dispatch event", batch, err)
}
assertPhoneCallMessage("callee dispatch event", batch[0].Message, callee.ID, caller.ID, caller.ID, false)
if _, ok := findDialogUserByID(batch[0].Users, caller.ID); !ok {
t.Fatalf("callee batch users = %+v, want caller snapshot", batch[0].Users)
}
}
func TestUpdateEventStorePreservesChannelForwardRefsWithoutChannelSnapshot(t *testing.T) { func TestUpdateEventStorePreservesChannelForwardRefsWithoutChannelSnapshot(t *testing.T) {
pool := testPool(t) pool := testPool(t)
ctx := context.Background() ctx := context.Background()

View file

@ -1036,6 +1036,10 @@ SET body = sqlc.arg(body)::text,
reply_markup = CASE reply_markup = CASE
WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb
ELSE reply_markup ELSE reply_markup
END,
rich_message = CASE
WHEN sqlc.arg(set_rich_message)::boolean THEN sqlc.arg(rich_message_json)::jsonb
ELSE rich_message
END END
WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint
AND id = sqlc.arg(private_message_id)::bigint; AND id = sqlc.arg(private_message_id)::bigint;
@ -1050,6 +1054,10 @@ SET body = sqlc.arg(body)::text,
reply_markup = CASE reply_markup = CASE
WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb
ELSE reply_markup ELSE reply_markup
END,
rich_message = CASE
WHEN sqlc.arg(set_rich_message)::boolean THEN sqlc.arg(rich_message_json)::jsonb
ELSE rich_message
END END
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND box_id = sqlc.arg(box_id)::int AND box_id = sqlc.arg(box_id)::int

View file

@ -27,7 +27,7 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel { if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel {
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid peer") return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid peer")
} }
if req.Message == "" && req.Media.IsZero() { if req.Message == "" && req.Media.IsZero() && req.RichMessage.IsZero() {
return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: empty message") return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: empty message")
} }
if req.Date == 0 { if req.Date == 0 {
@ -41,6 +41,10 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
if err != nil { if err != nil {
return domain.ScheduledMessage{}, err return domain.ScheduledMessage{}, err
} }
richMessage, err := encodeRichMessage(req.RichMessage)
if err != nil {
return domain.ScheduledMessage{}, err
}
meta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, req.ReplyTo, req.Forward) meta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, req.ReplyTo, req.Forward)
if err != nil { if err != nil {
return domain.ScheduledMessage{}, err return domain.ScheduledMessage{}, err
@ -87,7 +91,7 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO scheduled_messages ( INSERT INTO scheduled_messages (
owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date, owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date,
body, entities, media, silent, noforwards, body, entities, media, rich_message, silent, noforwards,
reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
quote_text, quote_entities, quote_offset, quote_text, quote_entities, quote_offset,
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date,
@ -95,14 +99,14 @@ INSERT INTO scheduled_messages (
schedule_date, schedule_repeat_period, state, created_at, updated_at schedule_date, schedule_repeat_period, state, created_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $1, $2, $3, $4, $5, $6,
$7, $8::jsonb, $9::jsonb, $10, $11, $7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12,
$12, $13, $14, $15, $13, $14, $15, $16,
$16, $17::jsonb, $18, $17, $18::jsonb, $19,
$19, $20, $21, $22, $20, $21, $22, $23,
$23, $24, $24, $25,
$25, $26, 'pending', $27, $27 $26, $27, 'pending', $28, $28
)`, req.OwnerUserID, nextID, string(req.Peer.Type), req.Peer.ID, req.RandomID, req.Date, )`, req.OwnerUserID, nextID, string(req.Peer.Type), req.Peer.ID, req.RandomID, req.Date,
req.Message, entities, media, req.Silent, req.NoForwards, req.Message, entities, media, richMessage, req.Silent, req.NoForwards,
meta.ReplyToMsgID, meta.ReplyToPeerType, meta.ReplyToPeerID, meta.ReplyToTopID, meta.ReplyToMsgID, meta.ReplyToPeerType, meta.ReplyToPeerID, meta.ReplyToTopID,
meta.QuoteText, meta.QuoteEntitiesJSON, meta.QuoteOffset, meta.QuoteText, meta.QuoteEntitiesJSON, meta.QuoteOffset,
meta.FwdFromPeerType, meta.FwdFromPeerID, meta.FwdFromName, meta.FwdDate, meta.FwdFromPeerType, meta.FwdFromPeerID, meta.FwdFromName, meta.FwdDate,
@ -167,29 +171,38 @@ func (s *MessageStore) EditScheduledMessage(ctx context.Context, req domain.Edit
} }
message := current.Message message := current.Message
entities := append([]domain.MessageEntity(nil), current.Entities...) entities := append([]domain.MessageEntity(nil), current.Entities...)
richMessage := current.RichMessage
if req.SetMessage { if req.SetMessage {
if req.Message == "" && current.Media.IsZero() {
return domain.ScheduledMessage{}, domain.ErrMessageEmpty
}
message = req.Message message = req.Message
entities = append([]domain.MessageEntity(nil), req.Entities...) entities = append([]domain.MessageEntity(nil), req.Entities...)
} }
if req.SetRichMessage {
richMessage = req.RichMessage
}
if message == "" && current.Media.IsZero() && richMessage.IsZero() {
return domain.ScheduledMessage{}, domain.ErrMessageEmpty
}
encodedEntities, err := encodeMessageEntities(entities) encodedEntities, err := encodeMessageEntities(entities)
if err != nil { if err != nil {
return domain.ScheduledMessage{}, err return domain.ScheduledMessage{}, err
} }
encodedRichMessage, err := encodeRichMessage(richMessage)
if err != nil {
return domain.ScheduledMessage{}, err
}
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
UPDATE scheduled_messages UPDATE scheduled_messages
SET body = $5, SET body = $5,
entities = $6::jsonb, entities = $6::jsonb,
schedule_date = $7, rich_message = $7::jsonb,
updated_at = $8 schedule_date = $8,
updated_at = $9
WHERE owner_user_id = $1 WHERE owner_user_id = $1
AND peer_type = $2 AND peer_type = $2
AND peer_id = $3 AND peer_id = $3
AND scheduled_id = $4 AND scheduled_id = $4
AND state = 'pending' AND state = 'pending'
RETURNING `+scheduledMessageSelectColumns(), req.OwnerUserID, string(req.Peer.Type), req.Peer.ID, req.ID, message, encodedEntities, req.ScheduleDate, req.Date) RETURNING `+scheduledMessageSelectColumns(), req.OwnerUserID, string(req.Peer.Type), req.Peer.ID, req.ID, message, encodedEntities, encodedRichMessage, req.ScheduleDate, req.Date)
msg, err := scanScheduledMessage(row) msg, err := scanScheduledMessage(row)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@ -472,7 +485,7 @@ LIMIT $4`, filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, limit)
func scheduledMessageSelectColumns() string { func scheduledMessageSelectColumns() string {
return `owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date, return `owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date,
body, entities::text, media::text, silent, noforwards, body, entities::text, media::text, rich_message::text, silent, noforwards,
reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
quote_text, quote_entities::text, quote_offset, quote_text, quote_entities::text, quote_offset,
fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date,
@ -486,7 +499,7 @@ func scheduledMessageSelectColumnsFor(alias string) string {
} }
prefix := alias + "." prefix := alias + "."
return prefix + `owner_user_id, ` + prefix + `scheduled_id, ` + prefix + `peer_type, ` + prefix + `peer_id, ` + prefix + `random_id, ` + prefix + `message_date, return prefix + `owner_user_id, ` + prefix + `scheduled_id, ` + prefix + `peer_type, ` + prefix + `peer_id, ` + prefix + `random_id, ` + prefix + `message_date,
` + prefix + `body, ` + prefix + `entities::text, ` + prefix + `media::text, ` + prefix + `silent, ` + prefix + `noforwards, ` + prefix + `body, ` + prefix + `entities::text, ` + prefix + `media::text, ` + prefix + `rich_message::text, ` + prefix + `silent, ` + prefix + `noforwards,
` + prefix + `reply_to_msg_id, ` + prefix + `reply_to_peer_type, ` + prefix + `reply_to_peer_id, ` + prefix + `reply_to_top_id, ` + prefix + `reply_to_msg_id, ` + prefix + `reply_to_peer_type, ` + prefix + `reply_to_peer_id, ` + prefix + `reply_to_top_id,
` + prefix + `quote_text, ` + prefix + `quote_entities::text, ` + prefix + `quote_offset, ` + prefix + `quote_text, ` + prefix + `quote_entities::text, ` + prefix + `quote_offset,
` + prefix + `fwd_from_peer_type, ` + prefix + `fwd_from_peer_id, ` + prefix + `fwd_from_name, ` + prefix + `fwd_date, ` + prefix + `fwd_from_peer_type, ` + prefix + `fwd_from_peer_id, ` + prefix + `fwd_from_name, ` + prefix + `fwd_date,
@ -516,6 +529,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
peerType string peerType string
entitiesJSON string entitiesJSON string
mediaJSON string mediaJSON string
richMessageJSON string
replyToMsgID int32 replyToMsgID int32
replyToPeerType string replyToPeerType string
replyToPeerID int64 replyToPeerID int64
@ -533,7 +547,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
) )
if err := scanner.Scan( if err := scanner.Scan(
&msg.OwnerUserID, &msg.ID, &peerType, &msg.Peer.ID, &msg.RandomID, &msg.CreatedAt, &msg.OwnerUserID, &msg.ID, &peerType, &msg.Peer.ID, &msg.RandomID, &msg.CreatedAt,
&msg.Message, &entitiesJSON, &mediaJSON, &msg.Silent, &msg.NoForwards, &msg.Message, &entitiesJSON, &mediaJSON, &richMessageJSON, &msg.Silent, &msg.NoForwards,
&replyToMsgID, &replyToPeerType, &replyToPeerID, &replyToTopID, &replyToMsgID, &replyToPeerType, &replyToPeerID, &replyToTopID,
&quoteText, &quoteEntitiesJSON, &quoteOffset, &quoteText, &quoteEntitiesJSON, &quoteOffset,
&fwdFromPeerType, &fwdFromPeerID, &fwdFromName, &fwdDate, &fwdFromPeerType, &fwdFromPeerID, &fwdFromName, &fwdDate,
@ -554,6 +568,11 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled media: %w", err) return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled media: %w", err)
} }
msg.Media = media msg.Media = media
richMessage, err := decodeRichMessage(richMessageJSON)
if err != nil {
return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled rich message: %w", err)
}
msg.RichMessage = richMessage
// scheduled_messages 不存 saved_from:到点投递经 SendPrivateText 实时 // scheduled_messages 不存 saved_from:到点投递经 SendPrivateText 实时
// 重算 saved 语义(self-chat 直发归 self),fwd saved 维度恒空。 // 重算 saved 语义(self-chat 直发归 self),fwd saved 维度恒空。
_, _, reply, forward, err := messageMetadataFromFields( _, _, reply, forward, err := messageMetadataFromFields(

View file

@ -107,4 +107,72 @@ func TestScheduledMessageEditPreservesContentWhenMessageUnset(t *testing.T) {
if !errors.Is(err, domain.ErrMessageEmpty) { if !errors.Is(err, domain.ErrMessageEmpty) {
t.Fatalf("empty text scheduled edit err = %v, want ErrMessageEmpty", err) t.Fatalf("empty text scheduled edit err = %v, want ErrMessageEmpty", err)
} }
richInitial := &domain.MessageRichMessage{Rtl: true, Blocks: []byte{1, 2, 3, 4}}
richOnly, err := messages.CreateScheduledMessage(ctx, domain.ScheduleMessageRequest{
OwnerUserID: owner.ID,
Peer: peer,
RandomID: 7003,
RichMessage: richInitial,
ScheduleDate: 1700021600,
Date: 1700000500,
})
if err != nil {
t.Fatalf("create rich scheduled message: %v", err)
}
if !richMessagesEqual(richOnly.RichMessage, richInitial) || richOnly.Message != "" || !richOnly.Media.IsZero() {
t.Fatalf("rich scheduled create = %+v, want rich-only content", richOnly)
}
gotRich, err := messages.GetScheduledMessages(ctx, domain.ScheduledMessageFilter{
OwnerUserID: owner.ID,
Peer: peer,
IDs: []int{richOnly.ID},
Limit: 10,
})
if err != nil {
t.Fatalf("get rich scheduled message: %v", err)
}
if len(gotRich.Messages) != 1 || !richMessagesEqual(gotRich.Messages[0].RichMessage, richInitial) {
t.Fatalf("get rich scheduled = %+v, want original rich", gotRich.Messages)
}
richDateOnly, err := messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{
OwnerUserID: owner.ID,
Peer: peer,
ID: richOnly.ID,
ScheduleDate: 1700025200,
Date: 1700000600,
})
if err != nil {
t.Fatalf("date-only edit rich scheduled message: %v", err)
}
if richDateOnly.ScheduleDate != 1700025200 || !richMessagesEqual(richDateOnly.RichMessage, richInitial) {
t.Fatalf("date-only rich scheduled edit = %+v, want original rich and new date", richDateOnly)
}
richEdited := &domain.MessageRichMessage{Part: true, Blocks: []byte{5, 6, 7}}
editedRich, err := messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{
OwnerUserID: owner.ID,
Peer: peer,
ID: richOnly.ID,
SetRichMessage: true,
RichMessage: richEdited,
ScheduleDate: 1700028800,
Date: 1700000700,
})
if err != nil {
t.Fatalf("edit rich scheduled message: %v", err)
}
if !richMessagesEqual(editedRich.RichMessage, richEdited) || editedRich.ScheduleDate != 1700028800 {
t.Fatalf("edited rich scheduled = %+v, want replacement rich", editedRich)
}
_, err = messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{
OwnerUserID: owner.ID,
Peer: peer,
ID: richOnly.ID,
SetRichMessage: true,
ScheduleDate: 1700032400,
Date: 1700000800,
})
if !errors.Is(err, domain.ErrMessageEmpty) {
t.Fatalf("clear only rich scheduled edit err = %v, want ErrMessageEmpty", err)
}
} }

View file

@ -3554,9 +3554,13 @@ SET body = $1::text,
reply_markup = CASE reply_markup = CASE
WHEN $6::boolean THEN $7::jsonb WHEN $6::boolean THEN $7::jsonb
ELSE reply_markup ELSE reply_markup
END,
rich_message = CASE
WHEN $8::boolean THEN $9::jsonb
ELSE rich_message
END END
WHERE owner_user_id = $8::bigint WHERE owner_user_id = $10::bigint
AND box_id = $9::int AND box_id = $11::int
AND NOT deleted AND NOT deleted
RETURNING RETURNING
box_id, box_id,
@ -3613,6 +3617,8 @@ type UpdateMessageBoxEditParams struct {
Pts int32 Pts int32
SetReplyMarkup bool SetReplyMarkup bool
ReplyMarkupJson []byte ReplyMarkupJson []byte
SetRichMessage bool
RichMessageJson []byte
OwnerUserID int64 OwnerUserID int64
BoxID int32 BoxID int32
} }
@ -3673,6 +3679,8 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox
arg.Pts, arg.Pts,
arg.SetReplyMarkup, arg.SetReplyMarkup,
arg.ReplyMarkupJson, arg.ReplyMarkupJson,
arg.SetRichMessage,
arg.RichMessageJson,
arg.OwnerUserID, arg.OwnerUserID,
arg.BoxID, arg.BoxID,
) )
@ -3735,9 +3743,13 @@ SET body = $1::text,
reply_markup = CASE reply_markup = CASE
WHEN $5::boolean THEN $6::jsonb WHEN $5::boolean THEN $6::jsonb
ELSE reply_markup ELSE reply_markup
END,
rich_message = CASE
WHEN $7::boolean THEN $8::jsonb
ELSE rich_message
END END
WHERE sender_user_id = $7::bigint WHERE sender_user_id = $9::bigint
AND id = $8::bigint AND id = $10::bigint
` `
type UpdatePrivateMessageEditParams struct { type UpdatePrivateMessageEditParams struct {
@ -3747,6 +3759,8 @@ type UpdatePrivateMessageEditParams struct {
HideEdited bool HideEdited bool
SetReplyMarkup bool SetReplyMarkup bool
ReplyMarkupJson []byte ReplyMarkupJson []byte
SetRichMessage bool
RichMessageJson []byte
SenderUserID int64 SenderUserID int64
PrivateMessageID int64 PrivateMessageID int64
} }
@ -3759,6 +3773,8 @@ func (q *Queries) UpdatePrivateMessageEdit(ctx context.Context, arg UpdatePrivat
arg.HideEdited, arg.HideEdited,
arg.SetReplyMarkup, arg.SetReplyMarkup,
arg.ReplyMarkupJson, arg.ReplyMarkupJson,
arg.SetRichMessage,
arg.RichMessageJson,
arg.SenderUserID, arg.SenderUserID,
arg.PrivateMessageID, arg.PrivateMessageID,
) )