diff --git a/deploy/migrations/0068_content_unread_and_blocks.down.sql b/deploy/migrations/0068_content_unread_and_blocks.down.sql new file mode 100644 index 00000000..c1096878 --- /dev/null +++ b/deploy/migrations/0068_content_unread_and_blocks.down.sql @@ -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; diff --git a/deploy/migrations/0068_content_unread_and_blocks.up.sql b/deploy/migrations/0068_content_unread_and_blocks.up.sql new file mode 100644 index 00000000..e0083861 --- /dev/null +++ b/deploy/migrations/0068_content_unread_and_blocks.up.sql @@ -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 $$; diff --git a/docs/channel-module.md b/docs/channel-module.md index 0bef22c5..4c3e3dc6 100644 --- a/docs/channel-module.md +++ b/docs/channel-module.md @@ -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 broadcast,TDesktop 通过 `Channel.has_link` 和 `ChannelFull.linked_chat_id` 刷新讨论组入口。linked broadcast 发新 post 时会在 discussion megagroup 创建一条 forwarded root message,source 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 index,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 message;web 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 index,history/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 message;web 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-state;channel 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` 的操作必须一次性分配连续 range,PG 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 state;broadcast invite/join/leave 不占 channel pts,但仍带 viewer-specific chats 与 `updateChannel`。 + ## Create Flow `messages.createChat`: diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md index f38116dc..43f467e2 100644 --- a/docs/compatibility-matrix.md +++ b/docs/compatibility-matrix.md @@ -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 peer,channel 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/chats,payload 来自 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 peer,channel 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/chats,payload 来自 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 相册聚合留 todo);geo/contact/poll/todo/dice/story 等仍返回 `MEDIA_INVALID`;album cap=10,caption/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 seek,limit 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 seek,limit 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=100,offset<=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_SHORT,limit 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 peer;channel 返回 PeerChannel + Chats;不存在返回 USERNAME_NOT_OCCUPIED,非法格式返回 USERNAME_INVALID | | contacts.resolvePhone | done | real-partial | 按手机号解析 user peer;当前阶段未接完整 privacy,默认已知手机号可解析,未命中返回 PHONE_NOT_OCCUPIED | -| contacts.getBlocked | stub | empty | Settings 隐私/安全预取;第一阶段无 blocklist,limit cap=50 | +| contacts.block | done | real-blocklist | 写入当前 owner blocklist,幂等;同步刷新 peer settings,story-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` 唯一 blocklist,limit 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 peers,TDesktop 搜索框分支返回 sponsoredPeersEmpty | | users.getUsers | done | real | InputUserSelf 与已知 InputUser 返回用户(含 777000 官方账号);未登录则跳过(空列表) | diff --git a/docs/message-module.md b/docs/message-module.md index cd9b69de..20e54d19 100644 --- a/docs/message-module.md +++ b/docs/message-module.md @@ -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 已分配 pts,store 会尽力写 `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 pagination;folder_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`。 \ No newline at end of file +- 范围外:grouped_id 相册聚合(sendMultiMedia 当前各条独立成消息)、geo/contact/poll/todo/dice/story media 仍 `MEDIA_INVALID`。 diff --git a/docs/persistence-layer.md b/docs/persistence-layer.md index dcde1809..38983448 100644 --- a/docs/persistence-layer.md +++ b/docs/persistence-layer.md @@ -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_date;silent/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 outbox,edit/revoke delete 会返回 forbidden。—— ✅ ## 7. 与铁律的关系 diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go index a8514a89..24f0e262 100644 --- a/internal/app/channels/service.go +++ b/internal/app/channels/service.go @@ -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 { diff --git a/internal/app/channels/service_test.go b/internal/app/channels/service_test.go index 9e34a532..514ff74c 100644 --- a/internal/app/channels/service_test.go +++ b/internal/app/channels/service_test.go @@ -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) { diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go index 19b52967..237af230 100644 --- a/internal/app/contacts/service.go +++ b/internal/app/contacts/service.go @@ -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 { diff --git a/internal/domain/channel.go b/internal/domain/channel.go index 55548605..80d74a6d 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -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 diff --git a/internal/domain/contact.go b/internal/domain/contact.go index a2d67d3b..778ffbff 100644 --- a/internal/domain/contact.go +++ b/internal/domain/contact.go @@ -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 diff --git a/internal/domain/message.go b/internal/domain/message.go index 6462081c..fdb02809 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -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 查询。 diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go index 32ebdbf4..7ee4f437 100644 --- a/internal/domain/update_event.go +++ b/internal/domain/update_event.go @@ -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 +} diff --git a/internal/rpc/channels.go b/internal/rpc/channels.go index 0afff93e..3228f80c 100644 --- a/internal/rpc/channels.go +++ b/internal/rpc/channels.go @@ -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), diff --git a/internal/rpc/contacts.go b/internal/rpc/contacts.go index 7e3c7a3c..4f75edb6 100644 --- a/internal/rpc/contacts.go +++ b/internal/rpc/contacts.go @@ -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 diff --git a/internal/rpc/convert.go b/internal/rpc/convert.go index 01bc6211..d5ad29f3 100644 --- a/internal/rpc/convert.go +++ b/internal/rpc/convert.go @@ -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) diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 49c07bd8..0b913220 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -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) diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 986811ec..13a83a97 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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") } diff --git a/internal/rpc/messages.go b/internal/rpc/messages.go index 42bbe3e4..f8841bc7 100644 --- a/internal/rpc/messages.go +++ b/internal/rpc/messages.go @@ -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): diff --git a/internal/rpc/router_test.go b/internal/rpc/router_test.go index 7e87270c..8d50c761 100644 --- a/internal/rpc/router_test.go +++ b/internal/rpc/router_test.go @@ -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) diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index 634f8805..a463ee65 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -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) diff --git a/internal/rpc/updates.go b/internal/rpc/updates.go index 95e7cac3..b34c6c1a 100644 --- a/internal/rpc/updates.go +++ b/internal/rpc/updates.go @@ -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 diff --git a/internal/store/channel.go b/internal/store/channel.go index f73ab249..310a68c1 100644 --- a/internal/store/channel.go +++ b/internal/store/channel.go @@ -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) diff --git a/internal/store/contact.go b/internal/store/contact.go index 71e06c04..478c0087 100644 --- a/internal/store/contact.go +++ b/internal/store/contact.go @@ -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) } diff --git a/internal/store/memory/channel.go b/internal/store/memory/channel.go index b80c401f..2337c3ce 100644 --- a/internal/store/memory/channel.go +++ b/internal/store/memory/channel.go @@ -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 diff --git a/internal/store/memory/memory.go b/internal/store/memory/memory.go index 28860be1..683c1a6d 100644 --- a/internal/store/memory/memory.go +++ b/internal/store/memory/memory.go @@ -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() diff --git a/internal/store/memory/message_test.go b/internal/store/memory/message_test.go index 37dd776c..33b8bb1b 100644 --- a/internal/store/memory/message_test.go +++ b/internal/store/memory/message_test.go @@ -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, diff --git a/internal/store/postgres/channel.go b/internal/store/postgres/channel.go index 500499b3..ac218622 100644 --- a/internal/store/postgres/channel.go +++ b/internal/store/postgres/channel.go @@ -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 diff --git a/internal/store/postgres/contact.go b/internal/store/postgres/contact.go index a453b112..7b9dd660 100644 --- a/internal/store/postgres/contact.go +++ b/internal/store/postgres/contact.go @@ -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 diff --git a/internal/store/postgres/message.go b/internal/store/postgres/message.go index ce65e426..ab109e92 100644 --- a/internal/store/postgres/message.go +++ b/internal/store/postgres/message.go @@ -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 } diff --git a/internal/store/postgres/queries/message.sql b/internal/store/postgres/queries/message.sql index fe605787..6f4db751 100644 --- a/internal/store/postgres/queries/message.sql +++ b/internal/store/postgres/queries/message.sql @@ -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 diff --git a/internal/store/postgres/queries/user_update_event.sql b/internal/store/postgres/queries/user_update_event.sql index 824c4a3b..bbc6c2ac 100644 --- a/internal/store/postgres/queries/user_update_event.sql +++ b/internal/store/postgres/queries/user_update_event.sql @@ -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, diff --git a/internal/store/postgres/sqlcgen/message.sql.go b/internal/store/postgres/sqlcgen/message.sql.go index 2d68720d..90e89e21 100644 --- a/internal/store/postgres/sqlcgen/message.sql.go +++ b/internal/store/postgres/sqlcgen/message.sql.go @@ -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 } diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index c261b2e2..3228346f 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -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 { diff --git a/internal/store/postgres/sqlcgen/user_update_event.sql.go b/internal/store/postgres/sqlcgen/user_update_event.sql.go index d2dac885..fc9b29ab 100644 --- a/internal/store/postgres/sqlcgen/user_update_event.sql.go +++ b/internal/store/postgres/sqlcgen/user_update_event.sql.go @@ -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, diff --git a/internal/store/postgres/update_event.go b/internal/store/postgres/update_event.go index 89fb24cc..f3db0d5b 100644 --- a/internal/store/postgres/update_event.go +++ b/internal/store/postgres/update_event.go @@ -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),