Merge branch 'iamxvbaba:main' into main
This commit is contained in:
commit
36bf20b341
76 changed files with 2870 additions and 175 deletions
66
data/langpack/weba/weba_en_v12000002.strings
Normal file
66
data/langpack/weba/weba_en_v12000002.strings
Normal 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
27
data/server_rsa.pem
Normal 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
8
data/server_rsa.pub
Normal 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-----
|
||||||
8
data/server_rsa_android.pub
Normal file
8
data/server_rsa_android.pub
Normal 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-----
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
20
deploy/migrations/0050_rich_message_content_checks.down.sql
Normal file
20
deploy/migrations/0050_rich_message_content_checks.down.sql
Normal 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
|
||||||
|
);
|
||||||
22
deploy/migrations/0050_rich_message_content_checks.up.sql
Normal file
22
deploy/migrations/0050_rich_message_content_checks.up.sql
Normal 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
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE public.scheduled_messages
|
||||||
|
DROP COLUMN IF EXISTS rich_message;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE public.scheduled_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS rich_message jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||||
18
go.mod
18
go.mod
|
|
@ -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
36
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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...)
|
||||||
|
|
|
||||||
|
|
@ -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 提供客户端启动配置与国家区号目录。
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
internal/app/langpack/service_test.go
Normal file
81
internal/app/langpack/service_test.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
|
@ -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}},
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 的
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 暴露并随附原始帧(旧版仅靠错误文案匹配,升级后失效)。
|
||||||
|
|
|
||||||
399
internal/mtprotoedge/exchange_compat.go
Normal file
399
internal/mtprotoedge/exchange_compat.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 会被静默裁剪。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
156
internal/rpc/reactions_android_compat.go
Normal file
156
internal/rpc/reactions_android_compat.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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" {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 媒体标签页。
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
"eText, "eEntitiesJSON, "eOffset,
|
"eText, "eEntitiesJSON, "eOffset,
|
||||||
&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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue