fix: align private and channel update semantics

(cherry picked from commit c65f76f56278f74082c4fa792ed49104d5d33c38)
This commit is contained in:
A 2026-06-07 22:22:23 +08:00
parent dce7b92772
commit d84fa6e126
36 changed files with 1765 additions and 382 deletions

View file

@ -0,0 +1,35 @@
DROP TABLE IF EXISTS contact_blocks;
ALTER TABLE user_update_events
DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE user_update_events
ADD CONSTRAINT user_update_events_type_check CHECK (
event_type IN (
'new_message',
'read_history_inbox',
'read_history_outbox',
'edit_message',
'message_reactions',
'contacts_reset',
'dialog_pinned',
'pinned_dialogs',
'dialog_unread_mark',
'peer_settings',
'delete_messages',
'dialog_filter',
'dialog_filter_order',
'dialog_filters',
'folder_peers',
'channel_available_messages',
'channel_view_forum_as_messages',
'noop'
)
);
ALTER TABLE channel_unread_mentions
DROP COLUMN IF EXISTS media_unread;
ALTER TABLE message_boxes
DROP COLUMN IF EXISTS reaction_unread,
DROP COLUMN IF EXISTS media_unread;

View file

@ -0,0 +1,58 @@
ALTER TABLE message_boxes
ADD COLUMN IF NOT EXISTS media_unread BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS reaction_unread BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE channel_unread_mentions
ADD COLUMN IF NOT EXISTS media_unread BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE user_update_events
DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE user_update_events
ADD CONSTRAINT user_update_events_type_check CHECK (
event_type IN (
'new_message',
'read_history_inbox',
'read_history_outbox',
'read_message_contents',
'edit_message',
'message_reactions',
'contacts_reset',
'dialog_pinned',
'pinned_dialogs',
'dialog_unread_mark',
'peer_settings',
'delete_messages',
'dialog_filter',
'dialog_filter_order',
'dialog_filters',
'folder_peers',
'channel_available_messages',
'channel_view_forum_as_messages',
'noop'
)
);
CREATE TABLE IF NOT EXISTS contact_blocks (
owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (owner_user_id, blocked_user_id)
) PARTITION BY HASH (owner_user_id);
CREATE INDEX IF NOT EXISTS contact_blocks_owner_date_idx
ON contact_blocks (owner_user_id, date DESC, blocked_user_id DESC);
DO $$
DECLARE
i int;
BEGIN
FOR i IN 0..63 LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS contact_blocks_p%s PARTITION OF contact_blocks FOR VALUES WITH (MODULUS 64, REMAINDER %s)',
lpad(i::text, 2, '0'),
i
);
END LOOP;
END $$;

View file

@ -38,7 +38,7 @@ Date: 2026-06-01
- `channels.setStickers`files/sticker store 未接入前只允许 megagroup `inputStickerSetEmpty` 清空 no-op 兼容;非空 sticker set 返回 `STICKERSET_INVALID`,避免 TDesktop 误以为群贴纸集已持久化。`channels.reorderUsernames/toggleUsername/deactivateAllUsernames` 仍先做权限校验型兼容响应Fragment/多 username 入口不改主 username主 username 只允许 `channels.updateUsername` 设置或清除。
- TDesktop 已有调用证据但非首批真实业务的入口统一注册为显式 stub`channels.reportSpam/editLocation/convertToGigagroup/reportAntiSpamFalsePositive/setBoostsToUnblockRestrictions/setEmojiStickers/checkSearchPostsFlood/setMainProfileTab``channels.searchPosts` 已提升为公开频道/超级群帖子真实搜索,`checkSearchPostsFlood` 仍保持免费额度兼容 stub二者共享 query 长度边界。`setBoostsToUnblockRestrictions` 按 Layer225 限制 0..8。`channels.restrictSponsoredMessages/updatePaidMessagesPrice/toggleAutotranslation` 已提升为最小真实设置持久化:分别回填 `ChannelFull.restricted_sponsored``Channel/ChannelFull.send_paid_messages_stars` + broadcast `Channel.broadcast_messages_allowed``Channel.autotranslation`,其中 `updatePaidMessagesPrice` 按 TDesktop 默认 app config 限制 stars<=10000并允许 broadcast direct messages 用 `-1` 表示关闭真实广告投放、boost/premium 校验、paid messages/monoforum/结算后续补。`channels.exportMessageLink``channels.readMessageContents``channels.getMessageAuthor``channels.deleteParticipantHistory``getInactiveChannels``getChannelRecommendations``toggleJoinToSend``toggleJoinRequest``toggleParticipantsHidden``toggleForum``toggleViewForumAsMessages``toggleAntiSpam``getLeftChannels``getGroupsForDiscussion``setDiscussionGroup` 已从该列表提升为真实实现。
- `channels.getGroupsForDiscussion/setDiscussionGroup` 真实维护 broadcast channel 与 megagroup 的双向 `linked_chat_id`。候选列表只返回当前用户可管理的 supergroup不返回 legacy basic group设置时校验 access_hash、broadcast/group 类型、管理权限与 hidden prehistory替换链接会同步清理旧 group/old broadcastTDesktop 通过 `Channel.has_link``ChannelFull.linked_chat_id` 刷新讨论组入口。linked broadcast 发新 post 时会在 discussion megagroup 创建一条 forwarded root messagesource post 保存 `discussion_channel_id/message_id``messages.getDiscussionMessage/getReplies/readDiscussion` 都映射到该 root 和 target channel read state。
- TDesktop 已有源码调用但当前业务模型尚未维护独立 media/sticker/custom-emoji/saved-message tag assignment/poll/todo/scheduled 的 `messages.reportSpam/report/reportReaction/reportMessagesDelivery/reportReadMetrics/reportMusicListen/reportSponsoredMessage/getSavedReactionTags/updateSavedReactionTag/getDefaultTagReactions/getExtendedMedia/getAttachedStickers/getCustomEmojiDocuments/searchStickerSets/searchStickers/getEmojiKeywords/getEmojiKeywordsDifference/sendVote/getPollResults/getPollVotes/addPollAnswer/deletePollAnswer/getUnreadPollVotes/readPollVotes/appendTodoList/toggleTodoCompleted/getSearchCounters/getSearchResultsCalendar/getSearchResultsPositions/getOnlines/getWebPagePreview/uploadMedia/sendMedia/sendMultiMedia/getScheduledHistory/getScheduledMessages/sendScheduledMessages/deleteScheduledMessages` 均显式注册统一做参数上限、peer 校验和空/零兼容响应,禁止落到未知 RPC。其中 `messages.readMessageContents` 已提升为 private exact-id real-partial只校验当前账号可见 message box 并向其它 session 推 content-read update不维护 media/reaction content-read 状态channel/supergroup 的 `channels.readMessageContents` 会按可见消息 ID 清理当前作者视角的 unread reaction、重算 `channel_dialogs.unread_reactions_count`,并向当前账号 session 推 `updateMessageReactions` 让 TDesktop 立即去掉 dialog reaction 角标;`messages.getMessagesViews` 已维护 channel-scoped views 去重计数并返回 replies/comment 统计;`messages.getUnreadMentions/readMentions` 已维护 channel-scoped unread mention indexreadMentions 返回 channel pts`messages.sendReaction/getMessagesReactions/getMessageReactionsList/getUnreadReactions/readReactions/getRecentReactions/clearRecentReactions/getTopReactions` 已提升为 private + channel/supergroup emoji reaction 最小真实实现private 写 `private_message_reactions` 并按双端 owner-visible message box 返回/推送 `updateMessageReactions`channel 写 `channel_message_reactions` 并按消息作者维护 unread reaction 状态history/getMessages/search 均回填 `message.reactions``add_to_recent` 写账号级最近 channel reaction 列表且 get/clear 支持 hash/notModified`getTopReactions` 按账号使用次数排序并优先用真实 `available_reactions` catalog 兜底;`messages.getSavedReactionTags/updateSavedReactionTag` 已持久化账号级 emoji saved reaction tag 标题并向其它 session 推 `updateSavedReactionTags`,但 peer 维度 count 与 saved-message tag assignment 仍返回空;`messages.getForumTopics/getForumTopicsByID/createForumTopic/editForumTopic/updatePinnedForumTopic/reorderPinnedForumTopics/deleteTopicHistory` 已从纯空响应提升为真实最小 topic store虚拟 General + root service message topic + bounded delete`messages.getCommonChats` 已提升为真实共同超级群查询并回填 `users.getFullUser.common_chats_count``messages.report` 保持 TDesktop 分步举报 UI 所需的 choose option/add comment/reported 形态但暂不落库report/metrics/music/sponsored 这类 telemetry 入口只返回 BoolTrue/reported不写业务状态TDesktop 资料页 shared media count 会用 `messages.search(limit=0, filter=photo/video/document/url/gif/music/roundVoice/poll)``messages.channelMessages.count`,当前显式返回空页/count=0避免纯文本 channel history 污染 photos/videos/files 等计数search calendar 空结果会回填请求 offset date/id避免空月份重复拉取default tag、sticker/custom emoji 与 extended media 均只返回空/notModified 或明确错误,不伪造 paid media 或 tag 绑定状态poll/todo mutating 入口在缺少媒体 store 时返回可解释错误,不伪造 `updateMessagePoll` 或 todo service messageweb preview 返回 `messageMediaEmpty` 且不会抓外网,`sendMedia(inputMediaWebPage)` 降级为纯文本发送,真实 photo/document/poll/album、todo、scheduled store 留待后续模型。
- TDesktop 已有源码调用但当前业务模型尚未维护独立 media/sticker/custom-emoji/saved-message tag assignment/poll/todo/scheduled 的 `messages.reportSpam/report/reportReaction/reportMessagesDelivery/reportReadMetrics/reportMusicListen/reportSponsoredMessage/getSavedReactionTags/updateSavedReactionTag/getDefaultTagReactions/getExtendedMedia/getAttachedStickers/getCustomEmojiDocuments/searchStickerSets/searchStickers/getEmojiKeywords/getEmojiKeywordsDifference/sendVote/getPollResults/getPollVotes/addPollAnswer/deletePollAnswer/getUnreadPollVotes/readPollVotes/appendTodoList/toggleTodoCompleted/getSearchCounters/getSearchResultsCalendar/getSearchResultsPositions/getOnlines/getWebPagePreview/uploadMedia/sendMedia/sendMultiMedia/getScheduledHistory/getScheduledMessages/sendScheduledMessages/deleteScheduledMessages` 均显式注册统一做参数上限、peer 校验和空/零兼容响应,禁止落到未知 RPC。其中 `messages.readMessageContents` 已提升为 private real-content-read清理 owner 侧 `message_boxes.media_unread/reaction_unread`,有变化才生成 durable `updateReadMessagesContents`channel/supergroup 的 `channels.readMessageContents` 会按可见消息 ID 清理当前作者视角的 unread reaction、重算 `channel_dialogs.unread_reactions_count`,并向当前账号 session 推 `updateMessageReactions` 让 TDesktop 立即去掉 dialog reaction 角标;`messages.getMessagesViews` 已维护 channel-scoped views 去重计数并返回 replies/comment 统计;`messages.getUnreadMentions/readMentions` 已维护 channel-scoped unread mention indexhistory/getMessages/difference/online update 按 viewer 回填 `mentioned/media_unread`readMentions 返回 channel pts`messages.sendReaction/getMessagesReactions/getMessageReactionsList/getUnreadReactions/readReactions/getRecentReactions/clearRecentReactions/getTopReactions` 已提升为 private + channel/supergroup emoji reaction 最小真实实现private 写 `private_message_reactions` 并按双端 owner-visible message box 返回/推送 `updateMessageReactions`channel 写 `channel_message_reactions` 并按消息作者维护 unread reaction 状态history/getMessages/search 均回填 `message.reactions``add_to_recent` 写账号级最近 channel reaction 列表且 get/clear 支持 hash/notModified`getTopReactions` 按账号使用次数排序并优先用真实 `available_reactions` catalog 兜底;`messages.getSavedReactionTags/updateSavedReactionTag` 已持久化账号级 emoji saved reaction tag 标题并向其它 session 推 `updateSavedReactionTags`,但 peer 维度 count 与 saved-message tag assignment 仍返回空;`messages.getForumTopics/getForumTopicsByID/createForumTopic/editForumTopic/updatePinnedForumTopic/reorderPinnedForumTopics/deleteTopicHistory` 已从纯空响应提升为真实最小 topic store虚拟 General + root service message topic + bounded delete`messages.getCommonChats` 已提升为真实共同超级群查询并回填 `users.getFullUser.common_chats_count``messages.report` 保持 TDesktop 分步举报 UI 所需的 choose option/add comment/reported 形态但暂不落库report/metrics/music/sponsored 这类 telemetry 入口只返回 BoolTrue/reported不写业务状态TDesktop 资料页 shared media count 会用 `messages.search(limit=0, filter=photo/video/document/url/gif/music/roundVoice/poll)``messages.channelMessages.count`,当前显式返回空页/count=0避免纯文本 channel history 污染 photos/videos/files 等计数search calendar 空结果会回填请求 offset date/id避免空月份重复拉取default tag、sticker/custom emoji 与 extended media 均只返回空/notModified 或明确错误,不伪造 paid media 或 tag 绑定状态poll/todo mutating 入口在缺少媒体 store 时返回可解释错误,不伪造 `updateMessagePoll` 或 todo service messageweb preview 返回 `messageMediaEmpty` 且不会抓外网,`sendMedia(inputMediaWebPage)` 降级为纯文本发送,真实 photo/document/poll/album、todo、scheduled store 留待后续模型。
- legacy chat 管理入口 `messages.getChats/getFullChat/addChatUser/deleteChatUser/editChatTitle/editChatPhoto/editChatAdmin/editChatAbout/editChatDefaultBannedRights/editChatParticipantRank` 统一映射到 megagroup/channel 语义;其中 `about` 与 default banned rights 真实持久化default banned rights 参与普通成员发送和邀请权限校验。`editChatCreator` 已显式注册并校验 peer/user但账号 2FA/SRP 与所有权转移事务未接入前返回可解释的密码错误,不进入 fallback。
## Reference Audit
@ -114,7 +114,7 @@ username 与管理项方面,参考实现 的 `channels.checkUsername` 只校
参考实现 的 `messages.toggleNoForwards` 已实现 channel 路径:先校验 access hash再发布 `ToggleChannelNoForwardsCommand`,通过 channel 聚合返回 updates`messages.setChatAvailableReactions``messages.setChatTheme` handler 仍未实现,但 `ChannelFullReadModel/ChannelFullMapper` 已有 `ReactionType/AvailableReactions/ReactionsLimit``chatReactions*` 的映射。telesrv 因此不把 reactions 当纯兼容噪声,而是持久化到 channel setting并在 full channel 中恢复给 TDesktop。
参考实现 对 TDesktop 长尾入口多采用空/BoolTrue 响应:`readMessageContents/reportSpam/setEmojiStickers/getInactiveChannels/getChannelRecommendations/checkSearchPostsFlood/setMainProfileTab` 等直接返回兼容值;`channels.getMessageAuthor` handler 存在但未实现。参考实现 的 `messages.readMessageContents` / `channels.readMessageContents` 会先查当前用户可见消息、过滤 mention/media_unread/reaction 内容状态,再向 not-me 推 `updateReadMessagesContents``updateChannelReadMessagesContents`;未找到 `channels.getMessageAuthor` 对应实现。telesrv 当前没有 media/reaction content-read 持久表,因此 private 与 channel 都只用现有 exact-id message store 校验可见消息,并把存在的 id 推给当前用户其它在线 session缺失 id 仍按参考项目返回当前 pts/BoolTrue`getMessageAuthor` 则按 Layer225/TDesktop monoforum 右键菜单的最小可用语义,只查可见 channel message 的 `SenderUserID` 并返回 user完整 monoforum 管理员权限留后续模型。`deleteParticipantHistory` 做管理员权限后按固定 page size 分批删除;`searchPosts` 按 参考实现 的 PublicPosts 语义落到本项目公开 username channel_messages 查询,只返回公开频道/超级群文本消息,满页用 `next_rate + offset_peer + offset_id` seek 翻页,付费 flood/限额继续由 `checkSearchPostsFlood` 免费 stub 承接;`getInactiveChannels` 按 TDesktop Premium 限额弹窗消费方式返回当前用户 active 频道/超级群,`dates[i]``chats[i]` 对齐并按最久未活跃排序;`getChannelRecommendations` 则从空响应提升为公开 broadcast channel 推荐,指定来源时排除 source无来源时排除当前账号已加入频道`getGroupsForDiscussion/setDiscussionGroup` 是讨论组链路。telesrv 借鉴其删除边界:按 sender 分页取一批 message id生成一条 channel delete update通过 `offset` 提示客户端续删,禁止一次性展开超大历史。
参考实现 对 TDesktop 长尾入口多采用空/BoolTrue 响应:`readMessageContents/reportSpam/setEmojiStickers/getInactiveChannels/getChannelRecommendations/checkSearchPostsFlood/setMainProfileTab` 等直接返回兼容值;`channels.getMessageAuthor` handler 存在但未实现。参考实现 的 `messages.readMessageContents` / `channels.readMessageContents` 会先查当前用户可见消息、过滤 mention/media_unread/reaction 内容状态,再向 not-me 推 `updateReadMessagesContents``updateChannelReadMessagesContents`;未找到 `channels.getMessageAuthor` 对应实现。telesrv 已为 private 接入 owner 侧 `media_unread/reaction_unread` 持久状态与 durable `read_message_contents`channel mention/media unread 则来自 `channel_unread_mentions` viewer-statechannel unread reaction 继续由 `channels.readMessageContents/readReactions` 清理`getMessageAuthor` 则按 Layer225/TDesktop monoforum 右键菜单的最小可用语义,只查可见 channel message 的 `SenderUserID` 并返回 user完整 monoforum 管理员权限留后续模型。`deleteParticipantHistory` 做管理员权限后按固定 page size 分批删除;`searchPosts` 按 参考实现 的 PublicPosts 语义落到本项目公开 username channel_messages 查询,只返回公开频道/超级群文本消息,满页用 `next_rate + offset_peer + offset_id` seek 翻页,付费 flood/限额继续由 `checkSearchPostsFlood` 免费 stub 承接;`getInactiveChannels` 按 TDesktop Premium 限额弹窗消费方式返回当前用户 active 频道/超级群,`dates[i]``chats[i]` 对齐并按最久未活跃排序;`getChannelRecommendations` 则从空响应提升为公开 broadcast channel 推荐,指定来源时排除 source无来源时排除当前账号已加入频道`getGroupsForDiscussion/setDiscussionGroup` 是讨论组链路。telesrv 借鉴其删除边界:按 sender 分页取一批 message id生成一条 channel delete update通过 `offset` 提示客户端续删,禁止一次性展开超大历史。
参考实现 的 `channels.getAdminLog` 当前返回空结果,但保留了 read model/query 模型:按 `channel_id`、action types、skip/limit 拉取 admin log event。telesrv 采用“有真实 event store、但只实现 TDesktop 首批可见 action”的路线避免管理入口只能打开空页。
@ -165,9 +165,9 @@ username 与管理项方面,参考实现 的 `channels.checkUsername` 只校
- `ChannelMember.AvailableMinPts`:当前成员可恢复 channel difference 的 pts 下界;新加入/导入/受邀/重新加入成员初始化为加入前 `channels.pts``updates.getChannelDifference(pts=0)` 也会先抬到该值,避免入群前消息类 durable event 泄漏。
- `ChannelMember.ReadInboxDate`:当前成员最后一次推进 `read_inbox_max_id` 的时间,用于 `messages.getMessageReadParticipants` 返回 `readParticipantDate.date`;不参与 channel pts。
- `ChannelMember.SlowmodeLastSendDate`:普通成员最近一次成功发言时间,用于服务端按 channel 维度返回 `SLOWMODE_WAIT_X`creator/admin 不受首批 slowmode 限制。
- `ChannelMessage``ChannelID``ID``RandomID``SenderUserID``From``SendAs``Date``EditDate``Post``Silent``NoForwards``Body``Entities``ReplyTo``Forward``Action``Pts``Deleted`
- `ChannelMessage``ChannelID``ID``RandomID``SenderUserID``From``SendAs``Date``EditDate``Post``Silent``NoForwards``Body``Entities``ReplyTo``Forward``Action``Pts``Deleted``Mentioned` / `MediaUnread` 是按当前 viewer 回填的瞬时字段,不是全局消息真值
- `ChannelDialog`:当前 user 对 channel 的会话摘要,保存 `FolderID``Pinned``PinnedOrder``TopMessageID``ReadInboxMaxID``ReadOutboxMaxID``UnreadCount``UnreadMentions``UnreadMark``ViewForumAsMessages``NotifySettings`。channel message id 是 channel-scoped跨频道 dialog 排序不能只看 `top_message_id`;统一按 `pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC` 排序,并用 `offset_date + offset_id + offset_peer(channel_id)` 做 seek cursor。folder include/exclude/read/archive/type 条件必须在 SQL `LIMIT` 前下推,避免 TDesktop dialogs 翻页重复顶部频道或自定义分组漏掉旧频道。
- `ChannelUnreadMention`:按 `(user_id, channel_id, message_id)` 保存未读提及,`top_message_id` 支持 thread/topic 级清除;它是 owner 视角状态,不写入 `channel_update_events``messages.readMentions` 返回当前 channel pts 供客户端清本地 badge。
- `ChannelUnreadMention`:按 `(user_id, channel_id, message_id)` 保存未读提及,`top_message_id` 支持 thread/topic 级清除`media_unread` 表示该未读提及同时对应媒体内容未读;它是 owner 视角状态,不写入 `channel_update_events``messages.readMentions` 返回当前 channel pts 供客户端清本地 badge。
- `ChannelUpdateEvent``ChannelID``Pts``PtsCount``Type``Date``MessageID``MessageIDs``SenderUserID``UserIDs``Payload`
- `ChannelAdminLogEvent``ChannelID``ID``UserID``Date``Type`、前后字符串/布尔/整数、前后 participant、相关 message、`Query`;用于 `channels.getAdminLog`,不参与 channel pts。
@ -192,7 +192,7 @@ username 与管理项方面,参考实现 的 `channels.checkUsername` 只校
| `channel_message_reactions` | hash(`channel_id`) | `(channel_id,message_id,reacted_user_id,reaction)` 当前 reaction 状态,支持 `sendReaction/getMessagesReactions/getMessageReactionsList` |
| `private_message_reactions` | message id fk | `(private_message_id,user_id,reaction)` 当前私聊 reaction 状态,双端 message box 共享聚合,支持 private `sendReaction/getMessagesReactions/getMessageReactionsList` |
| `user_saved_reaction_tags` | user_id prefix | 账号级 saved-message reaction tag 标题;支持 `messages.getSavedReactionTags/updateSavedReactionTag`,不承载 message assignment/count |
| `channel_unread_mentions` | hash(`user_id`) | owner 视角未读提及索引,支持 `messages.getUnreadMentions/readMentions`,不复制 channel message body |
| `channel_unread_mentions` | hash(`user_id`) | owner 视角未读提及/媒体未读索引,支持 `messages.getUnreadMentions/readMentions`,不复制 channel message body |
| `channel_update_events` | hash(`channel_id`) | channel pts durable log`updates.getChannelDifference` |
| `channel_dialogs` | hash(`user_id`) | 当前账号对 channel 的 dialog/read/folder/pin/mute 摘要 |
| `dialog_drafts` | hash(`user_id`) | user/channel peer 云草稿;支持 forum `top_msg_id`,不复制 channel message |
@ -213,7 +213,7 @@ username 与管理项方面,参考实现 的 `channels.checkUsername` 只校
- `channel_messages(discussion_channel_id, discussion_message_id) WHERE discussion_channel_id <> 0 AND discussion_message_id <> 0 AND NOT deleted` 支持 broadcast post 到 discussion root 的反查与排查。
- `channel_message_viewers(channel_id, message_id, viewer_user_id)` 主键去重;`messages.getMessagesViews` 先按最多 100 个 id 过滤可见 `channel_messages`,只对首次插入的 viewer 更新 `channel_messages.views_count`,不按 viewer 做 count 聚合扫描。
- `channel_message_reactions(channel_id, message_id, reaction_date DESC, reacted_user_id DESC, reaction_value ASC)` 支持最近 reaction 回填和列表 seek`(channel_id,message_id,reaction_type,reaction_value,reaction_date DESC,reacted_user_id DESC)` 支持按单个 emoji 过滤,不使用 SQL OFFSET。
- `channel_unread_mentions(user_id, channel_id, top_message_id, message_id DESC)` 支持当前账号 unread mentions seek发送时只为解析出的 active/可见/未读成员插入,单条消息最多 100 个候选,清除时单批最多 1000 条。
- `channel_unread_mentions(user_id, channel_id, top_message_id, message_id DESC)` 支持当前账号 unread mentions seek发送/编辑时只为解析出的 active/可见/未读成员插入,单条消息最多 100 个候选,清除时单批最多 1000 条`media_unread` 随同一行保存,供 history/getMessages/getChannelDifference/online update 按 viewer 回填 `message.media_unread`
- `channel_update_events(channel_id, pts)` 主键;`(channel_id, pts)` 升序扫描差量。
- `channel_dialogs(user_id, folder_id, pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC)` 支持 dialogs seek`default_send_as_peer_type/default_send_as_peer_id` 只保存当前 owner 的默认发送身份,不参与列表排序。
- `dialog_drafts(user_id, date DESC, peer_type, peer_id, top_message_id)` 支持 `messages.getAllDrafts/clearAllDrafts` 有界扫描draft 内容用 domain JSON 保存,业务层不持有 `tg.*`
@ -237,6 +237,16 @@ Redis miss 恢复来源:
分配必须使用 Redis Lua 的初始化+递增原子脚本;批量 delete/clear 这类 `pts_count>1` 的操作必须一次性分配连续 rangePG fallback 也要实现 `NextChannelPtsN(current+count)`,不能用多次读取 `MAX(pts)+1` 模拟。事务失败但 pts 已分配时必须写 `noop` channel update 占位,避免 TDesktop channel PtsWaiter 永久 gap。
## Viewer State / Difference Nudge
`tg.Message.mentioned``media_unread` 对 channel/supergroup 来说是 viewer-specific 状态,来源只允许是 `channel_unread_mentions` 等 owner 视角 unread 表,不写入 `channel_messages` 全局行:
1. 发送或编辑 channel message 时RPC 层解析 mention-name entity / `@username`store 只为 active、可见、未读且不是 sender 自己的成员写 unread mention。带媒体的 mention 同行保存 `media_unread=true`
2. `messages.getHistory``messages.getMessages``updates.getChannelDifference` 与在线 `updateNewChannelMessage/updateEditChannelMessage` 都按当前 viewer 重取 unread mention 状态,再设置 TL `mentioned/media_unread`。未被 mention 的 viewer 看到同一条消息时两个 flag 必须为 false。
3. `messages.readMentions` 清理当前 owner 的 unread mention 后,后续 history/difference 不再带 `mentioned/media_unread`。编辑移除 mention 会删除旧 unread mention新增 mention 会补写新 owner 的 unread mention供在线和离线差量恢复。
4. 账号级 `updates.getDifference` 不承载 channel message 本体。若请求 date 之后当前用户 active joined channel 有 `channel_update_events`RPC 层追加计算型 `updateChannelTooLong(channel_id, pts)` nudge提示 TDesktop 随后调用 `updates.getChannelDifference`;该 nudge 不写 `user_update_events`,不推进账号 pts也不影响 account difference 连续性。
5. create/invite/join/leave/import/hide request 等 membership/state 操作即使没有 service message pts也必须在 RPC response 和在线推送里带 `updateChannel`。megagroup 有可见 service message 时返回/推送 `updateNewChannelMessage` 占 channel pts同时追加 `updateChannel` 刷新 participant count/self statebroadcast invite/join/leave 不占 channel pts但仍带 viewer-specific chats 与 `updateChannel`
## Create Flow
`messages.createChat`

View file

@ -126,7 +126,7 @@ status 取值done(真实实现) / stub(兼容响应) / todo(已发现未实
| method | status | behavior | note |
|---|---|---|---|
| updates.getState | done | real | auth_key+user 维度持久化 update_states账号级当前 pts 报告**最大连续已提交 pts**MaxContiguousPts非 allocator 最大已分配值),避免同设备换号/多账号串差分,也避免越过在途空洞 |
| updates.getDifference | done | real | 按 user_id 从 user_update_events 拉取 pts 后增量;只返回从客户端 pts 起**连续**的事件(遇在途空洞即截断),超 100 条置 differenceSlice支持 new_message、read_history_inbox/read_history_outbox私聊与 channel peerchannel read 映射 updateReadChannelInbox、edit_message、message_reactions输出带最新 `message.reactions` 的 affected message + `updateMessageReactions`,并按 viewer 重取最新聚合,避免 TDesktop 离线恢复时本地 message cache 不刷新、delete_messages、contacts_reset、dialog pinned/order/manual unread、peer_settings、dialog filters/folder peers 与 noop gap消息事件携带 fwd/reply 所需 users/chatspayload 来自 durable log。**账号级绝不返回 `differenceTooLong`**:已核对 TDesktop 基线 `api_updates.cpp:516`——对账号级 differenceTooLong 只打一行日志、不读 pts 且漏 `setRequesting(false)`,会永久锁死 update 引擎(`:689` 早退,重连/新 session 不可恢复);落后客户端改用 `differenceSlice` 续传。因此 `user_update_events` **永久保留、不做 retention 裁剪**(对齐 参考实现),详见 docs/performance-audit.md 附录 C |
| updates.getDifference | done | real | 按 user_id 从 user_update_events 拉取 pts 后增量;只返回从客户端 pts 起**连续**的事件(遇在途空洞即截断),超 100 条置 differenceSlice支持 new_message、read_history_inbox/read_history_outbox私聊与 channel peerchannel read 映射 updateReadChannelInbox、edit_message、message_reactions输出带最新 `message.reactions` 的 affected message + `updateMessageReactions`,并按 viewer 重取最新聚合,避免 TDesktop 离线恢复时本地 message cache 不刷新)、read_message_contents、delete_messages、contacts_reset、dialog pinned/order/manual unread、peer_settings、dialog filters/folder peers 与 noop gap消息事件携带 fwd/reply 所需 users/chatspayload 来自 durable log。若请求 date 后存在当前账号 active channel 的 channel durable events会追加计算型 `updateChannelTooLong(channel_id,pts)` nudge提示 TDesktop 再走 `updates.getChannelDifference`;该 nudge 不写 `user_update_events`,不消耗账号 pts。**账号级绝不返回 `differenceTooLong`**:已核对 TDesktop 基线 `api_updates.cpp:516`——对账号级 differenceTooLong 只打一行日志、不读 pts 且漏 `setRequesting(false)`,会永久锁死 update 引擎(`:689` 早退,重连/新 session 不可恢复);落后客户端改用 `differenceSlice` 续传。因此 `user_update_events` **永久保留、不做 retention 裁剪**(对齐 参考实现),详见 docs/performance-audit.md 附录 C |
| updates.getChannelDifference | done | real-channel | 超级群/频道使用 channel 维度 durable log`channel_update_events(channel_id, pts, pts_count, ...)`;按 channel pts 返回 `channelDifferenceEmpty/channelDifference/channelDifferenceTooLong`limit cap=100`pts < 0``pts > current_channel_pts` 返回 `PERSISTENT_TIMESTAMP_INVALID`;当前 member 的 `available_min_pts` 会抬高请求 pts避免新加入/重新加入成员拉到入群前消息类 durable 事件;公共 username 频道允许非成员以只读预览身份拉可见差分并返回 synthetic read dialog私有频道和禁看用户仍返回权限错误`current_channel_pts-pts > cap` 时返回带当前 dialog pts 和最新有界消息快照的 `channelDifferenceTooLong`,避免大频道旧 pts 客户端循环拉大量页;普通 difference 优先使用事件 payload 中的 message 快照,连续编辑/删除后不会被当前消息状态污染;`updateChannelParticipant` 不进入 channel pts log成员权限/封禁状态靠在线 `updateChannelParticipant/updateChannel``channels.getFullChannel/getParticipants/getParticipant` 刷新;本页消息的 sender/send_as/fwd_from/reply_to/action peers 会随 users/chats 返回,禁止复用 user_update_events |
### 兼容硬约束pts / pts_count跨所有产生 update 的 RPC
@ -162,9 +162,9 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱
| messages.getMessageEditData | done | real-text-only | 参考实现/参考实现 的编辑前校验语义:按 peer/msg_id 校验消息存在与作者/管理员 edit_messages 权限;当前只支持文本消息编辑,没有媒体 caption 状态,因此返回 `messages.messageEditData{caption=false}` |
| messages.getWebPagePreview | stub | empty-preview | TDesktop 输入框链接预览入口;参考实现 空预览与 参考实现 空文本校验,`message` trim 后为空返回 `MESSAGE_EMPTY`entities/text 有界,返回 `messages.webPagePreview{media=messageMediaEmpty}`,不抓取外网、不落缓存 |
| messages.uploadMedia / messages.sendMedia / messages.sendMultiMedia | done | photo/document/sticker | 接入 files/media 存储documents/photos/file_blobs + 本地 blob backend与消息 media 快照列;`resolveInputMedia` 解析 `inputMediaUploadedPhoto/Document`(组装上传分片→建 Photo/Document`inputMediaPhoto/Document`(按服务端 document id 引用已存在资源,含贴纸)→ `domain.MessageMedia`,经 sendOutgoing 走与文本相同的 pts/box/outbox/在线推送/离线 difference私聊与 channel 均支持;`sendMedia(inputMediaEmpty/WebPage)` 仍降级为纯文本 `sendMessage``uploadMedia` 返回可复用 `messageMedia`sendMultiMedia 各条作为独立消息发送grouped_id 相册聚合留 todogeo/contact/poll/todo/dice/story 等仍返回 `MEDIA_INVALID`album cap=10caption/entities/random_id/peer/access_hash 均校验 |
| messages.readMessageContents | done | real-partial | TDesktop 普通消息内容已读入口;校验 id cap=100、message_id 范围与当前账号 exact 可见私聊消息,对存在消息向当前账号其它在线 session 推 `updateReadMessagesContents`;当前无 media/reaction content-read 持久状态,返回当前账号 affectedMessages 且不生成新 pts |
| messages.readMessageContents | done | real-content-read | TDesktop 普通消息内容已读入口;校验 id cap=100、message_id 范围与当前账号 exact 可见私聊消息;私聊 incoming media 写入 recipient box 时置 `media_unread`,对端 reaction 写入消息作者 box 时置 `reaction_unread` 并重算 dialog unread reaction 计数。事务内只清理实际 unread 的 message boxes有变化才分配 user pts、写 durable `updateReadMessagesContents` + dispatch_outbox其它在线 session 与离线 `updates.getDifference` 可恢复;重复调用/不可见 id/已读 id 返回当前 affectedMessages 且 `pts_count=0` |
| messages.getMessagesViews | done | real-channel-views | TDesktop 频道浏览计数入口channel/supergroup peer 校验 access_hash 与 id cap=100`increment=true` 时按 `(channel_id,message_id,viewer_user_id)` 去重后递增 `channel_messages.views_count`,按请求顺序返回显式 `views` 与 replies/comment context不存在、删除或本地清历史前不可见的 id 返回空 `messageViews`forwards 计数与 forwarded-channel source views 透传仍待媒体/转发统计模型补齐 |
| messages.getUnreadMentions | done | real-channel-mentions | Channel/supergroup peer 维护 `channel_unread_mentions(user_id,channel_id,message_id)` 独立索引sendMessage 解析 mention-name entity 与 `@username`,写入 active 且可见成员;查询按 user+channel+top_msg_id+message_id seeklimit cap=100返回 channel context |
| messages.getUnreadMentions | done | real-channel-mentions | Channel/supergroup peer 维护 `channel_unread_mentions(user_id,channel_id,message_id,media_unread)` 独立索引sendMessage 解析 mention-name entity 与 `@username`,写入 active 且可见成员;history/getMessages/getChannelDifference/online update 按 viewer 回填 `message.mentioned/media_unread`readMentions 后同一 viewer 不再带 flag查询按 user+channel+top_msg_id+message_id seeklimit cap=100返回 channel context |
| messages.readMentions | done | real-channel-mentions | Channel/supergroup peer 按 top_msg_id 有界清除 unread mention单次最多 1000 条,重算 `channel_dialogs.unread_mentions_count`;返回 current channel pts/offset供 TDesktop channel PtsWaiter 消费 |
| messages.reportSpam / messages.report | stub | reported-bounded | TDesktop peer bar/消息举报入口;校验 peer/access_hash/message id/option/comment 上限,不落 report 表;`report` 空 option 返回举报原因,`other` 返回 addComment其余合法 option 返回 reported非法 option 返回 `OPTION_INVALID` |
| messages.reportReaction / messages.reportMessagesDelivery / messages.reportReadMetrics / messages.reportMusicListen / messages.reportSponsoredMessage | stub | telemetry-noop | TDesktop 反应举报、Gateway 送达、阅读指标、音乐播放、广告举报入口;校验 peer/access_hash/id vector/metrics/document/duration/random_id 上限后返回 BoolTrue 或 sponsored reported不落 telemetry/report 表 |
@ -288,7 +288,7 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱
| channels.updateColor | done | real-appearance | 持久化 `channels.color/profile_color` 与 background emoji id保留 color flag 显式 0校验 access_hash + change_info 后返回/推送带 `Channel.color/profile_color``updateChannel`boost level 暂不强制,避免本地测试频道外观入口失效 |
| channels.updateEmojiStatus | done | real-minimal | 支持 `emojiStatusEmpty` 清除与普通 `emojiStatus(document_id,until)` 持久化并输出到 `Channel.emoji_status`collectible emoji status 缺少 gift/read model 时返回 `EMOJI_STATUS_INVALID`,不伪造 collectible 元数据 |
| channels.exportMessageLink | done | real-message-link | 校验 channel/access_hash、msg_id 范围和当前成员可见的单份 channel message公开 channel 返回 `t.me/{username}/{msg_id}`,私有 channel 返回 `t.me/c/{channel_id}/{msg_id}``thread=true` 且消息有 reply root 时追加 `?thread={root_id}``grouped/html` 与 public discussion `comment=` 精细链接后续补 |
| channels.readMessageContents | done | real-partial | 校验 channel/access_hash、id vector cap=100 与可见 exact message对存在消息向当前用户其它在线 session 推 `updateChannelReadMessagesContents`,当前无 media/reaction content-read 持久状态,不生成 pts |
| channels.readMessageContents | done | real-channel-content-read | 校验 channel/access_hash、id vector cap=100 与可见 exact message按当前作者视角清理 visible messages 的 unread reaction、重算 `channel_dialogs.unread_reactions_count`,并向当前账号其它 session 推 `updateChannelReadMessagesContents` / `updateMessageReactions` 刷新 TDesktop 角标;不生成 channel pts |
| channels.reportSpam | stub | ok | 校验 channel、participant 与 id cap=100 后返回 BoolTrue风控/举报队列后续补 |
| channels.getLeftChannels | done | real-left-export | TDesktop takeout/export 路径;按当前 user 的 left channel/supergroup membership 返回有界 pageSize=100offset<=10000带 full count 与 left channel flag最终非空页返回 `messages.chats`,越界空页返回空 `messages.chatsSlice` 让导出流程结束 |
| channels.getInactiveChannels | done | real-least-active | Premium limits 路径;按当前用户 active 频道/超级群的可见 top message date 旧到新返回dates 与 chats 对齐limit cap=100不实现 Premium 限额策略 |
@ -359,7 +359,9 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱
| contacts.search | done | real | TDesktop 搜索框 peer 分支strip `@`、空/过短查询报 SEARCH_QUERY_EMPTY/QUERY_TOO_SHORTlimit cap=50联系人 user 进 MyResults非联系人 user 进 Results公开 username channel/supergroup 同步返回 PeerChannel + Chats当前已加入的放 MyResults其它公开命中放 Results 并以 left chat 标记只读预览,避免 TDesktop 误显示已加入;用户搜索走手机号前缀/username/姓名/owner 保存姓名索引,公开频道搜索走 username/title trgm 索引 |
| contacts.resolveUsername | done | real | 按大小写不敏感 username 解析 user 或公开 channel/supergroup peerchannel 返回 PeerChannel + Chats不存在返回 USERNAME_NOT_OCCUPIED非法格式返回 USERNAME_INVALID |
| contacts.resolvePhone | done | real-partial | 按手机号解析 user peer当前阶段未接完整 privacy默认已知手机号可解析未命中返回 PHONE_NOT_OCCUPIED |
| contacts.getBlocked | stub | empty | Settings 隐私/安全预取;第一阶段无 blocklistlimit cap=50 |
| contacts.block | done | real-blocklist | 写入当前 owner blocklist幂等同步刷新 peer settingsstory-only block flag 当前按主 blocklist 处理,完整 stories privacy 留后续 |
| contacts.unblock | done | real-blocklist | 从当前 owner blocklist 删除 peer幂等同步刷新 peer settings |
| contacts.getBlocked | done | real-blocklist | Settings 隐私/安全预取;`contacts.block/unblock/getBlocked` 维护 `owner_user_id + blocked_user_id` 唯一 blocklistlimit cap=100按 date/user_id 返回 `peerBlocked` + users`contacts.getPeerSettings` 按当前 owner block 状态返回 block/unblock action。当前不扩展完整 Telegram privacy key 体系blocklist 是本阶段 send/edit/delete 的唯一 privacy gate |
| contacts.getTopPeers | stub | disabled | 第一阶段不维护 top peers 统计 |
| contacts.getSponsoredPeers | stub | empty | 第一阶段不做 sponsored peersTDesktop 搜索框分支返回 sponsoredPeersEmpty |
| users.getUsers | done | real | InputUserSelf 与已知 InputUser 返回用户(含 777000 官方账号);未登录则跳过(空列表) |

View file

@ -27,8 +27,9 @@ Date: 2026-05-31
| table | partition key | purpose |
|---|---|---|
| `private_messages` | `sender_user_id` HASH | 共享私聊消息主体;`sender_user_id + random_id` 保证发送幂等 |
| `message_boxes` | `owner_user_id` HASH | owner 视角消息盒TDesktop 看到的 message id 即 `box_id` |
| `message_boxes` | `owner_user_id` HASH | owner 视角消息盒TDesktop 看到的 message id 即 `box_id`,并保存当前 owner 的 `media_unread/reaction_unread` 内容已读状态 |
| `dialogs` | `user_id` HASH | 会话摘要;`folder_id=0/1` 表示主列表/归档置顶、manual unread、action bar 隐藏均是 owner 视角 |
| `contact_blocks` | `owner_user_id` HASH | 当前 owner 的 blocklist`owner_user_id + blocked_user_id` 唯一,作为本阶段私聊 privacy gate |
| `dialog_filters` / `dialog_filter_settings` | `user_id` HASH | TDesktop 自定义 dialog filter、filter 顺序与 folder tags 开关;不把自定义 filter 伪装成 dialogs.folder_id |
| `user_update_events` | `user_id` HASH | 账号级 pts durable log承载新消息、已读 inbox/outbox、文本编辑、删除消息也承载 contacts reset、dialog pinned/order/manual unread、peer settings、dialog filters 与 folder peers 等 owner 视角状态事件 |
| `dispatch_outbox` | `target_user_id` HASH | transactional outbox事务后批量推送在线 session投递成功即删除仅保留未完成任务 |
@ -48,7 +49,7 @@ Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box
1. 写 `private_messages`,遇到同 `sender_user_id + random_id` 直接返回原消息盒。
2. Redis 分配 sender/recipient 的 `box_id``pts`
3. 写 sender/recipient `message_boxes`,并保存 `silent/noforwards/reply_to/fwd_from` 元数据reply 会把当前 owner 的 `reply_to_msg_id` 翻译成对端 owner 视角的 box_id。
3. 写 sender/recipient `message_boxes`,并保存 `silent/noforwards/reply_to/fwd_from` 元数据reply 会把当前 owner 的 `reply_to_msg_id` 翻译成对端 owner 视角的 box_id。incoming media 在 recipient box 上置 `media_unread=true`sender 自己始终为 false。
4. upsert 双方 `dialogs`
5. 写双方 `user_update_events(new_message)`
6. 写双方 `dispatch_outbox`sender 侧带 `exclude_session_id`
@ -56,6 +57,8 @@ Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box
若事务失败但 Redis 已分配 ptsstore 会尽力写 `noop` 事件占位,避免 pts 回退PG 不可用时该补偿也可能失败,后续需要告警指标覆盖。
如果 recipient 已 block sender`SendPrivateText` 仍写 sender outbox/dialog/update保证当前用户能看到自己发出的消息但不创建 recipient message box、不推进 recipient pts、不写 recipient dispatch_outbox也不会进入 recipient 离线 `updates.getDifference`。该规则同样适用于 `messages.sendMedia/sendMultiMedia` 和私聊转发,因为它们共用 `sendOutgoing`/`SendPrivateText`
## Forward / Reply Flow
`messages.forwardMessages` 当前覆盖私聊文本转发,参考实现 的业务语义但保持 telesrv 的 owner 视角模型:
@ -80,6 +83,16 @@ Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box
`messages.getOutboxReadDate` 复用 sender 侧 durable `read_history_outbox` 事件:先校验当前 owner 的 `msg_id` 是该 peer 下可见 outgoing message再取最早一条 `max_id >= msg_id` 的 outbox read event 日期返回 `outboxReadDate`;未被读到返回 `MESSAGE_NOT_READ_YET`。PG 上有 `(user_id, peer_type, peer_id, max_id, date) WHERE event_type='read_history_outbox'` partial index避免 TDesktop 已读详情查询扫全量 update log。
## Content Read Flow
`messages.readMessageContents` 处理 TDesktop 打开媒体/反应等“内容已读”入口,不等同于 history read 水位:
1. 私聊 incoming media 创建 recipient message box 时置 `media_unread=true`sender 自己为 false。
2. 私聊 reaction 写入时,若反应者不是原消息作者,会把原作者 owner 视角的 message box 标记 `reaction_unread=true`,并重算该 dialog 的 `unread_reactions_count`
3. `readMessageContents` 在事务内锁定当前 owner 的 exact message boxes只清理 `media_unread OR reaction_unread` 的行;不可见 id、已删除 id、已经 read 的 id 都不生成新 pts。
4. 实际清理时为当前 owner 分配连续 user pts`user_update_events(read_message_contents)``dispatch_outbox`TL 转换为 `updateReadMessagesContents{messages,pts,pts_count}`。重复调用返回当前 affectedMessages`pts_count=0`
5. 该事件排除当前 auth_key/session其它在线 session 走 reliable outbox离线设备通过 `updates.getDifference` 恢复。
## Edit Flow
`messages.editMessage` 当前只支持私聊文本编辑:
@ -92,12 +105,16 @@ Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box
如果文本和 entities 完全未变化,返回 `MESSAGE_NOT_MODIFIED`;非作者编辑返回 `MESSAGE_AUTHOR_REQUIRED`
若 peer 已 block 当前用户,私聊 edit 会返回 `EDIT_MESSAGES_FORBIDDEN`,避免修改对方侧已存在的 message box。该 gate 只来自 `contact_blocks`;完整 Telegram privacy keys 暂不扩展。
## Delete Flow
`messages.deleteMessages` / `messages.deleteHistory` 以 owner 视角软删除 `message_boxes`,不会删除共享 `private_messages` 主体。默认 deleteHistory 清空后如果该 peer 已无可见消息,则删除当前 owner 的 dialog后续任意新消息会通过正常 send/upsert 路径重建 dialog。`just_clear=true` 对齐 参考实现 语义:清空历史但保留一个空 dialog当前阶段不生成 `messageActionHistoryClear` 服务消息)。
`revoke=true` 时按 `(message_sender_id, private_message_id)` 找到同一私聊消息在其它 owner 下的 message_box 并软删除。每个受影响 owner 都生成自己的 `updateDeleteMessages``message_ids` 是该 owner 视角 box_id`pts_count=len(message_ids)`,并写入 `user_update_events + dispatch_outbox`。如果删除后仍有可见消息dialog 的 top/unread 会按剩余消息重算;否则删除 dialog 或在 `just_clear` 下保留空 dialog。
`revoke=true` 会影响已 block 当前用户的一方RPC 层返回 `DELETE_MESSAGES_FORBIDDEN``revoke=false` 或本地清理仍只影响当前 owner可继续执行。
全清也必须让所有被删的 owner 视角 message_id 最终进入 update/difference但不能合成一个超大 update。`messages.deleteHistory` 单次最多删除 `MaxDeleteHistoryBatch=1000` 条,按 box_id 倒序批量软删并返回 `affectedHistory.offset=1` 表示客户端应继续发起下一批;每一批各自产生一条有界 `updateDeleteMessages``messages.deleteMessages` 单次 id 数限制为 `MaxDeleteMessageIDs=1000`,服务端还会丢弃 `<=0` 或超过 TL/PG int4 可表达范围的 id。
## Query Path
@ -106,7 +123,7 @@ Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box
- `messages.search` / `messages.searchGlobal` 当前只覆盖当前 owner 私聊文本搜索;查询仍限定在 `owner_user_id` HASH 分区内,文本条件由 `pg_trgm` GIN 索引兜底。参考实现 的经验,未接外部全文搜索前禁止无索引大表模糊扫;后续群组/频道/全局多 peer 搜索应接专用搜索索引或 FTS。
- `messages.getDialogs``user_id` 分区定位当前账号,再按 `top_message_date/top_message_id/peer_id` 做 seek paginationfolder_id=0/1 直接走 `dialogs.folder_id`folder_id>=2 先取当前账号 `dialog_filters` 后按 include/exclude/contact 规则过滤;`hash``count` 基于当前筛选后的完整会话集计算。
- `messages.getDialogFilters` 返回 `dialogFilterDefault` + 当前账号持久化 filters`messages.updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags``folders.editPeerFolders` 都是 owner 视角写入,归档只允许 folder_id 0/1自定义 filters 从 ID 2 开始。
- `updates.getDifference` 只按 `user_id + pts` 顺序扫描 `user_update_events`,多设备各自的 `(auth_key_id,user_id)` state 只记录消费位置,不参与账号事件归属;离线设备通过同一条 durable log 恢复新消息、已读 inbox/outbox、文本编辑、删除消息、联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filters/order/reload 与 folder peers 变化置顶顺序、peer settings flags、filter payload 与 folder peers 会随事件负载持久化。
- `updates.getDifference` 只按 `user_id + pts` 顺序扫描 `user_update_events`,多设备各自的 `(auth_key_id,user_id)` state 只记录消费位置,不参与账号事件归属;离线设备通过同一条 durable log 恢复新消息、已读 inbox/outbox、内容已读、文本编辑、删除消息、联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filters/order/reload 与 folder peers 变化置顶顺序、peer settings flags、filter payload 与 folder peers 会随事件负载持久化。
## 参考实现 Comparison
@ -249,4 +266,4 @@ go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 3
- `private_messages` / `message_boxes``media` JSONB 快照列:发送在事务内随 body/entities 一起写双端盒子,历史 `getHistory`/`getMessages`/dialog preview 读取时随消息一并解码,无需 join `documents`/`photos`。转发复制源消息 media同一文档引用文本编辑保留 media。
- `tgMessage` 在 media 非空时 `SetMedia(MessageMediaPhoto/Document)`,客户端经 `upload.getFile` 从 blob backend 下载。Document id 在 domain/store 中保持 telesrv-owned 正数;外部 seed source id 在导入阶段归一,`InputDocument` / `inputDocumentFileLocation` 入站直接按服务端 id 解析,避免把第三方导出 id 当成本服资源身份。
- 仅媒体消息(无 caption放宽 `private_messages` body 非空 CHECK 为 `body<>'' OR media<>'{}'`
- 范围外grouped_id 相册聚合sendMultiMedia 当前各条独立成消息、geo/contact/poll/todo/dice/story media 仍 `MEDIA_INVALID`
- 范围外grouped_id 相册聚合sendMultiMedia 当前各条独立成消息、geo/contact/poll/todo/dice/story media 仍 `MEDIA_INVALID`

View file

@ -58,13 +58,14 @@ DDL 见 [`deploy/migrations/0001_init.up.sql`](../deploy/migrations/0001_init.up
- **`app_configs`** —— `help.getAppConfig` 的 data-backed JSON config包含 TDesktop read mark、quote reply 与 native anti-spam 管理入口所需参数。
- **`countries` / `country_codes`** —— `help.getCountriesList` 的登录页国家区号目录。
- **`update_states`** —— `auth_key_id + user_id` 维度的设备状态快照。账号级 pts 以 `user_update_events` 为权威,设备退出/换号只清当前 auth_key 状态,不删除账号事件。
- **`user_update_events`** —— 按 `user_id` HASH 分区的账号级增量事件队列。承载 `new_message``read_history_inbox/read_history_outbox`(私聊与 channel peer`edit_message``delete_messages`、`contacts_reset`、dialog 置顶/顺序/manual unread/peer settings、dialog filter/order/reload、folder peers、channel 本地清空后的 `channel_available_messages`、当前账号 forum 展示模式 `channel_view_forum_as_messages` 与 allocator gap 的 `noop`,供 `updates.getDifference` 和 outbox worker 补偿错过的推送;设置类事件通过 `UpdateEventStore.AppendWithDispatch``dispatch_outbox` 同事务写入,并持久化 `event_peers` / `peer_settings` / `message_ids` / `dialog_filter` / `filter_order` / `folder_peers` 负载,避免状态只存在在线 push 中。
- **`user_update_events`** —— 按 `user_id` HASH 分区的账号级增量事件队列。承载 `new_message``read_history_inbox/read_history_outbox`(私聊与 channel peer`edit_message``read_message_contents`、`delete_messages`、`contacts_reset`、dialog 置顶/顺序/manual unread/peer settings、dialog filter/order/reload、folder peers、channel 本地清空后的 `channel_available_messages`、当前账号 forum 展示模式 `channel_view_forum_as_messages` 与 allocator gap 的 `noop`,供 `updates.getDifference` 和 outbox worker 补偿错过的推送;设置类事件通过 `UpdateEventStore.AppendWithDispatch``dispatch_outbox` 同事务写入,并持久化 `event_peers` / `peer_settings` / `message_ids` / `dialog_filter` / `filter_order` / `folder_peers` 负载,避免状态只存在在线 push 中。account difference 中的 `updateChannelTooLong` channel nudge 是按 channel events + request date 计算出来的提示,不写入本表、不消耗账号 pts。
- **`contacts`** —— 当前账号通讯录关系与 owner 视角联系人资料。`contact_phone/contact_first_name/contact_last_name/note/note_entities` 均只属于 `(user_id, contact_user_id)`,同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;`mutual` 由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。
- **`contact_blocks`** —— 当前账号 blocklist`owner_user_id` HASH 分区;`(owner_user_id, blocked_user_id)` 唯一,按 `date DESC, blocked_user_id DESC` 返回 `contacts.getBlocked`。当前 full privacy keys 未接入,私聊 send/edit/delete 的拒绝来源仅为这张表。
- **`private_messages`** —— 共享私聊消息主体,按 `sender_user_id` HASH 分区;`sender_user_id + random_id` 唯一保证 `messages.sendMessage/forwardMessages` 幂等;文本编辑更新共享 body/entities/edit_datesilent/noforwards/reply_to/fwd_from 元数据随消息持久化。
- **`message_boxes`** —— owner 视角消息盒,按 `owner_user_id` HASH 分区;每个账号看到自己的 `box_id`、peer、outgoing、pts、edit_date 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box`revoke` 通过 `(message_sender_id, private_message_id)` 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。该反向定位与分区键不一致,规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。
- **`message_boxes`** —— owner 视角消息盒,按 `owner_user_id` HASH 分区;每个账号看到自己的 `box_id`、peer、outgoing、pts、edit_date`media_unread/reaction_unread` 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box`revoke` 通过 `(message_sender_id, private_message_id)` 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。`messages.readMessageContents` 只锁定当前 owner exact ids 且仅清理 unread 状态为 true 的行,有变化才写 `read_message_contents` durable event。该反向定位与分区键不一致,规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。
- **`dialogs`** —— 当前账号会话摘要,按 `user_id` HASH 分区;只允许 `user` peer支持 top message、置顶过滤、folder_id=0/1 主列表/归档与 offset 分页,并保存当前 owner 的 `pinned_order`、manual `unread_mark``hidden_peer_settings_bar`。列表查询 join `contacts` 时优先返回当前 owner 保存的联系人姓名/电话,避免不同账号看同一 peer 串备注;相关状态变化会写入账号级 durable update log离线设备可通过 `updates.getDifference` 恢复。
- **`channels` / `channel_messages` / `channel_message_viewers` / `channel_update_events`** —— 超级群/频道单份消息模型,按 `channel_id` HASH 分区;`channels.pts` 是 channel-scoped durable log 水位,`channels.forum/forum_tabs` 持久化 megagroup topics 开关与 TDesktop tabs/list 布局,`channels.participants_hidden` 持久化隐藏成员设置并由 `ChannelFull.participants_hidden` 恢复 TDesktop UI`channels.antispam` 持久化 native anti-spam 开关并由 `ChannelFull.antispam` 恢复管理入口,`channels.color_set/color/color_background_emoji_id/profile_color_set/profile_color/profile_color_background_emoji_id/emoji_status_document_id/emoji_status_until` 持久化频道外观并回填 `Channel.color/profile_color/emoji_status``channels.linked_chat_id` 维护 broadcast 与 discussion megagroup 的双向链接并走 `channels_linked_chat_idx` 反查,`channel_update_events(channel_id, pts)` 存 new/edit/delete/pin/noop 的恢复负载,成员权限/封禁变化不进入该 log`channel_messages(channel_id, id)` 走 seek pagination 并保存 `views_count` 聚合列,`channel_message_viewers(channel_id,message_id,viewer_user_id)` 用主键完成 views 去重递增,`reply_to_msg_id/reply_to_top_id` 支撑 thread/comment 分页,`discussion_channel_id/discussion_message_id` 把 broadcast post 映射到 linked megagroup root禁止按成员写扩散。
- **`channel_members` / `channel_dialogs` / `channel_unread_mentions`** —— 成员权限、读水位、owner 视角 channel dialog 与未读提及索引,分别按 `channel_id` / `user_id` / `user_id` HASH 分区;`available_min_id` 限制成员可见历史消息,`available_min_pts` 限制 `updates.getChannelDifference` 起点,避免新成员或重新加入成员恢复到入群前的消息类 durable 事件。`channel_dialogs.unread_count` 是小超级群普通未读缓存字段,不是 broadcast/大超级群真值;大频道读取 dialog/full channel 时按 `channel_members.read_inbox_max_id``available_min_id``channels.top_message_id` 与未删除消息动态派生普通未读。`channel_dialogs.default_send_as_peer_type/default_send_as_peer_id` 保存当前 owner 的默认发送身份,由 `channels.getFullChannel` 输出为 `channelFull.default_send_as`,不参与历史分页或 dialog 排序;`channel_dialogs.view_forum_as_messages` 是当前账号本地 forum 展示模式,由 `Dialog/ChannelFull.view_forum_as_messages` 恢复 UI并通过账号级 durable update 同步多 session。`channel_unread_mentions(user_id,channel_id,message_id)` 不复制消息正文,发送时只写解析出的 active/可见/未读成员,清除后重算 `channel_dialogs.unread_mentions_count`。共同超级群查询已迁到 `user_channel_member_index(user_id, channel_id)`,排除 broadcast 与非 active/deleted 成员;后续 `channels.getLeftChannels`、joined/admined channel 列表和启动 dialog 聚合也必须从 user 维度 read model 或两步 channel_id 列表读取,不能直接用 `channel_members WHERE user_id=...` 反向扫 `channel_id` 分区。
- **`channel_members` / `channel_dialogs` / `channel_unread_mentions`** —— 成员权限、读水位、owner 视角 channel dialog 与未读提及索引,分别按 `channel_id` / `user_id` / `user_id` HASH 分区;`available_min_id` 限制成员可见历史消息,`available_min_pts` 限制 `updates.getChannelDifference` 起点,避免新成员或重新加入成员恢复到入群前的消息类 durable 事件。`channel_dialogs.unread_count` 是小超级群普通未读缓存字段,不是 broadcast/大超级群真值;大频道读取 dialog/full channel 时按 `channel_members.read_inbox_max_id``available_min_id``channels.top_message_id` 与未删除消息动态派生普通未读。`channel_dialogs.default_send_as_peer_type/default_send_as_peer_id` 保存当前 owner 的默认发送身份,由 `channels.getFullChannel` 输出为 `channelFull.default_send_as`,不参与历史分页或 dialog 排序;`channel_dialogs.view_forum_as_messages` 是当前账号本地 forum 展示模式,由 `Dialog/ChannelFull.view_forum_as_messages` 恢复 UI并通过账号级 durable update 同步多 session。`channel_unread_mentions(user_id,channel_id,message_id,media_unread)` 不复制消息正文,发送/编辑时只写解析出的 active/可见/未读成员,清除后重算 `channel_dialogs.unread_mentions_count`history/getMessages/getChannelDifference/online update 按 viewer 用它回填 `mentioned/media_unread`。共同超级群查询已迁到 `user_channel_member_index(user_id, channel_id)`,排除 broadcast 与非 active/deleted 成员;后续 `channels.getLeftChannels`、joined/admined channel 列表和启动 dialog 聚合也必须从 user 维度 read model 或两步 channel_id 列表读取,不能直接用 `channel_members WHERE user_id=...` 反向扫 `channel_id` 分区。
- **`channel_invites` / `channel_invite_importers`** —— 邀请链接、导入者与 join request read model均按 `channel_id` HASH 分区invite 保存 `usage_count/requested_count`importer 以 `(channel_id,user_id)` 保证同一用户只有一个 pending/approved 状态,管理页查询走 `admin/revoked/offset_link``requested/link/date/user_id` seek 索引,禁止按超大 limit 或 hash 反查做全表扫。public `channels.toggleJoinRequest` 使用 `channels.join_request``channel_invite_importers(invite_id=0, requested=true)` 表达非 invite-link pending request管理员实时提醒用 bounded `updatePendingJoinRequests` + full channel 回填,不为每条 pending 状态生成无界 durable updates。
- **`dialog_filters` / `dialog_filter_settings`** —— 当前账号自定义 dialog filter、filter 顺序与 folder tags 开关,按 `user_id` HASH 分区;自定义 filter 从 ID 2 开始,归档只由 `dialogs.folder_id=1` 表达,避免一列同时承担归档状态和任意筛选规则。
- **`dispatch_outbox`** —— 按 `target_user_id` HASH 分区的 transactional outbox。发送事务内写入RPC outbox worker 用 `FOR UPDATE SKIP LOCKED` 批量 claim成功标记 delivered失败退避重试排除当前设备使用 `exclude_auth_key_id + exclude_session_id`,避免一个设备换号或多账号登录时误过滤。按 target 分区适合投递完成/失败按用户更新,但全局 claim/cleanup 与分区键不一致,规划会展开所有分区;上量前需引入 ready queue 或 worker shard read model。
@ -145,6 +146,7 @@ internal/store/
- **P4.3 删除消息/清空历史**`messages.deleteMessages/deleteHistory` 已支持 owner 视角软删除、`revoke` 对端清理、dialog top 重算/删除/`just_clear` 保留空 dialog、`updateDeleteMessages` durable payload`message_ids` + `pts_count=len(message_ids)`)与 outbox 投递;后续新消息会正常重建被删除的 dialog。—— ✅
- **P4.4 Dialog 分组/归档**`messages.getDialogFilters/updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags``folders.editPeerFolders` 已接入 PG-backed 服务;集成测试覆盖 folder_id 0/1 主列表/归档、自定义 filter、tags 和归档还原。—— ✅
- **P4.5 TDesktop 搜索入口**`contacts.search` 已支持联系人/非联系人用户搜索,`contacts.getSponsoredPeers` 返回空 sponsored peers`messages.searchGlobal` 接当前 owner 私聊文本搜索;查询保护与索引设计参考实现,避免客户端搜索框卡在 Loading。—— ✅
- **P4.6 内容已读与 blocklist privacy gate**`message_boxes.media_unread/reaction_unread``user_update_events(read_message_contents)``contact_blocks` 已落地;`messages.readMessageContents` 只在实际清理 unread 内容状态时分配 pts 并 durable 推送,被 block 后私聊 send 只写 sender outboxedit/revoke delete 会返回 forbidden。—— ✅
## 7. 与铁律的关系

View file

@ -1375,6 +1375,17 @@ func (s *Service) ActiveChannelIDsForUser(ctx context.Context, userID, afterChan
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
}
// DirtyActiveChannelsForUser pages active joined channels with channel events after sinceDate.
func (s *Service) DirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) {
if s == nil || s.channels == nil || userID == 0 || sinceDate <= 0 || afterChannelID < 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
limit = domain.MaxChannelDifferenceLimit
}
return s.channels.ListDirtyActiveChannelsForUser(ctx, userID, sinceDate, afterChannelID, limit)
}
// ActiveMemberIDs returns a bounded list for transient online fanout such as typing.
func (s *Service) ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) {
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {

View file

@ -113,6 +113,7 @@ func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) {
ChannelID: created.Channel.ID,
RandomID: 9101,
Message: "hello @friend",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument},
MentionUserIDs: []int64{1002, 1002, 1001},
Date: 1700000101,
})
@ -133,6 +134,31 @@ func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) {
if other.Dialog.UnreadMentions != 0 {
t.Fatalf("unmentioned dialog unread mentions = %d, want 0", other.Dialog.UnreadMentions)
}
history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
if err != nil {
t.Fatalf("GetHistory mentioned: %v", err)
}
if len(history.Messages) == 0 || !history.Messages[0].Mentioned || !history.Messages[0].MediaUnread {
t.Fatalf("mentioned history = %+v, want mentioned/media_unread flags", history.Messages)
}
otherHistory, err := service.GetHistory(ctx, 1003, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
if err != nil {
t.Fatalf("GetHistory other: %v", err)
}
if len(otherHistory.Messages) == 0 || otherHistory.Messages[0].Mentioned || otherHistory.Messages[0].MediaUnread {
t.Fatalf("other history = %+v, want no viewer-specific mention flags", otherHistory.Messages)
}
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{
ChannelID: created.Channel.ID,
Pts: sent.Event.Pts - 1,
Limit: 10,
})
if err != nil {
t.Fatalf("GetDifference mentioned: %v", err)
}
if len(diff.NewMessages) != 1 || !diff.NewMessages[0].Mentioned || !diff.NewMessages[0].MediaUnread {
t.Fatalf("mentioned diff = %+v, want mentioned/media_unread flags", diff.NewMessages)
}
mentions, err := service.GetUnreadMentions(ctx, 1002, domain.ChannelUnreadMentionsFilter{
ChannelID: created.Channel.ID,
OffsetID: 1,
@ -159,6 +185,13 @@ func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) {
if mentions.Count != 0 || len(mentions.Messages) != 0 {
t.Fatalf("mentions after read = count %d messages %d, want empty", mentions.Count, len(mentions.Messages))
}
history, err = service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
if err != nil {
t.Fatalf("GetHistory after read mentions: %v", err)
}
if len(history.Messages) == 0 || history.Messages[0].Mentioned || history.Messages[0].MediaUnread {
t.Fatalf("mentioned history after read = %+v, want mention flags cleared", history.Messages)
}
}
func TestServiceRejectsMismatchedUserContextForStateReads(t *testing.T) {

View file

@ -229,13 +229,59 @@ func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain
if err != nil {
return domain.PeerSettings{}, err
}
blocked, err := s.contacts.IsBlocked(ctx, userID, peer.ID)
if err != nil {
return domain.PeerSettings{}, err
}
return domain.PeerSettings{
AddContact: !found,
BlockContact: !found,
BlockContact: !blocked,
ShareContact: found,
}, nil
}
// BlockContact adds peer to the current user's blocklist.
func (s *Service) BlockContact(ctx context.Context, userID, peerUserID int64, date int) (bool, error) {
if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 || peerUserID == userID {
return false, ErrContactIDInvalid
}
if s.users != nil {
if _, found, err := s.users.ByID(ctx, peerUserID); err != nil {
return false, err
} else if !found {
return false, ErrContactIDInvalid
}
}
return s.contacts.Block(ctx, userID, peerUserID, date)
}
// UnblockContact removes peer from the current user's blocklist.
func (s *Service) UnblockContact(ctx context.Context, userID, peerUserID int64) (bool, error) {
if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 || peerUserID == userID {
return false, ErrContactIDInvalid
}
return s.contacts.Unblock(ctx, userID, peerUserID)
}
// IsBlocked reports whether owner has blocked peer.
func (s *Service) IsBlocked(ctx context.Context, userID, peerUserID int64) (bool, error) {
if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 {
return false, nil
}
return s.contacts.IsBlocked(ctx, userID, peerUserID)
}
// GetBlocked returns a bounded blocked contact page.
func (s *Service) GetBlocked(ctx context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) {
if s == nil || s.contacts == nil || userID == 0 {
return domain.BlockedContactList{}, nil
}
if limit <= 0 || limit > 100 {
limit = 100
}
return s.contacts.ListBlocked(ctx, userID, offset, limit)
}
func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) {
list, notModified, err := s.GetContacts(ctx, userID, hash)
if err != nil || notModified {

View file

@ -347,6 +347,8 @@ type ChannelMessage struct {
Reactions *ChannelMessageReactions
Action *ChannelMessageAction
Media *MessageMedia
Mentioned bool
MediaUnread bool
Pts int
Deleted bool
}
@ -827,6 +829,12 @@ type ChannelDifference struct {
Timeout int
}
// DirtyChannel identifies an active channel with channel-scoped updates after an account difference date.
type DirtyChannel struct {
ChannelID int64
Pts int
}
// CreateChannelRequest creates a broadcast channel or megagroup.
type CreateChannelRequest struct {
CreatorUserID int64

View file

@ -20,6 +20,18 @@ type ContactList struct {
Hash int64
}
// BlockedContact is one owner-visible blocked peer.
type BlockedContact struct {
User User
Date int
}
// BlockedContactList describes contacts.getBlocked output.
type BlockedContactList struct {
Blocked []BlockedContact
Count int
}
// ContactInput 描述一次 owner 视角联系人写入。
type ContactInput struct {
ContactUserID int64

View file

@ -73,24 +73,26 @@ type MessageEntity struct {
// Message 是账号视角下的一条私聊消息。
type Message struct {
ID int // 当前 owner 视角下的 message box id暴露给 Telegram 客户端。
UID int64 // 共享私聊消息主体 id不暴露给客户端。
RandomID int64
OwnerUserID int64
Peer Peer
From Peer
Date int
EditDate int
Out bool
Silent bool
NoForwards bool
Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
Reactions *ChannelMessageReactions
Pts int
Media *MessageMedia
ID int // 当前 owner 视角下的 message box id暴露给 Telegram 客户端。
UID int64 // 共享私聊消息主体 id不暴露给客户端。
RandomID int64
OwnerUserID int64
Peer Peer
From Peer
Date int
EditDate int
Out bool
Silent bool
NoForwards bool
Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
Reactions *ChannelMessageReactions
Pts int
Media *MessageMedia
MediaUnread bool
ReactionUnread bool
}
// MessageReply describes a message reply/thread header without depending on TL types.
@ -139,19 +141,20 @@ type MessageFilter struct {
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
type SendPrivateTextRequest struct {
SenderUserID int64
RecipientUserID int64
RandomID int64
Message string
Entities []MessageEntity
Media *MessageMedia
Silent bool
NoForwards bool
ReplyTo *MessageReply
Forward *MessageForward
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
SenderUserID int64
RecipientUserID int64
RandomID int64
Message string
Entities []MessageEntity
Media *MessageMedia
Silent bool
NoForwards bool
ReplyTo *MessageReply
Forward *MessageForward
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
RecipientBlocked bool
}
// SendPrivateTextResult 描述一次私聊文本发送的双端结果。
@ -189,18 +192,19 @@ type PrivateMessageReactionsResult struct {
// ForwardPrivateMessagesRequest 是私聊文本消息转发命令。
type ForwardPrivateMessagesRequest struct {
OwnerUserID int64
FromPeer Peer
ToUserID int64
MessageIDs []int
RandomIDs []int64
Silent bool
NoForwards bool
DropAuthor bool
ReplyTo *MessageReply
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
OwnerUserID int64
FromPeer Peer
ToUserID int64
MessageIDs []int
RandomIDs []int64
Silent bool
NoForwards bool
DropAuthor bool
ReplyTo *MessageReply
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
RecipientBlocked bool
}
// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。
@ -238,14 +242,18 @@ type ReadHistoryResult struct {
// ReadMessageContentsRequest marks media/mention contents as read for exact owner-visible messages.
type ReadMessageContentsRequest struct {
OwnerUserID int64
IDs []int
OwnerUserID int64
IDs []int
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// ReadMessageContentsResult contains owner-visible message IDs that existed and can be synced.
// ReadMessageContentsResult contains owner-visible message IDs whose content unread state changed.
type ReadMessageContentsResult struct {
OwnerUserID int64
MessageIDs []int
Event UpdateEvent
}
// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。

View file

@ -4,24 +4,25 @@ package domain
type UpdateEventType string
const (
UpdateEventNewMessage UpdateEventType = "new_message"
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
UpdateEventEditMessage UpdateEventType = "edit_message"
UpdateEventMessageReactions UpdateEventType = "message_reactions"
UpdateEventContactsReset UpdateEventType = "contacts_reset"
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
UpdateEventPeerSettings UpdateEventType = "peer_settings"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
UpdateEventDialogFilter UpdateEventType = "dialog_filter"
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
UpdateEventFolderPeers UpdateEventType = "folder_peers"
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
UpdateEventNoop UpdateEventType = "noop"
UpdateEventNewMessage UpdateEventType = "new_message"
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
UpdateEventEditMessage UpdateEventType = "edit_message"
UpdateEventMessageReactions UpdateEventType = "message_reactions"
UpdateEventContactsReset UpdateEventType = "contacts_reset"
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
UpdateEventPeerSettings UpdateEventType = "peer_settings"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
UpdateEventDialogFilter UpdateEventType = "dialog_filter"
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
UpdateEventFolderPeers UpdateEventType = "folder_peers"
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
UpdateEventNoop UpdateEventType = "noop"
)
// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。
@ -50,9 +51,16 @@ type UpdateEvent struct {
// UpdateDifference 是 updates.getDifference 的业务层结果。
type UpdateDifference struct {
State UpdateState
Events []UpdateEvent
State UpdateState
Events []UpdateEvent
ChannelNudges []ChannelDifferenceNudge
// Partial 为 true 表示连续事件被 limit 截断、后面还有(映射 updates.differenceSlice
// 客户端据 State 继续翻页false 表示已到当前连续末尾updates.difference
Partial bool
}
// ChannelDifferenceNudge is a computed account-level hint that a channel diff is dirty.
type ChannelDifferenceNudge struct {
ChannelID int64
Pts int
}

View file

@ -2700,12 +2700,15 @@ func (r *Router) channelOperationUpdates(ctx context.Context, viewerUserID int64
for _, member := range res.Members {
users = append(users, member.UserID)
}
updates := make([]tg.UpdateClass, 0, 1)
updates := make([]tg.UpdateClass, 0, 2)
if res.Event.Pts != 0 {
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
updates = append(updates, update)
}
}
if res.Channel.ID != 0 {
updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID})
}
return &tg.Updates{
Updates: updates,
Users: r.tgUsersForIDs(ctx, viewerUserID, users),

View file

@ -31,6 +31,8 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) {
d.OnContactsImportContacts(r.onContactsImportContacts)
d.OnContactsAddContact(r.onContactsAddContact)
d.OnContactsDeleteContacts(r.onContactsDeleteContacts)
d.OnContactsBlock(r.onContactsBlock)
d.OnContactsUnblock(r.onContactsUnblock)
d.OnContactsUpdateContactNote(r.onContactsUpdateContactNote)
d.OnContactsSearch(r.onContactsSearch)
d.OnContactsResolveUsername(r.onContactsResolveUsername)
@ -38,12 +40,7 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) {
d.OnContactsGetTopPeers(func(ctx context.Context, req *tg.ContactsGetTopPeersRequest) (tg.ContactsTopPeersClass, error) {
return tdesktop.TopPeers(), nil
})
d.OnContactsGetBlocked(func(ctx context.Context, req *tg.ContactsGetBlockedRequest) (tg.ContactsBlockedClass, error) {
if req.Limit > 50 {
return nil, limitInvalidErr()
}
return tdesktop.BlockedContacts(), nil
})
d.OnContactsGetBlocked(r.onContactsGetBlocked)
d.OnContactsGetSponsoredPeers(func(ctx context.Context, q string) (tg.ContactsSponsoredPeersClass, error) {
if utf8.RuneCountInString(q) > maxContactSearchQLen {
return nil, limitInvalidErr()
@ -52,6 +49,81 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) {
})
}
func (r *Router) onContactsBlock(ctx context.Context, req *tg.ContactsBlockRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
peer, ok := r.domainPeerFromInputPeer(userID, req.ID)
if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
return false, userIDInvalidErr()
}
if r.deps.Contacts == nil {
return true, nil
}
if _, err := r.deps.Contacts.BlockContact(ctx, userID, peer.ID, int(r.clock.Now().Unix())); err != nil {
return false, contactErr(err)
}
if settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer); err == nil {
_ = r.recordPeerSettings(ctx, userID, peer, settings)
}
return true, nil
}
func (r *Router) onContactsUnblock(ctx context.Context, req *tg.ContactsUnblockRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
peer, ok := r.domainPeerFromInputPeer(userID, req.ID)
if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
return false, userIDInvalidErr()
}
if r.deps.Contacts == nil {
return true, nil
}
if _, err := r.deps.Contacts.UnblockContact(ctx, userID, peer.ID); err != nil {
return false, contactErr(err)
}
if settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer); err == nil {
_ = r.recordPeerSettings(ctx, userID, peer, settings)
}
return true, nil
}
func (r *Router) onContactsGetBlocked(ctx context.Context, req *tg.ContactsGetBlockedRequest) (tg.ContactsBlockedClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if req.Limit > 100 || req.Offset < 0 {
return nil, limitInvalidErr()
}
if r.deps.Contacts == nil {
return tdesktop.BlockedContacts(), nil
}
list, err := r.deps.Contacts.GetBlocked(ctx, userID, req.Offset, req.Limit)
if err != nil {
return nil, internalErr()
}
blocked := make([]tg.PeerBlocked, 0, len(list.Blocked))
users := make([]tg.UserClass, 0, len(list.Blocked))
for _, item := range list.Blocked {
if item.User.ID == 0 {
continue
}
blocked = append(blocked, tg.PeerBlocked{
PeerID: &tg.PeerUser{UserID: item.User.ID},
Date: item.Date,
})
users = append(users, r.tgUser(item.User))
}
if list.Count > len(blocked)+req.Offset {
return &tg.ContactsBlockedSlice{Count: list.Count, Blocked: blocked, Chats: []tg.ChatClass{}, Users: users}, nil
}
return &tg.ContactsBlocked{Blocked: blocked, Chats: []tg.ChatClass{}, Users: users}, nil
}
func (r *Router) onContactsGetContacts(ctx context.Context, hash int64) (tg.ContactsContactsClass, error) {
if r.deps.Contacts == nil {
return &tg.ContactsContacts{}, nil

View file

@ -349,6 +349,16 @@ func tgUpdatesDifference(diff domain.UpdateDifference) tg.UpdatesDifferenceClass
}
}
}
for _, nudge := range diff.ChannelNudges {
if nudge.ChannelID == 0 {
continue
}
update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID}
if nudge.Pts > 0 {
update.SetPts(nudge.Pts)
}
out.OtherUpdates = append(out.OtherUpdates, update)
}
// Partial连续事件被 limit 截断、后面还有 → updates.differenceSlice客户端据 IntermediateState 续拉。
if diff.Partial {
return &tg.UpdatesDifferenceSlice{
@ -561,6 +571,15 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
}
case domain.UpdateEventReadHistoryOutbox:
return tgReadHistoryOutbox(event)
case domain.UpdateEventReadMessageContents:
if len(event.MessageIDs) == 0 {
return nil
}
return &tg.UpdateReadMessagesContents{
Messages: append([]int(nil), event.MessageIDs...),
Pts: event.Pts,
PtsCount: event.PtsCount,
}
case domain.UpdateEventEditMessage:
msg := tgMessage(event.Message)
if msg == nil {
@ -856,12 +875,13 @@ func tgMessage(m domain.Message) tg.MessageClass {
return nil
}
msg := &tg.Message{
Out: m.Out,
ID: m.ID,
PeerID: peer,
Date: m.Date,
Message: m.Body,
Entities: tgMessageEntities(m.Entities),
Out: m.Out,
MediaUnread: m.MediaUnread,
ID: m.ID,
PeerID: peer,
Date: m.Date,
Message: m.Body,
Entities: tgMessageEntities(m.Entities),
}
if m.EditDate != 0 {
msg.SetEditDate(m.EditDate)
@ -993,16 +1013,18 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
return msg
}
msg := &tg.Message{
Out: outgoing,
Silent: m.Silent,
Post: m.Post,
Noforwards: m.NoForwards,
ID: m.ID,
FromID: from,
PeerID: peer,
Date: m.Date,
Message: m.Body,
Entities: tgMessageEntities(m.Entities),
Out: outgoing,
Silent: m.Silent,
Post: m.Post,
Noforwards: m.NoForwards,
Mentioned: m.Mentioned,
MediaUnread: m.MediaUnread,
ID: m.ID,
FromID: from,
PeerID: peer,
Date: m.Date,
Message: m.Body,
Entities: tgMessageEntities(m.Entities),
}
if m.EditDate != 0 {
msg.SetEditDate(m.EditDate)

View file

@ -138,6 +138,10 @@ type ContactsService interface {
DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)
UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error)
GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error)
BlockContact(ctx context.Context, userID, peerUserID int64, date int) (bool, error)
UnblockContact(ctx context.Context, userID, peerUserID int64) (bool, error)
IsBlocked(ctx context.Context, userID, peerUserID int64) (bool, error)
GetBlocked(ctx context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error)
}
// DialogsService 抽象会话列表查询。
@ -278,6 +282,7 @@ type ChannelsService interface {
GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
ActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error)
DirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error)
ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error)
InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)

View file

@ -140,6 +140,10 @@ func messageAuthorRequiredErr() error { return tgerr.New(403, "MESSAGE_AUTHOR_RE
func messageNotModifiedErr() error { return tgerr.New(400, "MESSAGE_NOT_MODIFIED") }
func messageEditForbiddenErr() error { return tgerr.New(403, "EDIT_MESSAGES_FORBIDDEN") }
func messageDeleteForbiddenErr() error { return tgerr.New(403, "DELETE_MESSAGES_FORBIDDEN") }
func messageNotReadYetErr() error { return tgerr.New(400, "MESSAGE_NOT_READ_YET") }
func replyMessageIDInvalidErr() error { return tgerr.New(400, "REPLY_MESSAGE_ID_INVALID") }

View file

@ -2808,6 +2808,7 @@ func (r *Router) onMessagesGetSponsoredMessages(ctx context.Context, req *tg.Mes
func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (*tg.MessagesAffectedMessages, error) {
id, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
@ -2823,8 +2824,11 @@ func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (
read := domain.ReadMessageContentsResult{OwnerUserID: userID}
if r.deps.Messages != nil {
read, err = r.deps.Messages.ReadMessageContents(ctx, userID, domain.ReadMessageContentsRequest{
OwnerUserID: userID,
IDs: ids,
OwnerUserID: userID,
IDs: ids,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: id,
OriginSessionID: sessionID,
})
if err != nil {
if errors.Is(err, domain.ErrMessageIDInvalid) {
@ -2833,12 +2837,15 @@ func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (
return nil, internalErr()
}
}
affected, err := r.affectedMessages(ctx, id, userID)
if err != nil {
return nil, err
affected := &tg.MessagesAffectedMessages{Pts: read.Event.Pts, PtsCount: read.Event.PtsCount}
if read.Event.Pts == 0 {
affected, err = r.affectedMessages(ctx, id, userID)
if err != nil {
return nil, err
}
}
if contentIDs := readMessageContentIDs(read.MessageIDs); len(contentIDs) > 0 {
r.pushUserUpdates(ctx, userID, &tg.Updates{
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateReadMessagesContents{
Messages: contentIDs,
Pts: affected.Pts,
@ -4378,6 +4385,10 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
if r.deps.Channels == nil || r.deps.Messages == nil {
return nil, peerIDInvalidErr()
}
recipientBlocked, err := r.peerBlocksUser(ctx, userID, toPeer.ID)
if err != nil {
return nil, err
}
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
if err != nil {
return nil, messageForwardErr(err)
@ -4391,19 +4402,20 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
forward = nil
}
sent, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: toPeer.ID,
RandomID: req.RandomID[i],
Message: source.body,
Entities: source.entities,
Media: source.media,
Silent: req.Silent,
NoForwards: req.Noforwards,
ReplyTo: replyTo,
Forward: forward,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
SenderUserID: userID,
RecipientUserID: toPeer.ID,
RandomID: req.RandomID[i],
Message: source.body,
Entities: source.entities,
Media: source.media,
Silent: req.Silent,
NoForwards: req.Noforwards,
ReplyTo: replyTo,
Forward: forward,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
if err != nil {
return nil, messageForwardErr(err)
@ -4421,19 +4433,24 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
recipientBlocked, err := r.peerBlocksUser(ctx, userID, toPeer.ID)
if err != nil {
return nil, err
}
res, err := r.deps.Messages.ForwardPrivateMessages(ctx, userID, domain.ForwardPrivateMessagesRequest{
OwnerUserID: userID,
FromPeer: fromPeer,
ToUserID: toPeer.ID,
MessageIDs: append([]int(nil), req.ID...),
RandomIDs: append([]int64(nil), req.RandomID...),
Silent: req.Silent,
NoForwards: req.Noforwards,
DropAuthor: req.DropAuthor,
ReplyTo: replyTo,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
OwnerUserID: userID,
FromPeer: fromPeer,
ToUserID: toPeer.ID,
MessageIDs: append([]int(nil), req.ID...),
RandomIDs: append([]int64(nil), req.RandomID...),
Silent: req.Silent,
NoForwards: req.Noforwards,
DropAuthor: req.DropAuthor,
ReplyTo: replyTo,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
if err != nil {
return nil, messageForwardErr(err)
@ -4641,6 +4658,13 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
if peer.Type != domain.PeerTypeUser || r.deps.Messages == nil {
return nil, peerIDInvalidErr()
}
blocked, err := r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
return nil, err
}
if blocked {
return nil, messageEditForbiddenErr()
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{
@ -4818,6 +4842,19 @@ func (r *Router) onMessagesDeleteMessages(ctx context.Context, req *tg.MessagesD
if len(req.ID) > domain.MaxDeleteMessageIDs {
return nil, limitInvalidErr()
}
if req.GetRevoke() {
list, err := r.deps.Messages.GetMessages(ctx, userID, req.ID)
if err != nil {
return nil, internalErr()
}
blocked, err := r.messagesTouchBlockedPeer(ctx, userID, list.Messages)
if err != nil {
return nil, err
}
if blocked {
return nil, messageDeleteForbiddenErr()
}
}
sessionID, _ := SessionIDFrom(ctx)
res, err := r.deps.Messages.DeleteMessages(ctx, userID, domain.DeleteMessagesRequest{
OwnerUserID: userID,
@ -4892,6 +4929,15 @@ func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDe
if r.deps.Messages == nil {
return r.affectedHistory(ctx, authKeyID, userID, 0)
}
if req.GetRevoke() {
blocked, err := r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
return nil, err
}
if blocked {
return nil, messageDeleteForbiddenErr()
}
}
sessionID, _ := SessionIDFrom(ctx)
res, err := r.deps.Messages.DeleteHistory(ctx, userID, domain.DeleteHistoryRequest{
OwnerUserID: userID,
@ -4972,6 +5018,38 @@ func messageSendErr(err error) error {
}
}
func (r *Router) peerBlocksUser(ctx context.Context, userID, peerUserID int64) (bool, error) {
if userID == 0 || peerUserID == 0 || userID == peerUserID || r.deps.Contacts == nil {
return false, nil
}
blocked, err := r.deps.Contacts.IsBlocked(ctx, peerUserID, userID)
if err != nil {
return false, internalErr()
}
return blocked, nil
}
func (r *Router) messagesTouchBlockedPeer(ctx context.Context, userID int64, messages []domain.Message) (bool, error) {
seen := make(map[int64]struct{}, len(messages))
for _, msg := range messages {
if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID == 0 {
continue
}
if _, ok := seen[msg.Peer.ID]; ok {
continue
}
seen[msg.Peer.ID] = struct{}{}
blocked, err := r.peerBlocksUser(ctx, userID, msg.Peer.ID)
if err != nil {
return false, err
}
if blocked {
return true, nil
}
}
return false, nil
}
func messageForwardErr(err error) error {
switch {
case errors.Is(err, domain.ErrMessageIDInvalid):

View file

@ -829,13 +829,16 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
if !ok || !channel.Megagroup || channel.Broadcast {
t.Fatalf("chat = %#v, want megagroup channel", updates.Chats[0])
}
if len(updates.Updates) != 2 {
t.Fatalf("updates len = %d, want create + invite service messages", len(updates.Updates))
if len(updates.Updates) != 4 {
t.Fatalf("updates len = %d, want create/invite service messages plus channel refreshes", len(updates.Updates))
}
newMsg, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage)
if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 {
t.Fatalf("create update = %#v, want channel pts=1", updates.Updates[0])
}
if refresh, ok := updates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID {
t.Fatalf("create refresh = %#v, want channel refresh", updates.Updates[1])
}
service, ok := newMsg.Message.(*tg.MessageService)
if !ok {
t.Fatalf("create message = %T, want service", newMsg.Message)
@ -843,9 +846,12 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok {
t.Fatalf("service action = %T, want channel create", service.Action)
}
inviteMsg, ok := updates.Updates[1].(*tg.UpdateNewChannelMessage)
inviteMsg, ok := updates.Updates[2].(*tg.UpdateNewChannelMessage)
if !ok || inviteMsg.Pts != 2 || inviteMsg.PtsCount != 1 {
t.Fatalf("invite update = %#v, want channel pts=2", updates.Updates[1])
t.Fatalf("invite update = %#v, want channel pts=2", updates.Updates[2])
}
if refresh, ok := updates.Updates[3].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID {
t.Fatalf("invite refresh = %#v, want channel refresh", updates.Updates[3])
}
inviteService, ok := inviteMsg.Message.(*tg.MessageService)
if !ok {
@ -1102,8 +1108,8 @@ func TestMessagesCreateChatDispatchRemembersTDesktopClientInfo(t *testing.T) {
if !ok || !channel.Megagroup || !channel.Creator {
t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1])
}
if len(updates.Updates) != 2 {
t.Fatalf("updates len = %d, want create + invite service messages", len(updates.Updates))
if len(updates.Updates) != 4 {
t.Fatalf("updates len = %d, want create/invite service messages plus channel refreshes", len(updates.Updates))
}
participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
@ -4337,8 +4343,12 @@ func TestChannelAdminPinInviteRPC(t *testing.T) {
if err != nil {
t.Fatalf("import invite: %v", err)
}
if updates := imported.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) != 1 {
t.Fatalf("import updates = %+v, want chat and join service update", updates)
if updates := imported.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) != 2 {
t.Fatalf("import updates = %+v, want chat, join service update, and channel refresh", updates)
} else if _, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage); !ok {
t.Fatalf("import first update = %T, want join service update", updates.Updates[0])
} else if refresh, ok := updates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID {
t.Fatalf("import second update = %#v, want channel refresh", updates.Updates[1])
}
inviteList, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
@ -7202,6 +7212,29 @@ func TestUpdatesDifferenceIncludesDeleteMessages(t *testing.T) {
}
}
func TestUpdatesDifferenceIncludesChannelTooLongNudge(t *testing.T) {
got, ok := tgUpdatesDifference(domain.UpdateDifference{
State: domain.UpdateState{Pts: 8, Date: 1700000250, Seq: 0},
ChannelNudges: []domain.ChannelDifferenceNudge{{
ChannelID: 2000000001,
Pts: 12,
}},
}).(*tg.UpdatesDifference)
if !ok {
t.Fatalf("difference = %T, want *tg.UpdatesDifference", got)
}
if got.State.Pts != 8 || len(got.OtherUpdates) != 1 {
t.Fatalf("difference = %+v, want one channel nudge and account pts unchanged", got)
}
update, ok := got.OtherUpdates[0].(*tg.UpdateChannelTooLong)
if !ok || update.ChannelID != 2000000001 {
t.Fatalf("update = %T %+v, want UpdateChannelTooLong", got.OtherUpdates[0], got.OtherUpdates[0])
}
if pts, ok := update.GetPts(); !ok || pts != 12 {
t.Fatalf("channel nudge pts = %d set=%v, want 12", pts, ok)
}
}
func TestUpdatesDifferenceIncludesSettingsUpdates(t *testing.T) {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
got, ok := tgUpdatesDifference(domain.UpdateDifference{
@ -8354,6 +8387,137 @@ func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) {
}
}
func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Alice"})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009002", FirstName: "Bob"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Contacts: appcontacts.NewService(memory.NewContactStore(), userStore),
}, zaptest.NewLogger(t), clock.System)
ok, err := r.onContactsBlock(WithUserID(ctx, bob.ID), &tg.ContactsBlockRequest{
ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
})
if err != nil || !ok {
t.Fatalf("contacts.block = %v, %v", ok, err)
}
blocked, err := r.onContactsGetBlocked(WithUserID(ctx, bob.ID), &tg.ContactsGetBlockedRequest{Limit: 10})
if err != nil {
t.Fatalf("contacts.getBlocked: %v", err)
}
full, ok := blocked.(*tg.ContactsBlocked)
if !ok || len(full.Blocked) != 1 || len(full.Users) != 1 {
t.Fatalf("blocked = %T %+v, want one blocked user", blocked, blocked)
}
if peer, ok := full.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != alice.ID {
t.Fatalf("blocked peer = %#v, want alice", full.Blocked[0].PeerID)
}
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID {
t.Fatalf("blocked user = %#v, want alice", full.Users[0])
}
ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{
ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
})
if err != nil || !ok {
t.Fatalf("contacts.unblock = %v, %v", ok, err)
}
blocked, err = r.onContactsGetBlocked(WithUserID(ctx, bob.ID), &tg.ContactsGetBlockedRequest{Limit: 10})
if err != nil {
t.Fatalf("contacts.getBlocked after unblock: %v", err)
}
if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 0 {
t.Fatalf("blocked after unblock = %T %+v, want empty contacts.blocked", blocked, blocked)
}
}
func TestMessagesPrivateBlockPreventsRecipientInboxAndRevokeRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009101", FirstName: "Alice"})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009102", FirstName: "Bob"})
if err != nil {
t.Fatalf("create bob: %v", err)
}
dialogs := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogs)
contactStore := memory.NewContactStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Contacts: appcontacts.NewService(contactStore, userStore),
Messages: appmessages.NewService(messageStore, dialogs),
Dialogs: appdialogs.NewService(dialogs),
}, zaptest.NewLogger(t), clock.System)
delivered, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Message: "before block",
RandomID: 91001,
})
if err != nil {
t.Fatalf("send before block: %v", err)
}
deliveredUpdates := delivered.(*tg.Updates)
deliveredMsg := deliveredUpdates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message)
if ok, err := r.onContactsBlock(WithUserID(ctx, bob.ID), &tg.ContactsBlockRequest{
ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
}); err != nil || !ok {
t.Fatalf("bob block alice = %v, %v", ok, err)
}
blockedSend, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Message: "after block",
RandomID: 91002,
})
if err != nil {
t.Fatalf("send after block: %v", err)
}
blockedUpdates := blockedSend.(*tg.Updates)
blockedMsg := blockedUpdates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message)
if blockedMsg.ID == 0 || !blockedMsg.Out {
t.Fatalf("blocked sender update = %#v, want outgoing sender message", blockedMsg)
}
bobHistory, err := messageStore.ListByUser(ctx, bob.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID},
Limit: 10,
})
if err != nil {
t.Fatalf("bob history: %v", err)
}
if len(bobHistory.Messages) != 1 || bobHistory.Messages[0].Body != "before block" {
t.Fatalf("bob history = %+v, want only pre-block delivered message", bobHistory.Messages)
}
aliceHistory, err := messageStore.ListByUser(ctx, alice.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID},
Limit: 10,
})
if err != nil {
t.Fatalf("alice history: %v", err)
}
if len(aliceHistory.Messages) != 2 {
t.Fatalf("alice history len = %d, want delivered + sender-only blocked message", len(aliceHistory.Messages))
}
deleteReq := &tg.MessagesDeleteMessagesRequest{ID: []int{deliveredMsg.ID}}
deleteReq.SetRevoke(true)
if _, err := r.onMessagesDeleteMessages(WithUserID(ctx, alice.ID), deleteReq); err == nil || !strings.Contains(err.Error(), "DELETE_MESSAGES_FORBIDDEN") {
t.Fatalf("revoke after block err = %v, want DELETE_MESSAGES_FORBIDDEN", err)
}
}
func TestMessagesSendMessageSupportsReplyAndFlags(t *testing.T) {
const (
senderID = int64(1000000001)

View file

@ -94,21 +94,26 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
if err != nil {
return nil, false, err
}
recipientBlocked, err := r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
return nil, false, err
}
sessionID, _ := SessionIDFrom(ctx)
authKeyID, _ := AuthKeyIDFrom(ctx)
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: peer.ID,
RandomID: p.randomID,
Message: p.message,
Entities: domainMessageEntities(p.entities),
Media: p.media,
Silent: p.silent,
NoForwards: p.noforwards,
ReplyTo: replyTo,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
SenderUserID: userID,
RecipientUserID: peer.ID,
RandomID: p.randomID,
Message: p.message,
Entities: domainMessageEntities(p.entities),
Media: p.media,
Silent: p.silent,
NoForwards: p.noforwards,
ReplyTo: replyTo,
Date: int(r.clock.Now().Unix()),
OriginAuthKeyID: authKeyID,
OriginSessionID: sessionID,
RecipientBlocked: recipientBlocked,
})
if err != nil {
return nil, false, messageSendErr(err)

View file

@ -63,13 +63,32 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD
return nil, internalErr()
}
r.markSessionReceivesUpdates(ctx, userID)
if len(st.Events) == 0 {
st.ChannelNudges = r.accountChannelDifferenceNudges(ctx, userID, req.Date)
if len(st.Events) == 0 && len(st.ChannelNudges) == 0 {
return &tg.UpdatesDifferenceEmpty{Date: st.State.Date, Seq: st.State.Seq}, nil
}
st.Events = r.enrichUpdateEvents(ctx, userID, st.Events)
return tgUpdatesDifference(st), nil
}
func (r *Router) accountChannelDifferenceNudges(ctx context.Context, userID int64, sinceDate int) []domain.ChannelDifferenceNudge {
if r.deps.Channels == nil || userID == 0 || sinceDate <= 0 {
return nil
}
dirty, err := r.deps.Channels.DirtyActiveChannelsForUser(ctx, userID, sinceDate, 0, domain.MaxChannelDifferenceLimit)
if err != nil || len(dirty) == 0 {
return nil
}
out := make([]domain.ChannelDifferenceNudge, 0, len(dirty))
for _, item := range dirty {
if item.ChannelID == 0 {
continue
}
out = append(out, domain.ChannelDifferenceNudge{ChannelID: item.ChannelID, Pts: item.Pts})
}
return out
}
func (r *Router) markSessionReceivesUpdates(ctx context.Context, userID int64) {
if r.deps.Sessions == nil {
return

View file

@ -109,6 +109,7 @@ type ChannelStore interface {
ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error)
ListDirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error)
ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)

View file

@ -14,4 +14,8 @@ type ContactStore interface {
UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error)
UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error)
Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)
Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error)
Unblock(ctx context.Context, userID, blockedUserID int64) (bool, error)
IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error)
ListBlocked(ctx context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error)
}

View file

@ -4937,6 +4937,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
}
messages = append(messages, cloneChannelMessage(msg))
}
s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages)
return domain.ChannelDifference{
Channel: channel,
Self: member,
@ -4991,6 +4992,15 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
diff.OtherUpdates = append(diff.OtherUpdates, cloneChannelEvent(event))
}
}
s.populateChannelMessageUnreadFlagsLocked(req.UserID, diff.NewMessages)
for i := range diff.OtherUpdates {
if diff.OtherUpdates[i].Message.ID == 0 {
continue
}
messages := []domain.ChannelMessage{diff.OtherUpdates[i].Message}
s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages)
diff.OtherUpdates[i].Message = messages[0]
}
return diff, nil
}
@ -5025,6 +5035,46 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, af
return out, nil
}
func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if userID == 0 || sinceDate <= 0 || afterChannelID < 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
limit = domain.MaxChannelDifferenceLimit
}
out := make([]domain.DirtyChannel, 0, limit)
for channelID, members := range s.members {
if channelID <= afterChannelID {
continue
}
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
continue
}
member, ok := members[userID]
if !ok || member.Status != domain.ChannelMemberActive {
continue
}
dirty := false
for _, event := range s.events[channelID] {
if event.Date > sinceDate {
dirty = true
break
}
}
if dirty {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (s *ChannelStore) ListActiveChannelMemberIDs(_ context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@ -6495,6 +6545,7 @@ func (s *ChannelStore) populateChannelMessageReactionsLocked(viewerUserID int64,
if len(messages) == 0 || channel.ID == 0 {
return
}
s.populateChannelMessageUnreadFlagsLocked(viewerUserID, messages)
for i := range messages {
if messages[i].ChannelID != channel.ID || messages[i].ID <= 0 {
continue
@ -6511,6 +6562,7 @@ func (s *ChannelStore) populateChannelMessagesReactionsLocked(viewerUserID int64
if len(messages) == 0 {
return
}
s.populateChannelMessageUnreadFlagsLocked(viewerUserID, messages)
channelsByID := make(map[int64]domain.Channel, len(channels))
for _, ch := range channels {
if ch.ID != 0 {
@ -6533,6 +6585,22 @@ func (s *ChannelStore) populateChannelMessagesReactionsLocked(viewerUserID int64
}
}
func (s *ChannelStore) populateChannelMessageUnreadFlagsLocked(viewerUserID int64, messages []domain.ChannelMessage) {
if viewerUserID == 0 || len(messages) == 0 {
return
}
for i := range messages {
if messages[i].ChannelID == 0 || messages[i].ID <= 0 {
continue
}
if _, ok := s.mentions[viewerUserID][messages[i].ChannelID][messages[i].ID]; !ok {
continue
}
messages[i].Mentioned = true
messages[i].MediaUnread = !messages[i].Media.IsZero()
}
}
type memoryReactionCursor struct {
date int
userID int64

View file

@ -242,13 +242,17 @@ func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]
// ContactStore 是 store.ContactStore 的内存实现。
type ContactStore struct {
mu sync.RWMutex
m map[int64]domain.ContactList
mu sync.RWMutex
m map[int64]domain.ContactList
blocks map[int64]map[int64]domain.BlockedContact
}
// NewContactStore 创建内存 ContactStore。
func NewContactStore() *ContactStore {
return &ContactStore{m: make(map[int64]domain.ContactList)}
return &ContactStore{
m: make(map[int64]domain.ContactList),
blocks: make(map[int64]map[int64]domain.BlockedContact),
}
}
func (s *ContactStore) ListByUser(_ context.Context, userID int64) (domain.ContactList, error) {
@ -400,6 +404,68 @@ func (s *ContactStore) Delete(_ context.Context, userID int64, contactUserIDs []
return deleted, nil
}
func (s *ContactStore) Block(_ context.Context, userID, blockedUserID int64, date int) (bool, error) {
if userID == 0 || blockedUserID == 0 || userID == blockedUserID {
return false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.blocks[userID] == nil {
s.blocks[userID] = make(map[int64]domain.BlockedContact)
}
_, existed := s.blocks[userID][blockedUserID]
s.blocks[userID][blockedUserID] = domain.BlockedContact{
User: domain.User{ID: blockedUserID},
Date: date,
}
return !existed, nil
}
func (s *ContactStore) Unblock(_ context.Context, userID, blockedUserID int64) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.blocks[userID] == nil {
return false, nil
}
_, existed := s.blocks[userID][blockedUserID]
delete(s.blocks[userID], blockedUserID)
return existed, nil
}
func (s *ContactStore) IsBlocked(_ context.Context, userID, blockedUserID int64) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
_, blocked := s.blocks[userID][blockedUserID]
return blocked, nil
}
func (s *ContactStore) ListBlocked(_ context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]domain.BlockedContact, 0, len(s.blocks[userID]))
for _, item := range s.blocks[userID] {
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].Date == items[j].Date {
return items[i].User.ID > items[j].User.ID
}
return items[i].Date > items[j].Date
})
total := len(items)
if offset < 0 {
offset = 0
}
if offset >= len(items) {
return domain.BlockedContactList{Count: total}, nil
}
if limit <= 0 || limit > len(items)-offset {
limit = len(items) - offset
}
out := append([]domain.BlockedContact(nil), items[offset:offset+limit]...)
return domain.BlockedContactList{Blocked: out, Count: total}, nil
}
// SaveList 保存一份用户通讯录,供测试和本地替身使用。
func (s *ContactStore) SaveList(_ context.Context, userID int64, list domain.ContactList) error {
list.Contacts = cloneContacts(list.Contacts)
@ -956,21 +1022,30 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
Forward: cloneMessageForward(req.Forward),
Pts: s.nextPtsLocked(req.SenderUserID),
}
recipient := sender
if req.SenderUserID != req.RecipientUserID {
recipient := domain.Message{}
if req.SenderUserID == req.RecipientUserID {
recipient = sender
}
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
recipient = sender
recipient.ID = s.nextBoxIDLocked(req.RecipientUserID)
recipient.OwnerUserID = req.RecipientUserID
recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
recipient.Out = false
recipient.ReplyTo = cloneMessageReply(recipientReply)
recipient.Pts = s.nextPtsLocked(req.RecipientUserID)
recipient.MediaUnread = !req.Media.IsZero()
}
s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender)
if req.SenderUserID != req.RecipientUserID {
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient)
}
if s.dialogs != nil {
s.upsertMemoryDialogsLocked(sender, recipient)
if recipient.ID != 0 {
s.upsertMemoryDialogsLocked(sender, recipient)
} else {
s.upsertMemoryDialogsLocked(sender, sender)
}
}
return domain.SendPrivateTextResult{
SenderMessage: cloneMessage(sender),
@ -1078,18 +1153,19 @@ func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.Fo
}
}
sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.OwnerUserID,
RecipientUserID: req.ToUserID,
RandomID: req.RandomIDs[i],
Message: source.Body,
Entities: append([]domain.MessageEntity(nil), source.Entities...),
Silent: req.Silent,
NoForwards: req.NoForwards,
ReplyTo: req.ReplyTo,
Forward: forward,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
SenderUserID: req.OwnerUserID,
RecipientUserID: req.ToUserID,
RandomID: req.RandomIDs[i],
Message: source.Body,
Entities: append([]domain.MessageEntity(nil), source.Entities...),
Silent: req.Silent,
NoForwards: req.NoForwards,
ReplyTo: req.ReplyTo,
Forward: forward,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
RecipientBlocked: req.RecipientBlocked,
})
if err != nil {
return res, err
@ -1277,14 +1353,52 @@ func (s *MessageStore) ReadMessageContents(_ context.Context, req domain.ReadMes
if len(wanted) == 0 {
return res, nil
}
s.mu.RLock()
for _, msg := range s.m[req.OwnerUserID] {
if _, ok := wanted[msg.ID]; ok {
res.MessageIDs = append(res.MessageIDs, msg.ID)
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
s.mu.Lock()
defer s.mu.Unlock()
affectedPeers := make(map[domain.Peer]struct{})
for i := range s.m[req.OwnerUserID] {
msg := &s.m[req.OwnerUserID][i]
if _, ok := wanted[msg.ID]; !ok {
continue
}
if !msg.MediaUnread && !msg.ReactionUnread {
continue
}
if msg.ReactionUnread && msg.Peer.ID != 0 {
affectedPeers[msg.Peer] = struct{}{}
}
msg.MediaUnread = false
msg.ReactionUnread = false
res.MessageIDs = append(res.MessageIDs, msg.ID)
}
s.mu.RUnlock()
sort.Ints(res.MessageIDs)
if len(res.MessageIDs) == 0 {
return res, nil
}
if s.dialogs != nil && len(affectedPeers) > 0 {
s.dialogs.mu.Lock()
list := s.dialogs.m[req.OwnerUserID]
for i := range list.Dialogs {
if _, ok := affectedPeers[list.Dialogs[i].Peer]; !ok {
continue
}
list.Dialogs[i].UnreadReactions = s.countPrivateUnreadReactionsLocked(req.OwnerUserID, list.Dialogs[i].Peer)
}
s.dialogs.m[req.OwnerUserID] = list
s.dialogs.mu.Unlock()
}
pts := s.nextPtsNLocked(req.OwnerUserID, len(res.MessageIDs))
res.Event = domain.UpdateEvent{
UserID: req.OwnerUserID,
Type: domain.UpdateEventReadMessageContents,
Pts: pts,
PtsCount: len(res.MessageIDs),
Date: req.Date,
MessageIDs: append([]int(nil), res.MessageIDs...),
}
return res, nil
}
@ -1355,6 +1469,27 @@ func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPriv
} else {
s.privateReactions[target.UID][req.UserID] = rows
}
if target.From.ID != 0 && target.From.ID != req.UserID {
for i := range s.m[target.From.ID] {
if s.m[target.From.ID][i].UID != target.UID {
continue
}
s.m[target.From.ID][i].ReactionUnread = len(rows) > 0
if s.dialogs != nil {
s.dialogs.mu.Lock()
list := s.dialogs.m[target.From.ID]
peer := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
for j := range list.Dialogs {
if list.Dialogs[j].Peer == peer {
list.Dialogs[j].UnreadReactions = s.countPrivateUnreadReactionsLocked(target.From.ID, peer)
}
}
s.dialogs.m[target.From.ID] = list
s.dialogs.mu.Unlock()
}
break
}
}
return s.privateReactionResultLocked(target.UID), nil
}
@ -1585,6 +1720,16 @@ func (s *MessageStore) nextPtsNLocked(userID int64, count int) int {
return next
}
func (s *MessageStore) countPrivateUnreadReactionsLocked(ownerUserID int64, peer domain.Peer) int {
count := 0
for _, msg := range s.m[ownerUserID] {
if msg.Peer == peer && msg.ReactionUnread {
count++
}
}
return count
}
func (s *MessageStore) upsertMemoryDialogsLocked(sender, recipient domain.Message) {
s.dialogs.mu.Lock()
defer s.dialogs.mu.Unlock()

View file

@ -120,6 +120,31 @@ func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing
if got := aliceReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || !got[0].Big || got[0].My {
t.Fatalf("alice recent reactions = %+v, want bob non-my big reaction", got)
}
aliceBox, err := messages.GetByIDs(ctx, aliceID, []int{sent.SenderMessage.ID})
if err != nil {
t.Fatalf("alice GetByIDs after reaction: %v", err)
}
if len(aliceBox.Messages) != 1 || !aliceBox.Messages[0].ReactionUnread {
t.Fatalf("alice box after reaction = %+v, want reaction_unread", aliceBox.Messages)
}
read, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: aliceID,
IDs: []int{sent.SenderMessage.ID},
Date: 1700000210,
})
if err != nil {
t.Fatalf("ReadMessageContents reaction: %v", err)
}
if !reflect.DeepEqual(read.MessageIDs, []int{sent.SenderMessage.ID}) || read.Event.Type != domain.UpdateEventReadMessageContents || read.Event.Pts == 0 {
t.Fatalf("read reaction contents = %+v, want one read_message_contents event", read)
}
aliceBox, err = messages.GetByIDs(ctx, aliceID, []int{sent.SenderMessage.ID})
if err != nil {
t.Fatalf("alice GetByIDs after read reaction: %v", err)
}
if len(aliceBox.Messages) != 1 || aliceBox.Messages[0].ReactionUnread {
t.Fatalf("alice box after read reaction = %+v, want reaction_unread cleared", aliceBox.Messages)
}
bobReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{
OwnerUserID: bobID,
@ -344,7 +369,7 @@ func TestMessageStoreReadHistoryEmitsInboxAndOutboxReceipts(t *testing.T) {
}
}
func TestMessageStoreReadMessageContentsReturnsExistingOwnerIDs(t *testing.T) {
func TestMessageStoreReadMessageContentsClearsUnreadContentOnce(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
@ -352,20 +377,39 @@ func TestMessageStoreReadMessageContentsReturnsExistingOwnerIDs(t *testing.T) {
RecipientUserID: 1002,
RandomID: 88,
Message: "voice placeholder",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Voice: true},
Date: 1700000300,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if !sent.RecipientMessage.MediaUnread {
t.Fatalf("recipient MediaUnread = false, want true for incoming media")
}
got, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{sent.RecipientMessage.ID, domain.MaxMessageBoxID},
Date: 1700000400,
})
if err != nil {
t.Fatalf("ReadMessageContents: %v", err)
}
if !reflect.DeepEqual(got.MessageIDs, []int{sent.RecipientMessage.ID}) {
t.Fatalf("MessageIDs = %v, want existing recipient id", got.MessageIDs)
t.Fatalf("MessageIDs = %v, want unread recipient id", got.MessageIDs)
}
if got.Event.Type != domain.UpdateEventReadMessageContents || got.Event.Pts == 0 || got.Event.PtsCount != 1 {
t.Fatalf("Event = %+v, want read_message_contents pts update", got.Event)
}
repeated, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{sent.RecipientMessage.ID},
Date: 1700000500,
})
if err != nil {
t.Fatalf("ReadMessageContents repeat: %v", err)
}
if len(repeated.MessageIDs) != 0 || repeated.Event.Pts != 0 {
t.Fatalf("repeat = %+v, want no affected messages and no pts", repeated)
}
if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,

View file

@ -6570,6 +6570,9 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
if err := rows.Err(); err != nil {
return domain.ChannelDifference{}, err
}
if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil {
return domain.ChannelDifference{}, err
}
if preview {
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
} else {
@ -6634,6 +6637,19 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
} else if lastPts > diff.Pts {
diff.Pts = lastPts
}
if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil {
return domain.ChannelDifference{}, err
}
for i := range diff.OtherUpdates {
if diff.OtherUpdates[i].Message.ID == 0 {
continue
}
messages := []domain.ChannelMessage{diff.OtherUpdates[i].Message}
if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, messages); err != nil {
return domain.ChannelDifference{}, err
}
diff.OtherUpdates[i].Message = messages[0]
}
users, err := listUsersByIDs(ctx, s.db, mapKeysInt64(userRefs))
if err != nil {
return domain.ChannelDifference{}, err
@ -6687,6 +6703,45 @@ LIMIT $3`, userID, afterChannelID, limit)
return out, rows.Err()
}
func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) {
if userID == 0 || sinceDate <= 0 || afterChannelID < 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
limit = domain.MaxChannelDifferenceLimit
}
rows, err := s.db.Query(ctx, `
SELECT i.channel_id, c.pts
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
AND EXISTS (
SELECT 1
FROM channel_update_events e
WHERE e.channel_id = i.channel_id
AND e.date > $2
LIMIT 1
)
ORDER BY i.channel_id ASC
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
if err != nil {
return nil, fmt.Errorf("list dirty active channels for user: %w", err)
}
defer rows.Close()
out := make([]domain.DirtyChannel, 0, limit)
for rows.Next() {
var item domain.DirtyChannel
if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil {
return nil, err
@ -7119,6 +7174,9 @@ func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db
if len(messages) == 0 {
return nil
}
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil {
return err
}
channelsByID := make(map[int64]domain.Channel, len(channels))
for _, ch := range channels {
if ch.ID != 0 {
@ -7230,6 +7288,54 @@ ORDER BY message_id ASC, reaction_date DESC, reacted_user_id DESC, reaction_valu
return nil
}
func populateChannelMessageUnreadFlags(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.ChannelMessage) error {
if viewerUserID == 0 || len(messages) == 0 {
return nil
}
indexes := make(map[channelReactionMessageKey][]int)
idsByChannel := make(map[int64][]int32)
for i := range messages {
if messages[i].ChannelID == 0 || messages[i].ID <= 0 {
continue
}
key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID}
if _, ok := indexes[key]; !ok {
idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID))
}
indexes[key] = append(indexes[key], i)
}
for channelID, ids := range idsByChannel {
rows, err := db.Query(ctx, `
SELECT message_id, COALESCE(media_unread, false)
FROM channel_unread_mentions
WHERE user_id = $1
AND channel_id = $2
AND message_id = ANY($3::int[])`, viewerUserID, channelID, ids)
if err != nil {
return fmt.Errorf("load channel message unread flags: %w", err)
}
for rows.Next() {
var messageID int
var mediaUnread bool
if err := rows.Scan(&messageID, &mediaUnread); err != nil {
rows.Close()
return err
}
key := channelReactionMessageKey{channelID: channelID, messageID: messageID}
for _, idx := range indexes[key] {
messages[idx].Mentioned = true
messages[idx].MediaUnread = mediaUnread
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
}
return nil
}
func channelReactionOffset(row domain.ChannelMessagePeerReaction) string {
return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + row.Reaction.Emoticon
}
@ -8611,6 +8717,7 @@ func insertChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int
candidates = candidates[:domain.MaxChannelMentionRecipients]
}
topID := channelMentionTopID(msg)
mediaUnread := !msg.Media.IsZero()
if _, err := tx.Exec(ctx, `
WITH input(user_id) AS (
SELECT DISTINCT unnest($4::bigint[])
@ -8626,8 +8733,8 @@ active AS (
LIMIT $6
),
inserted AS (
INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id)
SELECT user_id, $1, $2, $3
INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id, media_unread)
SELECT user_id, $1, $2, $3, $7
FROM active
ON CONFLICT DO NOTHING
RETURNING user_id
@ -8641,7 +8748,7 @@ ON CONFLICT (user_id, channel_id) DO UPDATE SET
top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id),
top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date),
unread_mentions_count = channel_dialogs.unread_mentions_count + 1,
updated_at = now()`, channelID, msg.ID, topID, candidates, msg.Date, domain.MaxChannelMentionRecipients); err != nil {
updated_at = now()`, channelID, msg.ID, topID, candidates, msg.Date, domain.MaxChannelMentionRecipients, mediaUnread); err != nil {
return fmt.Errorf("insert channel unread mentions: %w", err)
}
return nil

View file

@ -366,6 +366,118 @@ func contactFromFields(id, accessHash int64, phone, firstName, lastName, usernam
}
}
func (s *ContactStore) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) {
if userID == 0 || blockedUserID == 0 || userID == blockedUserID {
return false, nil
}
tag, err := s.db.Exec(ctx, `
INSERT INTO contact_blocks (owner_user_id, blocked_user_id, date)
VALUES ($1, $2, $3)
ON CONFLICT (owner_user_id, blocked_user_id) DO UPDATE SET
date = EXCLUDED.date,
created_at = contact_blocks.created_at`, userID, blockedUserID, date)
if err != nil {
return false, fmt.Errorf("block contact: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *ContactStore) Unblock(ctx context.Context, userID, blockedUserID int64) (bool, error) {
if userID == 0 || blockedUserID == 0 {
return false, nil
}
tag, err := s.db.Exec(ctx, `
DELETE FROM contact_blocks
WHERE owner_user_id = $1
AND blocked_user_id = $2`, userID, blockedUserID)
if err != nil {
return false, fmt.Errorf("unblock contact: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *ContactStore) IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error) {
if userID == 0 || blockedUserID == 0 {
return false, nil
}
var blocked bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM contact_blocks
WHERE owner_user_id = $1
AND blocked_user_id = $2
)`, userID, blockedUserID).Scan(&blocked); err != nil {
return false, fmt.Errorf("check contact block: %w", err)
}
return blocked, nil
}
func (s *ContactStore) ListBlocked(ctx context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) {
if userID == 0 {
return domain.BlockedContactList{}, nil
}
if offset < 0 {
offset = 0
}
if limit <= 0 || limit > 100 {
limit = 100
}
var count int
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM contact_blocks
WHERE owner_user_id = $1`, userID).Scan(&count); err != nil {
return domain.BlockedContactList{}, fmt.Errorf("count blocked contacts: %w", err)
}
rows, err := s.db.Query(ctx, `
SELECT
b.blocked_user_id,
b.date,
u.access_hash,
u.phone,
u.first_name,
u.last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contact_blocks b
JOIN users u ON u.id = b.blocked_user_id
WHERE b.owner_user_id = $1
ORDER BY b.date DESC, b.blocked_user_id DESC
OFFSET $2
LIMIT $3`, userID, offset, limit)
if err != nil {
return domain.BlockedContactList{}, fmt.Errorf("list blocked contacts: %w", err)
}
defer rows.Close()
out := domain.BlockedContactList{Count: count, Blocked: make([]domain.BlockedContact, 0, limit)}
for rows.Next() {
var item domain.BlockedContact
var lastSeen int64
if err := rows.Scan(
&item.User.ID,
&item.Date,
&item.User.AccessHash,
&item.User.Phone,
&item.User.FirstName,
&item.User.LastName,
&item.User.Username,
&item.User.CountryCode,
&item.User.Verified,
&item.User.Support,
&lastSeen,
); err != nil {
return domain.BlockedContactList{}, err
}
item.User.LastSeenAt = int(lastSeen)
out.Blocked = append(out.Blocked, item)
}
return out, rows.Err()
}
func contactListHash(contacts []domain.Contact) int64 {
if len(contacts) == 0 {
return 0

View file

@ -175,7 +175,8 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
var recipientBoxID, recipientPts int
selfMessage := req.RecipientUserID == req.SenderUserID
if !selfMessage {
deliverRecipient := !selfMessage && !req.RecipientBlocked
if deliverRecipient {
recipientBoxID, err = s.boxIDs.NextBoxID(ctx, req.RecipientUserID)
if err != nil {
s.recordPtsGaps(ctx, reserved, req.Date)
@ -249,6 +250,8 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
EntitiesJson: entities,
Pts: int32(senderPts),
MediaJson: mediaJSON,
MediaUnread: false,
ReactionUnread: false,
}
applyCreateMessageBoxMetadata(&senderArg, senderMeta)
senderRow, err := qtx.CreateMessageBox(ctx, senderArg)
@ -278,8 +281,11 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue sender dispatch: %w", err)
}
recipient := sender
if !selfMessage {
recipient := domain.Message{}
if selfMessage {
recipient = sender
}
if deliverRecipient {
recipientArg := sqlcgen.CreateMessageBoxParams{
OwnerUserID: req.RecipientUserID,
BoxID: int32(recipientBoxID),
@ -294,6 +300,8 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
EntitiesJson: entities,
Pts: int32(recipientPts),
MediaJson: mediaJSON,
MediaUnread: !req.Media.IsZero(),
ReactionUnread: false,
}
applyCreateMessageBoxMetadata(&recipientArg, recipientMeta)
recipientRow, err := qtx.CreateMessageBox(ctx, recipientArg)
@ -352,13 +360,23 @@ func (s *MessageStore) duplicateSendResult(ctx context.Context, senderUserID, re
return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate sender box: %w", err)
}
sender := messageFromGetBoxRow(senderRow)
recipient := sender
recipient := domain.Message{}
if recipientUserID == senderUserID {
recipient = sender
}
if recipientUserID != senderUserID {
recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
OwnerUserID: recipientUserID,
PrivateMessageID: pm.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SendPrivateTextResult{
SenderMessage: sender,
SenderEvent: eventFromMessage(sender),
RecipientEvent: domain.UpdateEvent{},
}, nil
}
return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate recipient box: %w", err)
}
recipient = messageFromGetBoxRow(recipientRow)
@ -514,19 +532,20 @@ func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.Fo
}
}
sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.OwnerUserID,
RecipientUserID: req.ToUserID,
RandomID: req.RandomIDs[i],
Message: source.Body,
Entities: append([]domain.MessageEntity(nil), source.Entities...),
Media: source.Media,
Silent: req.Silent,
NoForwards: req.NoForwards,
ReplyTo: req.ReplyTo,
Forward: forward,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
SenderUserID: req.OwnerUserID,
RecipientUserID: req.ToUserID,
RandomID: req.RandomIDs[i],
Message: source.Body,
Entities: append([]domain.MessageEntity(nil), source.Entities...),
Media: source.Media,
Silent: req.Silent,
NoForwards: req.NoForwards,
ReplyTo: req.ReplyTo,
Forward: forward,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
RecipientBlocked: req.RecipientBlocked,
})
if err != nil {
return res, err
@ -640,22 +659,24 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
return domain.MessageList{}, fmt.Errorf("decode message media: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
Media: media,
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
})
if out.Count == 0 {
out.Count = int(row.TotalCount)
@ -832,6 +853,9 @@ func (s *MessageStore) ReadMessageContents(ctx context.Context, req domain.ReadM
if req.OwnerUserID == 0 {
return res, fmt.Errorf("read message contents: missing owner user id")
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if len(req.IDs) > domain.MaxGetMessageIDs {
return res, domain.ErrMessageIDInvalid
}
@ -850,27 +874,128 @@ func (s *MessageStore) ReadMessageContents(ctx context.Context, req domain.ReadM
if len(ids) == 0 {
return res, nil
}
rows, err := s.db.Query(ctx, `
SELECT box_id
FROM message_boxes
WHERE owner_user_id = $1
AND box_id = ANY($2::int[])
AND NOT deleted
beginner, ok := s.db.(txBeginner)
if !ok {
return res, fmt.Errorf("read message contents: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return res, fmt.Errorf("begin read message contents tx: %w", err)
}
qtx := sqlcgen.New(tx)
committed := false
var reserved []reservedPts
defer func() {
if committed {
return
}
_ = tx.Rollback(ctx)
s.recordPtsGaps(ctx, reserved, req.Date)
}()
if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil {
return res, fmt.Errorf("lock read message contents user: %w", err)
}
rows, err := tx.Query(ctx, `
WITH target AS (
SELECT owner_user_id, box_id, peer_type, peer_id, reaction_unread
FROM message_boxes
WHERE owner_user_id = $1
AND box_id = ANY($2::int[])
AND NOT deleted
AND (media_unread OR reaction_unread)
FOR UPDATE
),
updated AS (
UPDATE message_boxes
SET media_unread = false,
reaction_unread = false
FROM target t
WHERE message_boxes.owner_user_id = t.owner_user_id
AND message_boxes.box_id = t.box_id
RETURNING message_boxes.box_id, t.peer_type, t.peer_id, t.reaction_unread
)
SELECT box_id, peer_type, peer_id, reaction_unread
FROM updated
ORDER BY box_id`, req.OwnerUserID, ids)
if err != nil {
return res, fmt.Errorf("read message contents: %w", err)
}
defer rows.Close()
affectedPeers := make(map[domain.Peer]struct{})
for rows.Next() {
var id int32
if err := rows.Scan(&id); err != nil {
var peerType string
var peerID int64
var reactionUnread bool
if err := rows.Scan(&id, &peerType, &peerID, &reactionUnread); err != nil {
return res, fmt.Errorf("scan read message contents: %w", err)
}
res.MessageIDs = append(res.MessageIDs, int(id))
if reactionUnread && peerID != 0 {
affectedPeers[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return res, fmt.Errorf("read message contents rows: %w", err)
}
if len(res.MessageIDs) == 0 {
if err := tx.Commit(ctx); err != nil {
return res, fmt.Errorf("commit read message contents noop: %w", err)
}
committed = true
return res, nil
}
for peer := range affectedPeers {
if peer.Type != domain.PeerTypeUser || peer.ID == 0 {
continue
}
if _, err := tx.Exec(ctx, `
UPDATE dialogs d
SET unread_reactions_count = (
SELECT COUNT(*)::int
FROM message_boxes m
WHERE m.owner_user_id = d.user_id
AND m.peer_type = d.peer_type
AND m.peer_id = d.peer_id
AND NOT m.deleted
AND m.reaction_unread
),
updated_at = now()
WHERE d.user_id = $1
AND d.peer_type = $2
AND d.peer_id = $3`, req.OwnerUserID, string(peer.Type), peer.ID); err != nil {
return res, fmt.Errorf("refresh dialog unread reactions after content read: %w", err)
}
}
pts, err := s.nextPtsN(ctx, req.OwnerUserID, len(res.MessageIDs))
if err != nil {
return res, fmt.Errorf("allocate read message contents pts: %w", err)
}
reserved = append(reserved, reservedPts{userID: req.OwnerUserID, pts: pts, count: len(res.MessageIDs)})
res.Event = domain.UpdateEvent{
UserID: req.OwnerUserID,
Type: domain.UpdateEventReadMessageContents,
Pts: pts,
PtsCount: len(res.MessageIDs),
Date: req.Date,
MessageIDs: append([]int(nil), res.MessageIDs...),
}
if err := appendUserUpdateEvent(ctx, qtx, req.OwnerUserID, res.Event); err != nil {
return res, fmt.Errorf("append read message contents event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.OwnerUserID,
Pts: int32(pts),
EventType: string(domain.UpdateEventReadMessageContents),
ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID),
ExcludeSessionID: req.OriginSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue read message contents dispatch: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return res, fmt.Errorf("commit read message contents tx: %w", err)
}
committed = true
return res, nil
}
@ -996,6 +1121,39 @@ DO UPDATE SET
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("insert message reaction: %w", err)
}
}
if target.messageSenderID != 0 && target.messageSenderID != req.UserID {
if _, err := tx.Exec(ctx, `
UPDATE message_boxes b
SET reaction_unread = EXISTS (
SELECT 1
FROM private_message_reactions r
WHERE r.message_sender_id = b.message_sender_id
AND r.private_message_id = b.private_message_id
AND r.user_id <> b.owner_user_id
)
WHERE b.owner_user_id = $1
AND b.message_sender_id = $2
AND b.private_message_id = $3`, target.messageSenderID, target.messageSenderID, target.privateMessageID); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("update private reaction unread: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE dialogs d
SET unread_reactions_count = (
SELECT COUNT(*)::int
FROM message_boxes m
WHERE m.owner_user_id = d.user_id
AND m.peer_type = d.peer_type
AND m.peer_id = d.peer_id
AND NOT m.deleted
AND m.reaction_unread
),
updated_at = now()
WHERE d.user_id = $1
AND d.peer_type = $2
AND d.peer_id = $3`, target.messageSenderID, string(domain.PeerTypeUser), req.UserID); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("refresh private reaction unread dialog: %w", err)
}
}
boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
MessageSenderID: target.messageSenderID,
@ -1911,22 +2069,24 @@ func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message {
row.FwdDate,
)
return domain.Message{
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
}
}
@ -1949,22 +2109,24 @@ func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.M
row.FwdDate,
)
return domain.Message{
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
}
}
@ -1996,22 +2158,24 @@ func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessag
return domain.Message{}, fmt.Errorf("decode visible message media: %w", err)
}
return domain.Message{
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
}, nil
}
@ -2043,22 +2207,24 @@ func messageFromUpdateEditRow(row sqlcgen.UpdateMessageBoxEditRow) (domain.Messa
return domain.Message{}, fmt.Errorf("decode edited message media: %w", err)
}
return domain.Message{
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
}, nil
}
@ -2090,22 +2256,24 @@ func messageFromForwardRow(row sqlcgen.GetMessageBoxesForForwardRow) (domain.Mes
return domain.Message{}, fmt.Errorf("decode forward message media: %w", err)
}
return domain.Message{
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
}, nil
}
@ -2137,22 +2305,24 @@ func messageFromIDRow(row sqlcgen.GetMessageBoxesByIDsRow) (domain.Message, erro
return domain.Message{}, fmt.Errorf("decode message media: %w", err)
}
return domain.Message{
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
Media: media,
ID: int(row.BoxID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Pts: int(row.Pts),
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
}, nil
}

View file

@ -167,7 +167,9 @@ INSERT INTO message_boxes (
fwd_from_name,
fwd_date,
pts,
media
media,
media_unread,
reaction_unread
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, sqlc.arg(entities_json)::jsonb,
sqlc.arg(silent)::boolean,
@ -184,7 +186,9 @@ INSERT INTO message_boxes (
sqlc.arg(fwd_from_name)::text,
sqlc.arg(fwd_date)::int,
sqlc.arg(pts)::int,
sqlc.arg(media_json)::jsonb
sqlc.arg(media_json)::jsonb,
sqlc.arg(media_unread)::boolean,
sqlc.arg(reaction_unread)::boolean
)
RETURNING
box_id,
@ -212,7 +216,9 @@ RETURNING
fwd_from_name,
fwd_date,
pts,
media::text AS media_json;
media::text AS media_json,
media_unread,
reaction_unread;
-- name: GetMessageBoxByPrivateMessage :one
SELECT
@ -241,7 +247,9 @@ SELECT
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
media::text AS media_json,
media_unread,
reaction_unread
FROM message_boxes
WHERE owner_user_id = $1
AND private_message_id = $2
@ -295,7 +303,9 @@ SELECT
m.fwd_from_name,
m.fwd_date,
m.pts,
m.media::text AS media_json
m.media::text AS media_json,
m.media_unread,
m.reaction_unread
FROM requested r
JOIN message_boxes m
ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint
@ -351,6 +361,8 @@ base AS NOT MATERIALIZED (
m.fwd_date,
m.pts,
m.media::text AS media_json,
m.media_unread,
m.reaction_unread,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
@ -483,6 +495,8 @@ SELECT
fwd_date,
pts,
media_json,
media_unread,
reaction_unread,
peer_user_id,
peer_access_hash,
peer_phone,
@ -537,6 +551,8 @@ SELECT
m.fwd_date,
m.pts,
m.media::text AS media_json,
m.media_unread,
m.reaction_unread,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
@ -594,7 +610,9 @@ SELECT
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
media::text AS media_json,
media_unread,
reaction_unread
FROM message_boxes
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND box_id = sqlc.arg(box_id)::int
@ -632,7 +650,9 @@ SELECT
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
media::text AS media_json,
media_unread,
reaction_unread
FROM message_boxes
WHERE message_sender_id = sqlc.arg(message_sender_id)::bigint
AND private_message_id = sqlc.arg(private_message_id)::bigint
@ -684,7 +704,9 @@ RETURNING
fwd_from_name,
fwd_date,
pts,
media::text AS media_json;
media::text AS media_json,
media_unread,
reaction_unread;
-- name: GetDialogReadStateForUpdate :one
SELECT

View file

@ -87,6 +87,8 @@ SELECT
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
COALESCE(m.fwd_date, 0)::int AS fwd_date,
COALESCE(m.media::text, '{}')::text AS media_json,
COALESCE(m.media_unread, false)::boolean AS media_unread,
COALESCE(m.reaction_unread, false)::boolean AS reaction_unread,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
@ -341,6 +343,8 @@ SELECT
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
COALESCE(m.fwd_date, 0)::int AS fwd_date,
COALESCE(m.media::text, '{}')::text AS media_json,
COALESCE(m.media_unread, false)::boolean AS media_unread,
COALESCE(m.reaction_unread, false)::boolean AS reaction_unread,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,

View file

@ -173,7 +173,9 @@ INSERT INTO message_boxes (
fwd_from_name,
fwd_date,
pts,
media
media,
media_unread,
reaction_unread
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb,
$12::boolean,
@ -190,7 +192,9 @@ INSERT INTO message_boxes (
$23::text,
$24::int,
$25::int,
$26::jsonb
$26::jsonb,
$27::boolean,
$28::boolean
)
RETURNING
box_id,
@ -218,7 +222,9 @@ RETURNING
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
media::text AS media_json,
media_unread,
reaction_unread
`
type CreateMessageBoxParams struct {
@ -248,6 +254,8 @@ type CreateMessageBoxParams struct {
FwdDate int32
Pts int32
MediaJson []byte
MediaUnread bool
ReactionUnread bool
}
type CreateMessageBoxRow struct {
@ -277,6 +285,8 @@ type CreateMessageBoxRow struct {
FwdDate int32
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
}
func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxParams) (CreateMessageBoxRow, error) {
@ -307,6 +317,8 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara
arg.FwdDate,
arg.Pts,
arg.MediaJson,
arg.MediaUnread,
arg.ReactionUnread,
)
var i CreateMessageBoxRow
err := row.Scan(
@ -336,6 +348,8 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara
&i.FwdDate,
&i.Pts,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
)
return i, err
}
@ -845,7 +859,9 @@ SELECT
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
media::text AS media_json,
media_unread,
reaction_unread
FROM message_boxes
WHERE owner_user_id = $1
AND private_message_id = $2
@ -884,6 +900,8 @@ type GetMessageBoxByPrivateMessageRow struct {
FwdDate int32
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
}
func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMessageBoxByPrivateMessageParams) (GetMessageBoxByPrivateMessageRow, error) {
@ -916,6 +934,8 @@ func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMess
&i.FwdDate,
&i.Pts,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
)
return i, err
}
@ -948,7 +968,9 @@ SELECT
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
media::text AS media_json,
media_unread,
reaction_unread
FROM message_boxes
WHERE owner_user_id = $1::bigint
AND box_id = $2::int
@ -994,6 +1016,8 @@ type GetMessageBoxForEditRow struct {
FwdDate int32
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
}
func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxForEditParams) (GetMessageBoxForEditRow, error) {
@ -1032,6 +1056,8 @@ func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxFor
&i.FwdDate,
&i.Pts,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
)
return i, err
}
@ -1104,6 +1130,8 @@ SELECT
m.fwd_date,
m.pts,
m.media::text AS media_json,
m.media_unread,
m.reaction_unread,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
@ -1167,6 +1195,8 @@ type GetMessageBoxesByIDsRow struct {
FwdDate int32
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
PeerUserID int64
PeerAccessHash int64
PeerPhone string
@ -1226,6 +1256,8 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB
&i.FwdDate,
&i.Pts,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
&i.PeerUserID,
&i.PeerAccessHash,
&i.PeerPhone,
@ -1292,7 +1324,9 @@ SELECT
m.fwd_from_name,
m.fwd_date,
m.pts,
m.media::text AS media_json
m.media::text AS media_json,
m.media_unread,
m.reaction_unread
FROM requested r
JOIN message_boxes m
ON m.owner_user_id = $1::bigint
@ -1339,6 +1373,8 @@ type GetMessageBoxesForForwardRow struct {
FwdDate int32
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
}
func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageBoxesForForwardParams) ([]GetMessageBoxesForForwardRow, error) {
@ -1384,6 +1420,8 @@ func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageB
&i.FwdDate,
&i.Pts,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
); err != nil {
return nil, err
}
@ -1633,6 +1671,8 @@ base AS NOT MATERIALIZED (
m.fwd_date,
m.pts,
m.media::text AS media_json,
m.media_unread,
m.reaction_unread,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
@ -1675,7 +1715,7 @@ total AS (
WHERE $12::boolean
),
backward AS (
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'backward'
@ -1688,9 +1728,9 @@ backward AS (
LIMIT (SELECT limit_count FROM load_params)
),
around_forward AS (
SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.pts, f.media_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_last_seen_at
SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_last_seen_at
FROM (
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'around'
@ -1703,7 +1743,7 @@ around_forward AS (
) f
),
around_backward AS (
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'around'
@ -1715,9 +1755,9 @@ around_backward AS (
LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0)
),
forward AS (
SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.pts, f.media_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_last_seen_at
SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_last_seen_at
FROM (
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'forward'
@ -1730,13 +1770,13 @@ forward AS (
) f
),
paged AS (
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM backward
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, media_unread, reaction_unread, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM backward
UNION ALL
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM around_forward
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, media_unread, reaction_unread, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM around_forward
UNION ALL
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM around_backward
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, media_unread, reaction_unread, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM around_backward
UNION ALL
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM forward
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, media_unread, reaction_unread, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM forward
)
SELECT
box_id,
@ -1765,6 +1805,8 @@ SELECT
fwd_date,
pts,
media_json,
media_unread,
reaction_unread,
peer_user_id,
peer_access_hash,
peer_phone,
@ -1833,6 +1875,8 @@ type ListMessagesByUserRow struct {
FwdDate int32
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
PeerUserID int64
PeerAccessHash int64
PeerPhone string
@ -1905,6 +1949,8 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
&i.FwdDate,
&i.Pts,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
&i.PeerUserID,
&i.PeerAccessHash,
&i.PeerPhone,
@ -1965,7 +2011,9 @@ SELECT
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
media::text AS media_json,
media_unread,
reaction_unread
FROM message_boxes
WHERE message_sender_id = $1::bigint
AND private_message_id = $2::bigint
@ -2007,6 +2055,8 @@ type ListVisibleMessageBoxesByPrivateMessageRow struct {
FwdDate int32
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
}
func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, arg ListVisibleMessageBoxesByPrivateMessageParams) ([]ListVisibleMessageBoxesByPrivateMessageRow, error) {
@ -2046,6 +2096,8 @@ func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, a
&i.FwdDate,
&i.Pts,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
); err != nil {
return nil, err
}
@ -2218,7 +2270,9 @@ RETURNING
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
media::text AS media_json,
media_unread,
reaction_unread
`
type UpdateMessageBoxEditParams struct {
@ -2258,6 +2312,8 @@ type UpdateMessageBoxEditRow struct {
FwdDate int32
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
}
func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBoxEditParams) (UpdateMessageBoxEditRow, error) {
@ -2298,6 +2354,8 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox
&i.FwdDate,
&i.Pts,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
)
return i, err
}

View file

@ -320,6 +320,7 @@ type ChannelUnreadMention struct {
MessageID int32
TopMessageID int32
CreatedAt pgtype.Timestamptz
MediaUnread bool
}
type ChannelUpdateEvent struct {
@ -357,6 +358,13 @@ type Contact struct {
StoriesHidden bool
}
type ContactBlock struct {
OwnerUserID int64
BlockedUserID int64
Date int32
CreatedAt pgtype.Timestamptz
}
type Country struct {
Iso2 string
DefaultName string
@ -514,6 +522,8 @@ type MessageBox struct {
FwdFromName string
FwdDate int32
Media []byte
MediaUnread bool
ReactionUnread bool
}
type Photo struct {

View file

@ -146,6 +146,8 @@ SELECT
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
COALESCE(m.fwd_date, 0)::int AS fwd_date,
COALESCE(m.media::text, '{}')::text AS media_json,
COALESCE(m.media_unread, false)::boolean AS media_unread,
COALESCE(m.reaction_unread, false)::boolean AS reaction_unread,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
@ -291,6 +293,8 @@ type BatchListDispatchEventsRow struct {
FwdFromName string
FwdDate int32
MediaJson string
MediaUnread bool
ReactionUnread bool
PeerUserID int64
PeerAccessHash int64
PeerPhone string
@ -432,6 +436,8 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
&i.FwdFromName,
&i.FwdDate,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
&i.PeerUserID,
&i.PeerAccessHash,
&i.PeerPhone,
@ -737,6 +743,8 @@ SELECT
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
COALESCE(m.fwd_date, 0)::int AS fwd_date,
COALESCE(m.media::text, '{}')::text AS media_json,
COALESCE(m.media_unread, false)::boolean AS media_unread,
COALESCE(m.reaction_unread, false)::boolean AS reaction_unread,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
@ -885,6 +893,8 @@ type ListUserUpdateEventsAfterRow struct {
FwdFromName string
FwdDate int32
MediaJson string
MediaUnread bool
ReactionUnread bool
PeerUserID int64
PeerAccessHash int64
PeerPhone string
@ -1024,6 +1034,8 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
&i.FwdFromName,
&i.FwdDate,
&i.MediaJson,
&i.MediaUnread,
&i.ReactionUnread,
&i.PeerUserID,
&i.PeerAccessHash,
&i.PeerPhone,

View file

@ -260,21 +260,23 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
FolderPeers: folderPeers,
TagsEnabled: row.TagsEnabled,
Message: domain.Message{
ID: int(row.MessageID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
ID: int(row.MessageID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
},
Users: usersFromUpdateEventRow(row),
Channels: channelsFromUpdateEventRow(row),
@ -519,21 +521,23 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
FolderPeers: folderPeers,
TagsEnabled: row.TagsEnabled,
Message: domain.Message{
ID: int(row.MessageID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
ID: int(row.MessageID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
},
Users: usersFromBatchDispatchRow(row),
Channels: channelsFromBatchDispatchRow(row),