fix: align private and channel update semantics
(cherry picked from commit c65f76f56278f74082c4fa792ed49104d5d33c38)
This commit is contained in:
parent
dce7b92772
commit
d84fa6e126
36 changed files with 1765 additions and 382 deletions
35
deploy/migrations/0068_content_unread_and_blocks.down.sql
Normal file
35
deploy/migrations/0068_content_unread_and_blocks.down.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
DROP TABLE IF EXISTS contact_blocks;
|
||||||
|
|
||||||
|
ALTER TABLE user_update_events
|
||||||
|
DROP CONSTRAINT IF EXISTS user_update_events_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE user_update_events
|
||||||
|
ADD CONSTRAINT user_update_events_type_check CHECK (
|
||||||
|
event_type IN (
|
||||||
|
'new_message',
|
||||||
|
'read_history_inbox',
|
||||||
|
'read_history_outbox',
|
||||||
|
'edit_message',
|
||||||
|
'message_reactions',
|
||||||
|
'contacts_reset',
|
||||||
|
'dialog_pinned',
|
||||||
|
'pinned_dialogs',
|
||||||
|
'dialog_unread_mark',
|
||||||
|
'peer_settings',
|
||||||
|
'delete_messages',
|
||||||
|
'dialog_filter',
|
||||||
|
'dialog_filter_order',
|
||||||
|
'dialog_filters',
|
||||||
|
'folder_peers',
|
||||||
|
'channel_available_messages',
|
||||||
|
'channel_view_forum_as_messages',
|
||||||
|
'noop'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE channel_unread_mentions
|
||||||
|
DROP COLUMN IF EXISTS media_unread;
|
||||||
|
|
||||||
|
ALTER TABLE message_boxes
|
||||||
|
DROP COLUMN IF EXISTS reaction_unread,
|
||||||
|
DROP COLUMN IF EXISTS media_unread;
|
||||||
58
deploy/migrations/0068_content_unread_and_blocks.up.sql
Normal file
58
deploy/migrations/0068_content_unread_and_blocks.up.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
ALTER TABLE message_boxes
|
||||||
|
ADD COLUMN IF NOT EXISTS media_unread BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS reaction_unread BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE channel_unread_mentions
|
||||||
|
ADD COLUMN IF NOT EXISTS media_unread BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE user_update_events
|
||||||
|
DROP CONSTRAINT IF EXISTS user_update_events_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE user_update_events
|
||||||
|
ADD CONSTRAINT user_update_events_type_check CHECK (
|
||||||
|
event_type IN (
|
||||||
|
'new_message',
|
||||||
|
'read_history_inbox',
|
||||||
|
'read_history_outbox',
|
||||||
|
'read_message_contents',
|
||||||
|
'edit_message',
|
||||||
|
'message_reactions',
|
||||||
|
'contacts_reset',
|
||||||
|
'dialog_pinned',
|
||||||
|
'pinned_dialogs',
|
||||||
|
'dialog_unread_mark',
|
||||||
|
'peer_settings',
|
||||||
|
'delete_messages',
|
||||||
|
'dialog_filter',
|
||||||
|
'dialog_filter_order',
|
||||||
|
'dialog_filters',
|
||||||
|
'folder_peers',
|
||||||
|
'channel_available_messages',
|
||||||
|
'channel_view_forum_as_messages',
|
||||||
|
'noop'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_blocks (
|
||||||
|
owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
blocked_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
date INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (owner_user_id, blocked_user_id)
|
||||||
|
) PARTITION BY HASH (owner_user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS contact_blocks_owner_date_idx
|
||||||
|
ON contact_blocks (owner_user_id, date DESC, blocked_user_id DESC);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
i int;
|
||||||
|
BEGIN
|
||||||
|
FOR i IN 0..63 LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE TABLE IF NOT EXISTS contact_blocks_p%s PARTITION OF contact_blocks FOR VALUES WITH (MODULUS 64, REMAINDER %s)',
|
||||||
|
lpad(i::text, 2, '0'),
|
||||||
|
i
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
@ -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` 设置或清除。
|
- `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` 已从该列表提升为真实实现。
|
- 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。
|
- `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。
|
- 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
|
## 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。
|
参考实现 的 `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”的路线,避免管理入口只能打开空页。
|
参考实现 的 `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.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.ReadInboxDate`:当前成员最后一次推进 `read_inbox_max_id` 的时间,用于 `messages.getMessageReadParticipants` 返回 `readParticipantDate.date`;不参与 channel pts。
|
||||||
- `ChannelMember.SlowmodeLastSendDate`:普通成员最近一次成功发言时间,用于服务端按 channel 维度返回 `SLOWMODE_WAIT_X`;creator/admin 不受首批 slowmode 限制。
|
- `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 翻页重复顶部频道或自定义分组漏掉旧频道。
|
- `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`。
|
- `ChannelUpdateEvent`:`ChannelID`、`Pts`、`PtsCount`、`Type`、`Date`、`MessageID`、`MessageIDs`、`SenderUserID`、`UserIDs`、`Payload`。
|
||||||
- `ChannelAdminLogEvent`:`ChannelID`、`ID`、`UserID`、`Date`、`Type`、前后字符串/布尔/整数、前后 participant、相关 message、`Query`;用于 `channels.getAdminLog`,不参与 channel pts。
|
- `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` |
|
| `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` |
|
| `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 |
|
| `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_update_events` | hash(`channel_id`) | channel pts durable log,供 `updates.getChannelDifference` |
|
||||||
| `channel_dialogs` | hash(`user_id`) | 当前账号对 channel 的 dialog/read/folder/pin/mute 摘要 |
|
| `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 |
|
| `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_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_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_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_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 的默认发送身份,不参与列表排序。
|
- `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.*`。
|
- `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。
|
分配必须使用 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
|
## Create Flow
|
||||||
|
|
||||||
`messages.createChat`:
|
`messages.createChat`:
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ status 取值:done(真实实现) / stub(兼容响应) / todo(已发现未实
|
||||||
| method | status | behavior | note |
|
| method | status | behavior | note |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| updates.getState | done | real | auth_key+user 维度持久化 update_states;账号级当前 pts 报告**最大连续已提交 pts**(MaxContiguousPts,非 allocator 最大已分配值),避免同设备换号/多账号串差分,也避免越过在途空洞 |
|
| 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 |
|
| 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)
|
### 兼容硬约束: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.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.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.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.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.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.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 表 |
|
| 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.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.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.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.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.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 限额策略 |
|
| 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.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.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.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.getTopPeers | stub | disabled | 第一阶段不维护 top peers 统计 |
|
||||||
| contacts.getSponsoredPeers | stub | empty | 第一阶段不做 sponsored peers,TDesktop 搜索框分支返回 sponsoredPeersEmpty |
|
| contacts.getSponsoredPeers | stub | empty | 第一阶段不做 sponsored peers,TDesktop 搜索框分支返回 sponsoredPeersEmpty |
|
||||||
| users.getUsers | done | real | InputUserSelf 与已知 InputUser 返回用户(含 777000 官方账号);未登录则跳过(空列表) |
|
| users.getUsers | done | real | InputUserSelf 与已知 InputUser 返回用户(含 777000 官方账号);未登录则跳过(空列表) |
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,9 @@ Date: 2026-05-31
|
||||||
| table | partition key | purpose |
|
| table | partition key | purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `private_messages` | `sender_user_id` HASH | 共享私聊消息主体;`sender_user_id + random_id` 保证发送幂等 |
|
| `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 视角 |
|
| `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 |
|
| `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 视角状态事件 |
|
| `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;投递成功即删除,仅保留未完成任务 |
|
| `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` 直接返回原消息盒。
|
1. 写 `private_messages`,遇到同 `sender_user_id + random_id` 直接返回原消息盒。
|
||||||
2. Redis 分配 sender/recipient 的 `box_id` 与 `pts`。
|
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`。
|
4. upsert 双方 `dialogs`。
|
||||||
5. 写双方 `user_update_events(new_message)`。
|
5. 写双方 `user_update_events(new_message)`。
|
||||||
6. 写双方 `dispatch_outbox`,sender 侧带 `exclude_session_id`。
|
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 不可用时该补偿也可能失败,后续需要告警指标覆盖。
|
若事务失败但 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
|
## Forward / Reply Flow
|
||||||
|
|
||||||
`messages.forwardMessages` 当前覆盖私聊文本转发,参考实现 的业务语义但保持 telesrv 的 owner 视角模型:
|
`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。
|
`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
|
## Edit Flow
|
||||||
|
|
||||||
`messages.editMessage` 当前只支持私聊文本编辑:
|
`messages.editMessage` 当前只支持私聊文本编辑:
|
||||||
|
|
@ -92,12 +105,16 @@ Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box
|
||||||
|
|
||||||
如果文本和 entities 完全未变化,返回 `MESSAGE_NOT_MODIFIED`;非作者编辑返回 `MESSAGE_AUTHOR_REQUIRED`。
|
如果文本和 entities 完全未变化,返回 `MESSAGE_NOT_MODIFIED`;非作者编辑返回 `MESSAGE_AUTHOR_REQUIRED`。
|
||||||
|
|
||||||
|
若 peer 已 block 当前用户,私聊 edit 会返回 `EDIT_MESSAGES_FORBIDDEN`,避免修改对方侧已存在的 message box。该 gate 只来自 `contact_blocks`;完整 Telegram privacy keys 暂不扩展。
|
||||||
|
|
||||||
## Delete Flow
|
## Delete Flow
|
||||||
|
|
||||||
`messages.deleteMessages` / `messages.deleteHistory` 以 owner 视角软删除 `message_boxes`,不会删除共享 `private_messages` 主体。默认 deleteHistory 清空后如果该 peer 已无可见消息,则删除当前 owner 的 dialog;后续任意新消息会通过正常 send/upsert 路径重建 dialog。`just_clear=true` 对齐 参考实现 语义:清空历史但保留一个空 dialog(当前阶段不生成 `messageActionHistoryClear` 服务消息)。
|
`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` 时按 `(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。
|
全清也必须让所有被删的 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
|
## 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.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.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 开始。
|
- `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
|
## 参考实现 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。
|
- `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 当成本服资源身份。
|
- `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<>'{}'`。
|
- 仅媒体消息(无 caption):放宽 `private_messages` body 非空 CHECK 为 `body<>'' OR media<>'{}'`。
|
||||||
- 范围外:grouped_id 相册聚合(sendMultiMedia 当前各条独立成消息)、geo/contact/poll/todo/dice/story media 仍 `MEDIA_INVALID`。
|
- 范围外:grouped_id 相册聚合(sendMultiMedia 当前各条独立成消息)、geo/contact/poll/todo/dice/story media 仍 `MEDIA_INVALID`。
|
||||||
|
|
|
||||||
|
|
@ -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 管理入口所需参数。
|
- **`app_configs`** —— `help.getAppConfig` 的 data-backed JSON config,包含 TDesktop read mark、quote reply 与 native anti-spam 管理入口所需参数。
|
||||||
- **`countries` / `country_codes`** —— `help.getCountriesList` 的登录页国家区号目录。
|
- **`countries` / `country_codes`** —— `help.getCountriesList` 的登录页国家区号目录。
|
||||||
- **`update_states`** —— `auth_key_id + user_id` 维度的设备状态快照。账号级 pts 以 `user_update_events` 为权威,设备退出/换号只清当前 auth_key 状态,不删除账号事件。
|
- **`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。
|
- **`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 元数据随消息持久化。
|
- **`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` 恢复。
|
- **`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,禁止按成员写扩散。
|
- **`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。
|
- **`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` 表达,避免一列同时承担归档状态和任意筛选规则。
|
- **`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。
|
- **`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.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.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.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. 与铁律的关系
|
## 7. 与铁律的关系
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1375,6 +1375,17 @@ func (s *Service) ActiveChannelIDsForUser(ctx context.Context, userID, afterChan
|
||||||
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
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.
|
// 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) {
|
func (s *Service) ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) {
|
||||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) {
|
||||||
ChannelID: created.Channel.ID,
|
ChannelID: created.Channel.ID,
|
||||||
RandomID: 9101,
|
RandomID: 9101,
|
||||||
Message: "hello @friend",
|
Message: "hello @friend",
|
||||||
|
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument},
|
||||||
MentionUserIDs: []int64{1002, 1002, 1001},
|
MentionUserIDs: []int64{1002, 1002, 1001},
|
||||||
Date: 1700000101,
|
Date: 1700000101,
|
||||||
})
|
})
|
||||||
|
|
@ -133,6 +134,31 @@ func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) {
|
||||||
if other.Dialog.UnreadMentions != 0 {
|
if other.Dialog.UnreadMentions != 0 {
|
||||||
t.Fatalf("unmentioned dialog unread mentions = %d, want 0", other.Dialog.UnreadMentions)
|
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{
|
mentions, err := service.GetUnreadMentions(ctx, 1002, domain.ChannelUnreadMentionsFilter{
|
||||||
ChannelID: created.Channel.ID,
|
ChannelID: created.Channel.ID,
|
||||||
OffsetID: 1,
|
OffsetID: 1,
|
||||||
|
|
@ -159,6 +185,13 @@ func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) {
|
||||||
if mentions.Count != 0 || len(mentions.Messages) != 0 {
|
if mentions.Count != 0 || len(mentions.Messages) != 0 {
|
||||||
t.Fatalf("mentions after read = count %d messages %d, want empty", mentions.Count, len(mentions.Messages))
|
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) {
|
func TestServiceRejectsMismatchedUserContextForStateReads(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -229,13 +229,59 @@ func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.PeerSettings{}, err
|
return domain.PeerSettings{}, err
|
||||||
}
|
}
|
||||||
|
blocked, err := s.contacts.IsBlocked(ctx, userID, peer.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PeerSettings{}, err
|
||||||
|
}
|
||||||
return domain.PeerSettings{
|
return domain.PeerSettings{
|
||||||
AddContact: !found,
|
AddContact: !found,
|
||||||
BlockContact: !found,
|
BlockContact: !blocked,
|
||||||
ShareContact: found,
|
ShareContact: found,
|
||||||
}, nil
|
}, 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) {
|
func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) {
|
||||||
list, notModified, err := s.GetContacts(ctx, userID, hash)
|
list, notModified, err := s.GetContacts(ctx, userID, hash)
|
||||||
if err != nil || notModified {
|
if err != nil || notModified {
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,8 @@ type ChannelMessage struct {
|
||||||
Reactions *ChannelMessageReactions
|
Reactions *ChannelMessageReactions
|
||||||
Action *ChannelMessageAction
|
Action *ChannelMessageAction
|
||||||
Media *MessageMedia
|
Media *MessageMedia
|
||||||
|
Mentioned bool
|
||||||
|
MediaUnread bool
|
||||||
Pts int
|
Pts int
|
||||||
Deleted bool
|
Deleted bool
|
||||||
}
|
}
|
||||||
|
|
@ -827,6 +829,12 @@ type ChannelDifference struct {
|
||||||
Timeout int
|
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.
|
// CreateChannelRequest creates a broadcast channel or megagroup.
|
||||||
type CreateChannelRequest struct {
|
type CreateChannelRequest struct {
|
||||||
CreatorUserID int64
|
CreatorUserID int64
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,18 @@ type ContactList struct {
|
||||||
Hash int64
|
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 视角联系人写入。
|
// ContactInput 描述一次 owner 视角联系人写入。
|
||||||
type ContactInput struct {
|
type ContactInput struct {
|
||||||
ContactUserID int64
|
ContactUserID int64
|
||||||
|
|
|
||||||
|
|
@ -73,24 +73,26 @@ type MessageEntity struct {
|
||||||
|
|
||||||
// Message 是账号视角下的一条私聊消息。
|
// Message 是账号视角下的一条私聊消息。
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID int // 当前 owner 视角下的 message box id,暴露给 Telegram 客户端。
|
ID int // 当前 owner 视角下的 message box id,暴露给 Telegram 客户端。
|
||||||
UID int64 // 共享私聊消息主体 id,不暴露给客户端。
|
UID int64 // 共享私聊消息主体 id,不暴露给客户端。
|
||||||
RandomID int64
|
RandomID int64
|
||||||
OwnerUserID int64
|
OwnerUserID int64
|
||||||
Peer Peer
|
Peer Peer
|
||||||
From Peer
|
From Peer
|
||||||
Date int
|
Date int
|
||||||
EditDate int
|
EditDate int
|
||||||
Out bool
|
Out bool
|
||||||
Silent bool
|
Silent bool
|
||||||
NoForwards bool
|
NoForwards bool
|
||||||
Body string
|
Body string
|
||||||
Entities []MessageEntity
|
Entities []MessageEntity
|
||||||
ReplyTo *MessageReply
|
ReplyTo *MessageReply
|
||||||
Forward *MessageForward
|
Forward *MessageForward
|
||||||
Reactions *ChannelMessageReactions
|
Reactions *ChannelMessageReactions
|
||||||
Pts int
|
Pts int
|
||||||
Media *MessageMedia
|
Media *MessageMedia
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageReply describes a message reply/thread header without depending on TL types.
|
// MessageReply describes a message reply/thread header without depending on TL types.
|
||||||
|
|
@ -139,19 +141,20 @@ type MessageFilter struct {
|
||||||
|
|
||||||
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
|
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
|
||||||
type SendPrivateTextRequest struct {
|
type SendPrivateTextRequest struct {
|
||||||
SenderUserID int64
|
SenderUserID int64
|
||||||
RecipientUserID int64
|
RecipientUserID int64
|
||||||
RandomID int64
|
RandomID int64
|
||||||
Message string
|
Message string
|
||||||
Entities []MessageEntity
|
Entities []MessageEntity
|
||||||
Media *MessageMedia
|
Media *MessageMedia
|
||||||
Silent bool
|
Silent bool
|
||||||
NoForwards bool
|
NoForwards bool
|
||||||
ReplyTo *MessageReply
|
ReplyTo *MessageReply
|
||||||
Forward *MessageForward
|
Forward *MessageForward
|
||||||
Date int
|
Date int
|
||||||
OriginAuthKeyID [8]byte
|
OriginAuthKeyID [8]byte
|
||||||
OriginSessionID int64
|
OriginSessionID int64
|
||||||
|
RecipientBlocked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendPrivateTextResult 描述一次私聊文本发送的双端结果。
|
// SendPrivateTextResult 描述一次私聊文本发送的双端结果。
|
||||||
|
|
@ -189,18 +192,19 @@ type PrivateMessageReactionsResult struct {
|
||||||
|
|
||||||
// ForwardPrivateMessagesRequest 是私聊文本消息转发命令。
|
// ForwardPrivateMessagesRequest 是私聊文本消息转发命令。
|
||||||
type ForwardPrivateMessagesRequest struct {
|
type ForwardPrivateMessagesRequest struct {
|
||||||
OwnerUserID int64
|
OwnerUserID int64
|
||||||
FromPeer Peer
|
FromPeer Peer
|
||||||
ToUserID int64
|
ToUserID int64
|
||||||
MessageIDs []int
|
MessageIDs []int
|
||||||
RandomIDs []int64
|
RandomIDs []int64
|
||||||
Silent bool
|
Silent bool
|
||||||
NoForwards bool
|
NoForwards bool
|
||||||
DropAuthor bool
|
DropAuthor bool
|
||||||
ReplyTo *MessageReply
|
ReplyTo *MessageReply
|
||||||
Date int
|
Date int
|
||||||
OriginAuthKeyID [8]byte
|
OriginAuthKeyID [8]byte
|
||||||
OriginSessionID int64
|
OriginSessionID int64
|
||||||
|
RecipientBlocked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。
|
// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。
|
||||||
|
|
@ -238,14 +242,18 @@ type ReadHistoryResult struct {
|
||||||
|
|
||||||
// ReadMessageContentsRequest marks media/mention contents as read for exact owner-visible messages.
|
// ReadMessageContentsRequest marks media/mention contents as read for exact owner-visible messages.
|
||||||
type ReadMessageContentsRequest struct {
|
type ReadMessageContentsRequest struct {
|
||||||
OwnerUserID int64
|
OwnerUserID int64
|
||||||
IDs []int
|
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 {
|
type ReadMessageContentsResult struct {
|
||||||
OwnerUserID int64
|
OwnerUserID int64
|
||||||
MessageIDs []int
|
MessageIDs []int
|
||||||
|
Event UpdateEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。
|
// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,25 @@ package domain
|
||||||
type UpdateEventType string
|
type UpdateEventType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
UpdateEventNewMessage UpdateEventType = "new_message"
|
UpdateEventNewMessage UpdateEventType = "new_message"
|
||||||
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
|
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
|
||||||
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
|
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
|
||||||
UpdateEventEditMessage UpdateEventType = "edit_message"
|
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
|
||||||
UpdateEventMessageReactions UpdateEventType = "message_reactions"
|
UpdateEventEditMessage UpdateEventType = "edit_message"
|
||||||
UpdateEventContactsReset UpdateEventType = "contacts_reset"
|
UpdateEventMessageReactions UpdateEventType = "message_reactions"
|
||||||
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
|
UpdateEventContactsReset UpdateEventType = "contacts_reset"
|
||||||
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
|
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
|
||||||
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
|
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
|
||||||
UpdateEventPeerSettings UpdateEventType = "peer_settings"
|
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
|
||||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
UpdateEventPeerSettings UpdateEventType = "peer_settings"
|
||||||
UpdateEventDialogFilter UpdateEventType = "dialog_filter"
|
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||||
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
|
UpdateEventDialogFilter UpdateEventType = "dialog_filter"
|
||||||
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
|
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
|
||||||
UpdateEventFolderPeers UpdateEventType = "folder_peers"
|
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
|
||||||
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
|
UpdateEventFolderPeers UpdateEventType = "folder_peers"
|
||||||
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
|
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
|
||||||
UpdateEventNoop UpdateEventType = "noop"
|
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
|
||||||
|
UpdateEventNoop UpdateEventType = "noop"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。
|
// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。
|
||||||
|
|
@ -50,9 +51,16 @@ type UpdateEvent struct {
|
||||||
|
|
||||||
// UpdateDifference 是 updates.getDifference 的业务层结果。
|
// UpdateDifference 是 updates.getDifference 的业务层结果。
|
||||||
type UpdateDifference struct {
|
type UpdateDifference struct {
|
||||||
State UpdateState
|
State UpdateState
|
||||||
Events []UpdateEvent
|
Events []UpdateEvent
|
||||||
|
ChannelNudges []ChannelDifferenceNudge
|
||||||
// Partial 为 true 表示连续事件被 limit 截断、后面还有(映射 updates.differenceSlice,
|
// Partial 为 true 表示连续事件被 limit 截断、后面还有(映射 updates.differenceSlice,
|
||||||
// 客户端据 State 继续翻页);false 表示已到当前连续末尾(updates.difference)。
|
// 客户端据 State 继续翻页);false 表示已到当前连续末尾(updates.difference)。
|
||||||
Partial bool
|
Partial bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChannelDifferenceNudge is a computed account-level hint that a channel diff is dirty.
|
||||||
|
type ChannelDifferenceNudge struct {
|
||||||
|
ChannelID int64
|
||||||
|
Pts int
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2700,12 +2700,15 @@ func (r *Router) channelOperationUpdates(ctx context.Context, viewerUserID int64
|
||||||
for _, member := range res.Members {
|
for _, member := range res.Members {
|
||||||
users = append(users, member.UserID)
|
users = append(users, member.UserID)
|
||||||
}
|
}
|
||||||
updates := make([]tg.UpdateClass, 0, 1)
|
updates := make([]tg.UpdateClass, 0, 2)
|
||||||
if res.Event.Pts != 0 {
|
if res.Event.Pts != 0 {
|
||||||
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
|
if update := tgChannelUpdate(viewerUserID, res.Event); update != nil {
|
||||||
updates = append(updates, update)
|
updates = append(updates, update)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if res.Channel.ID != 0 {
|
||||||
|
updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID})
|
||||||
|
}
|
||||||
return &tg.Updates{
|
return &tg.Updates{
|
||||||
Updates: updates,
|
Updates: updates,
|
||||||
Users: r.tgUsersForIDs(ctx, viewerUserID, users),
|
Users: r.tgUsersForIDs(ctx, viewerUserID, users),
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) {
|
||||||
d.OnContactsImportContacts(r.onContactsImportContacts)
|
d.OnContactsImportContacts(r.onContactsImportContacts)
|
||||||
d.OnContactsAddContact(r.onContactsAddContact)
|
d.OnContactsAddContact(r.onContactsAddContact)
|
||||||
d.OnContactsDeleteContacts(r.onContactsDeleteContacts)
|
d.OnContactsDeleteContacts(r.onContactsDeleteContacts)
|
||||||
|
d.OnContactsBlock(r.onContactsBlock)
|
||||||
|
d.OnContactsUnblock(r.onContactsUnblock)
|
||||||
d.OnContactsUpdateContactNote(r.onContactsUpdateContactNote)
|
d.OnContactsUpdateContactNote(r.onContactsUpdateContactNote)
|
||||||
d.OnContactsSearch(r.onContactsSearch)
|
d.OnContactsSearch(r.onContactsSearch)
|
||||||
d.OnContactsResolveUsername(r.onContactsResolveUsername)
|
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) {
|
d.OnContactsGetTopPeers(func(ctx context.Context, req *tg.ContactsGetTopPeersRequest) (tg.ContactsTopPeersClass, error) {
|
||||||
return tdesktop.TopPeers(), nil
|
return tdesktop.TopPeers(), nil
|
||||||
})
|
})
|
||||||
d.OnContactsGetBlocked(func(ctx context.Context, req *tg.ContactsGetBlockedRequest) (tg.ContactsBlockedClass, error) {
|
d.OnContactsGetBlocked(r.onContactsGetBlocked)
|
||||||
if req.Limit > 50 {
|
|
||||||
return nil, limitInvalidErr()
|
|
||||||
}
|
|
||||||
return tdesktop.BlockedContacts(), nil
|
|
||||||
})
|
|
||||||
d.OnContactsGetSponsoredPeers(func(ctx context.Context, q string) (tg.ContactsSponsoredPeersClass, error) {
|
d.OnContactsGetSponsoredPeers(func(ctx context.Context, q string) (tg.ContactsSponsoredPeersClass, error) {
|
||||||
if utf8.RuneCountInString(q) > maxContactSearchQLen {
|
if utf8.RuneCountInString(q) > maxContactSearchQLen {
|
||||||
return nil, limitInvalidErr()
|
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) {
|
func (r *Router) onContactsGetContacts(ctx context.Context, hash int64) (tg.ContactsContactsClass, error) {
|
||||||
if r.deps.Contacts == nil {
|
if r.deps.Contacts == nil {
|
||||||
return &tg.ContactsContacts{}, nil
|
return &tg.ContactsContacts{}, nil
|
||||||
|
|
|
||||||
|
|
@ -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 续拉。
|
// Partial:连续事件被 limit 截断、后面还有 → updates.differenceSlice,客户端据 IntermediateState 续拉。
|
||||||
if diff.Partial {
|
if diff.Partial {
|
||||||
return &tg.UpdatesDifferenceSlice{
|
return &tg.UpdatesDifferenceSlice{
|
||||||
|
|
@ -561,6 +571,15 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
|
||||||
}
|
}
|
||||||
case domain.UpdateEventReadHistoryOutbox:
|
case domain.UpdateEventReadHistoryOutbox:
|
||||||
return tgReadHistoryOutbox(event)
|
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:
|
case domain.UpdateEventEditMessage:
|
||||||
msg := tgMessage(event.Message)
|
msg := tgMessage(event.Message)
|
||||||
if msg == nil {
|
if msg == nil {
|
||||||
|
|
@ -856,12 +875,13 @@ func tgMessage(m domain.Message) tg.MessageClass {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
msg := &tg.Message{
|
msg := &tg.Message{
|
||||||
Out: m.Out,
|
Out: m.Out,
|
||||||
ID: m.ID,
|
MediaUnread: m.MediaUnread,
|
||||||
PeerID: peer,
|
ID: m.ID,
|
||||||
Date: m.Date,
|
PeerID: peer,
|
||||||
Message: m.Body,
|
Date: m.Date,
|
||||||
Entities: tgMessageEntities(m.Entities),
|
Message: m.Body,
|
||||||
|
Entities: tgMessageEntities(m.Entities),
|
||||||
}
|
}
|
||||||
if m.EditDate != 0 {
|
if m.EditDate != 0 {
|
||||||
msg.SetEditDate(m.EditDate)
|
msg.SetEditDate(m.EditDate)
|
||||||
|
|
@ -993,16 +1013,18 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
msg := &tg.Message{
|
msg := &tg.Message{
|
||||||
Out: outgoing,
|
Out: outgoing,
|
||||||
Silent: m.Silent,
|
Silent: m.Silent,
|
||||||
Post: m.Post,
|
Post: m.Post,
|
||||||
Noforwards: m.NoForwards,
|
Noforwards: m.NoForwards,
|
||||||
ID: m.ID,
|
Mentioned: m.Mentioned,
|
||||||
FromID: from,
|
MediaUnread: m.MediaUnread,
|
||||||
PeerID: peer,
|
ID: m.ID,
|
||||||
Date: m.Date,
|
FromID: from,
|
||||||
Message: m.Body,
|
PeerID: peer,
|
||||||
Entities: tgMessageEntities(m.Entities),
|
Date: m.Date,
|
||||||
|
Message: m.Body,
|
||||||
|
Entities: tgMessageEntities(m.Entities),
|
||||||
}
|
}
|
||||||
if m.EditDate != 0 {
|
if m.EditDate != 0 {
|
||||||
msg.SetEditDate(m.EditDate)
|
msg.SetEditDate(m.EditDate)
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,10 @@ type ContactsService interface {
|
||||||
DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)
|
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)
|
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)
|
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 抽象会话列表查询。
|
// DialogsService 抽象会话列表查询。
|
||||||
|
|
@ -278,6 +282,7 @@ type ChannelsService interface {
|
||||||
GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
|
GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
|
||||||
GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
|
GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
|
||||||
ActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, 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)
|
ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error)
|
||||||
InviteAdminMemberIDs(ctx context.Context, 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)
|
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||||
|
|
|
||||||
|
|
@ -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 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 messageNotReadYetErr() error { return tgerr.New(400, "MESSAGE_NOT_READ_YET") }
|
||||||
|
|
||||||
func replyMessageIDInvalidErr() error { return tgerr.New(400, "REPLY_MESSAGE_ID_INVALID") }
|
func replyMessageIDInvalidErr() error { return tgerr.New(400, "REPLY_MESSAGE_ID_INVALID") }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (*tg.MessagesAffectedMessages, error) {
|
||||||
id, _ := AuthKeyIDFrom(ctx)
|
id, _ := AuthKeyIDFrom(ctx)
|
||||||
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
userID, _, err := r.currentUserID(ctx)
|
userID, _, err := r.currentUserID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
|
|
@ -2823,8 +2824,11 @@ func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (
|
||||||
read := domain.ReadMessageContentsResult{OwnerUserID: userID}
|
read := domain.ReadMessageContentsResult{OwnerUserID: userID}
|
||||||
if r.deps.Messages != nil {
|
if r.deps.Messages != nil {
|
||||||
read, err = r.deps.Messages.ReadMessageContents(ctx, userID, domain.ReadMessageContentsRequest{
|
read, err = r.deps.Messages.ReadMessageContents(ctx, userID, domain.ReadMessageContentsRequest{
|
||||||
OwnerUserID: userID,
|
OwnerUserID: userID,
|
||||||
IDs: ids,
|
IDs: ids,
|
||||||
|
Date: int(r.clock.Now().Unix()),
|
||||||
|
OriginAuthKeyID: id,
|
||||||
|
OriginSessionID: sessionID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrMessageIDInvalid) {
|
if errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||||
|
|
@ -2833,12 +2837,15 @@ func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
affected, err := r.affectedMessages(ctx, id, userID)
|
affected := &tg.MessagesAffectedMessages{Pts: read.Event.Pts, PtsCount: read.Event.PtsCount}
|
||||||
if err != nil {
|
if read.Event.Pts == 0 {
|
||||||
return nil, err
|
affected, err = r.affectedMessages(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if contentIDs := readMessageContentIDs(read.MessageIDs); len(contentIDs) > 0 {
|
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{
|
Updates: []tg.UpdateClass{&tg.UpdateReadMessagesContents{
|
||||||
Messages: contentIDs,
|
Messages: contentIDs,
|
||||||
Pts: affected.Pts,
|
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 {
|
if r.deps.Channels == nil || r.deps.Messages == nil {
|
||||||
return nil, peerIDInvalidErr()
|
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)
|
sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, messageForwardErr(err)
|
return nil, messageForwardErr(err)
|
||||||
|
|
@ -4391,19 +4402,20 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
||||||
forward = nil
|
forward = nil
|
||||||
}
|
}
|
||||||
sent, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
sent, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
||||||
SenderUserID: userID,
|
SenderUserID: userID,
|
||||||
RecipientUserID: toPeer.ID,
|
RecipientUserID: toPeer.ID,
|
||||||
RandomID: req.RandomID[i],
|
RandomID: req.RandomID[i],
|
||||||
Message: source.body,
|
Message: source.body,
|
||||||
Entities: source.entities,
|
Entities: source.entities,
|
||||||
Media: source.media,
|
Media: source.media,
|
||||||
Silent: req.Silent,
|
Silent: req.Silent,
|
||||||
NoForwards: req.Noforwards,
|
NoForwards: req.Noforwards,
|
||||||
ReplyTo: replyTo,
|
ReplyTo: replyTo,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Date: int(r.clock.Now().Unix()),
|
Date: int(r.clock.Now().Unix()),
|
||||||
OriginAuthKeyID: authKeyID,
|
OriginAuthKeyID: authKeyID,
|
||||||
OriginSessionID: sessionID,
|
OriginSessionID: sessionID,
|
||||||
|
RecipientBlocked: recipientBlocked,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, messageForwardErr(err)
|
return nil, messageForwardErr(err)
|
||||||
|
|
@ -4421,19 +4433,24 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
|
||||||
}
|
}
|
||||||
sessionID, _ := SessionIDFrom(ctx)
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
authKeyID, _ := AuthKeyIDFrom(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{
|
res, err := r.deps.Messages.ForwardPrivateMessages(ctx, userID, domain.ForwardPrivateMessagesRequest{
|
||||||
OwnerUserID: userID,
|
OwnerUserID: userID,
|
||||||
FromPeer: fromPeer,
|
FromPeer: fromPeer,
|
||||||
ToUserID: toPeer.ID,
|
ToUserID: toPeer.ID,
|
||||||
MessageIDs: append([]int(nil), req.ID...),
|
MessageIDs: append([]int(nil), req.ID...),
|
||||||
RandomIDs: append([]int64(nil), req.RandomID...),
|
RandomIDs: append([]int64(nil), req.RandomID...),
|
||||||
Silent: req.Silent,
|
Silent: req.Silent,
|
||||||
NoForwards: req.Noforwards,
|
NoForwards: req.Noforwards,
|
||||||
DropAuthor: req.DropAuthor,
|
DropAuthor: req.DropAuthor,
|
||||||
ReplyTo: replyTo,
|
ReplyTo: replyTo,
|
||||||
Date: int(r.clock.Now().Unix()),
|
Date: int(r.clock.Now().Unix()),
|
||||||
OriginAuthKeyID: authKeyID,
|
OriginAuthKeyID: authKeyID,
|
||||||
OriginSessionID: sessionID,
|
OriginSessionID: sessionID,
|
||||||
|
RecipientBlocked: recipientBlocked,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, messageForwardErr(err)
|
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 {
|
if peer.Type != domain.PeerTypeUser || r.deps.Messages == nil {
|
||||||
return nil, peerIDInvalidErr()
|
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)
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{
|
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 {
|
if len(req.ID) > domain.MaxDeleteMessageIDs {
|
||||||
return nil, limitInvalidErr()
|
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)
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
res, err := r.deps.Messages.DeleteMessages(ctx, userID, domain.DeleteMessagesRequest{
|
res, err := r.deps.Messages.DeleteMessages(ctx, userID, domain.DeleteMessagesRequest{
|
||||||
OwnerUserID: userID,
|
OwnerUserID: userID,
|
||||||
|
|
@ -4892,6 +4929,15 @@ func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDe
|
||||||
if r.deps.Messages == nil {
|
if r.deps.Messages == nil {
|
||||||
return r.affectedHistory(ctx, authKeyID, userID, 0)
|
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)
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
res, err := r.deps.Messages.DeleteHistory(ctx, userID, domain.DeleteHistoryRequest{
|
res, err := r.deps.Messages.DeleteHistory(ctx, userID, domain.DeleteHistoryRequest{
|
||||||
OwnerUserID: userID,
|
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 {
|
func messageForwardErr(err error) error {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, domain.ErrMessageIDInvalid):
|
case errors.Is(err, domain.ErrMessageIDInvalid):
|
||||||
|
|
|
||||||
|
|
@ -829,13 +829,16 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
|
||||||
if !ok || !channel.Megagroup || channel.Broadcast {
|
if !ok || !channel.Megagroup || channel.Broadcast {
|
||||||
t.Fatalf("chat = %#v, want megagroup channel", updates.Chats[0])
|
t.Fatalf("chat = %#v, want megagroup channel", updates.Chats[0])
|
||||||
}
|
}
|
||||||
if len(updates.Updates) != 2 {
|
if len(updates.Updates) != 4 {
|
||||||
t.Fatalf("updates len = %d, want create + invite service messages", len(updates.Updates))
|
t.Fatalf("updates len = %d, want create/invite service messages plus channel refreshes", len(updates.Updates))
|
||||||
}
|
}
|
||||||
newMsg, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage)
|
newMsg, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage)
|
||||||
if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 {
|
if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 {
|
||||||
t.Fatalf("create update = %#v, want channel pts=1", updates.Updates[0])
|
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)
|
service, ok := newMsg.Message.(*tg.MessageService)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("create message = %T, want service", newMsg.Message)
|
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 {
|
if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok {
|
||||||
t.Fatalf("service action = %T, want channel create", service.Action)
|
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 {
|
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)
|
inviteService, ok := inviteMsg.Message.(*tg.MessageService)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -1102,8 +1108,8 @@ func TestMessagesCreateChatDispatchRemembersTDesktopClientInfo(t *testing.T) {
|
||||||
if !ok || !channel.Megagroup || !channel.Creator {
|
if !ok || !channel.Megagroup || !channel.Creator {
|
||||||
t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1])
|
t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1])
|
||||||
}
|
}
|
||||||
if len(updates.Updates) != 2 {
|
if len(updates.Updates) != 4 {
|
||||||
t.Fatalf("updates len = %d, want create + invite service messages", len(updates.Updates))
|
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{
|
participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
|
||||||
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
||||||
|
|
@ -4337,8 +4343,12 @@ func TestChannelAdminPinInviteRPC(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("import invite: %v", err)
|
t.Fatalf("import invite: %v", err)
|
||||||
}
|
}
|
||||||
if updates := imported.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) != 1 {
|
if updates := imported.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) != 2 {
|
||||||
t.Fatalf("import updates = %+v, want chat and join service update", updates)
|
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{
|
inviteList, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{
|
||||||
Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
|
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) {
|
func TestUpdatesDifferenceIncludesSettingsUpdates(t *testing.T) {
|
||||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
|
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
|
||||||
got, ok := tgUpdatesDifference(domain.UpdateDifference{
|
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) {
|
func TestMessagesSendMessageSupportsReplyAndFlags(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
senderID = int64(1000000001)
|
senderID = int64(1000000001)
|
||||||
|
|
|
||||||
|
|
@ -94,21 +94,26 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
recipientBlocked, err := r.peerBlocksUser(ctx, userID, peer.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
sessionID, _ := SessionIDFrom(ctx)
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
||||||
SenderUserID: userID,
|
SenderUserID: userID,
|
||||||
RecipientUserID: peer.ID,
|
RecipientUserID: peer.ID,
|
||||||
RandomID: p.randomID,
|
RandomID: p.randomID,
|
||||||
Message: p.message,
|
Message: p.message,
|
||||||
Entities: domainMessageEntities(p.entities),
|
Entities: domainMessageEntities(p.entities),
|
||||||
Media: p.media,
|
Media: p.media,
|
||||||
Silent: p.silent,
|
Silent: p.silent,
|
||||||
NoForwards: p.noforwards,
|
NoForwards: p.noforwards,
|
||||||
ReplyTo: replyTo,
|
ReplyTo: replyTo,
|
||||||
Date: int(r.clock.Now().Unix()),
|
Date: int(r.clock.Now().Unix()),
|
||||||
OriginAuthKeyID: authKeyID,
|
OriginAuthKeyID: authKeyID,
|
||||||
OriginSessionID: sessionID,
|
OriginSessionID: sessionID,
|
||||||
|
RecipientBlocked: recipientBlocked,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, messageSendErr(err)
|
return nil, false, messageSendErr(err)
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,32 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
r.markSessionReceivesUpdates(ctx, userID)
|
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
|
return &tg.UpdatesDifferenceEmpty{Date: st.State.Date, Seq: st.State.Seq}, nil
|
||||||
}
|
}
|
||||||
st.Events = r.enrichUpdateEvents(ctx, userID, st.Events)
|
st.Events = r.enrichUpdateEvents(ctx, userID, st.Events)
|
||||||
return tgUpdatesDifference(st), nil
|
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) {
|
func (r *Router) markSessionReceivesUpdates(ctx context.Context, userID int64) {
|
||||||
if r.deps.Sessions == nil {
|
if r.deps.Sessions == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ type ChannelStore interface {
|
||||||
ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
|
ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
|
||||||
ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
|
ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
|
||||||
ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, 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)
|
ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
|
||||||
ListChannelInviteAdminMemberIDs(ctx context.Context, 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)
|
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,8 @@ type ContactStore interface {
|
||||||
UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error)
|
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)
|
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4937,6 +4937,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
||||||
}
|
}
|
||||||
messages = append(messages, cloneChannelMessage(msg))
|
messages = append(messages, cloneChannelMessage(msg))
|
||||||
}
|
}
|
||||||
|
s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages)
|
||||||
return domain.ChannelDifference{
|
return domain.ChannelDifference{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
Self: member,
|
Self: member,
|
||||||
|
|
@ -4991,6 +4992,15 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
||||||
diff.OtherUpdates = append(diff.OtherUpdates, cloneChannelEvent(event))
|
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
|
return diff, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5025,6 +5035,46 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, af
|
||||||
return out, nil
|
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) {
|
func (s *ChannelStore) ListActiveChannelMemberIDs(_ context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
@ -6495,6 +6545,7 @@ func (s *ChannelStore) populateChannelMessageReactionsLocked(viewerUserID int64,
|
||||||
if len(messages) == 0 || channel.ID == 0 {
|
if len(messages) == 0 || channel.ID == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.populateChannelMessageUnreadFlagsLocked(viewerUserID, messages)
|
||||||
for i := range messages {
|
for i := range messages {
|
||||||
if messages[i].ChannelID != channel.ID || messages[i].ID <= 0 {
|
if messages[i].ChannelID != channel.ID || messages[i].ID <= 0 {
|
||||||
continue
|
continue
|
||||||
|
|
@ -6511,6 +6562,7 @@ func (s *ChannelStore) populateChannelMessagesReactionsLocked(viewerUserID int64
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.populateChannelMessageUnreadFlagsLocked(viewerUserID, messages)
|
||||||
channelsByID := make(map[int64]domain.Channel, len(channels))
|
channelsByID := make(map[int64]domain.Channel, len(channels))
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
if ch.ID != 0 {
|
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 {
|
type memoryReactionCursor struct {
|
||||||
date int
|
date int
|
||||||
userID int64
|
userID int64
|
||||||
|
|
|
||||||
|
|
@ -242,13 +242,17 @@ func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]
|
||||||
|
|
||||||
// ContactStore 是 store.ContactStore 的内存实现。
|
// ContactStore 是 store.ContactStore 的内存实现。
|
||||||
type ContactStore struct {
|
type ContactStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
m map[int64]domain.ContactList
|
m map[int64]domain.ContactList
|
||||||
|
blocks map[int64]map[int64]domain.BlockedContact
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContactStore 创建内存 ContactStore。
|
// NewContactStore 创建内存 ContactStore。
|
||||||
func 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) {
|
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
|
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 保存一份用户通讯录,供测试和本地替身使用。
|
// SaveList 保存一份用户通讯录,供测试和本地替身使用。
|
||||||
func (s *ContactStore) SaveList(_ context.Context, userID int64, list domain.ContactList) error {
|
func (s *ContactStore) SaveList(_ context.Context, userID int64, list domain.ContactList) error {
|
||||||
list.Contacts = cloneContacts(list.Contacts)
|
list.Contacts = cloneContacts(list.Contacts)
|
||||||
|
|
@ -956,21 +1022,30 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
|
||||||
Forward: cloneMessageForward(req.Forward),
|
Forward: cloneMessageForward(req.Forward),
|
||||||
Pts: s.nextPtsLocked(req.SenderUserID),
|
Pts: s.nextPtsLocked(req.SenderUserID),
|
||||||
}
|
}
|
||||||
recipient := sender
|
recipient := domain.Message{}
|
||||||
if req.SenderUserID != req.RecipientUserID {
|
if req.SenderUserID == req.RecipientUserID {
|
||||||
|
recipient = sender
|
||||||
|
}
|
||||||
|
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
|
||||||
|
recipient = sender
|
||||||
recipient.ID = s.nextBoxIDLocked(req.RecipientUserID)
|
recipient.ID = s.nextBoxIDLocked(req.RecipientUserID)
|
||||||
recipient.OwnerUserID = req.RecipientUserID
|
recipient.OwnerUserID = req.RecipientUserID
|
||||||
recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||||
recipient.Out = false
|
recipient.Out = false
|
||||||
recipient.ReplyTo = cloneMessageReply(recipientReply)
|
recipient.ReplyTo = cloneMessageReply(recipientReply)
|
||||||
recipient.Pts = s.nextPtsLocked(req.RecipientUserID)
|
recipient.Pts = s.nextPtsLocked(req.RecipientUserID)
|
||||||
|
recipient.MediaUnread = !req.Media.IsZero()
|
||||||
}
|
}
|
||||||
s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender)
|
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)
|
s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient)
|
||||||
}
|
}
|
||||||
if s.dialogs != nil {
|
if s.dialogs != nil {
|
||||||
s.upsertMemoryDialogsLocked(sender, recipient)
|
if recipient.ID != 0 {
|
||||||
|
s.upsertMemoryDialogsLocked(sender, recipient)
|
||||||
|
} else {
|
||||||
|
s.upsertMemoryDialogsLocked(sender, sender)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return domain.SendPrivateTextResult{
|
return domain.SendPrivateTextResult{
|
||||||
SenderMessage: cloneMessage(sender),
|
SenderMessage: cloneMessage(sender),
|
||||||
|
|
@ -1078,18 +1153,19 @@ func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.Fo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||||
SenderUserID: req.OwnerUserID,
|
SenderUserID: req.OwnerUserID,
|
||||||
RecipientUserID: req.ToUserID,
|
RecipientUserID: req.ToUserID,
|
||||||
RandomID: req.RandomIDs[i],
|
RandomID: req.RandomIDs[i],
|
||||||
Message: source.Body,
|
Message: source.Body,
|
||||||
Entities: append([]domain.MessageEntity(nil), source.Entities...),
|
Entities: append([]domain.MessageEntity(nil), source.Entities...),
|
||||||
Silent: req.Silent,
|
Silent: req.Silent,
|
||||||
NoForwards: req.NoForwards,
|
NoForwards: req.NoForwards,
|
||||||
ReplyTo: req.ReplyTo,
|
ReplyTo: req.ReplyTo,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Date: req.Date,
|
Date: req.Date,
|
||||||
OriginAuthKeyID: req.OriginAuthKeyID,
|
OriginAuthKeyID: req.OriginAuthKeyID,
|
||||||
OriginSessionID: req.OriginSessionID,
|
OriginSessionID: req.OriginSessionID,
|
||||||
|
RecipientBlocked: req.RecipientBlocked,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
|
|
@ -1277,14 +1353,52 @@ func (s *MessageStore) ReadMessageContents(_ context.Context, req domain.ReadMes
|
||||||
if len(wanted) == 0 {
|
if len(wanted) == 0 {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
s.mu.RLock()
|
if req.Date == 0 {
|
||||||
for _, msg := range s.m[req.OwnerUserID] {
|
req.Date = int(time.Now().Unix())
|
||||||
if _, ok := wanted[msg.ID]; ok {
|
}
|
||||||
res.MessageIDs = append(res.MessageIDs, msg.ID)
|
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)
|
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
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1355,6 +1469,27 @@ func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPriv
|
||||||
} else {
|
} else {
|
||||||
s.privateReactions[target.UID][req.UserID] = rows
|
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
|
return s.privateReactionResultLocked(target.UID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1585,6 +1720,16 @@ func (s *MessageStore) nextPtsNLocked(userID int64, count int) int {
|
||||||
return next
|
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) {
|
func (s *MessageStore) upsertMemoryDialogsLocked(sender, recipient domain.Message) {
|
||||||
s.dialogs.mu.Lock()
|
s.dialogs.mu.Lock()
|
||||||
defer s.dialogs.mu.Unlock()
|
defer s.dialogs.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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)
|
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{
|
bobReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{
|
||||||
OwnerUserID: bobID,
|
OwnerUserID: bobID,
|
||||||
|
|
@ -344,7 +369,7 @@ func TestMessageStoreReadHistoryEmitsInboxAndOutboxReceipts(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMessageStoreReadMessageContentsReturnsExistingOwnerIDs(t *testing.T) {
|
func TestMessageStoreReadMessageContentsClearsUnreadContentOnce(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := NewMessageStore()
|
messages := NewMessageStore()
|
||||||
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||||
|
|
@ -352,20 +377,39 @@ func TestMessageStoreReadMessageContentsReturnsExistingOwnerIDs(t *testing.T) {
|
||||||
RecipientUserID: 1002,
|
RecipientUserID: 1002,
|
||||||
RandomID: 88,
|
RandomID: 88,
|
||||||
Message: "voice placeholder",
|
Message: "voice placeholder",
|
||||||
|
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Voice: true},
|
||||||
Date: 1700000300,
|
Date: 1700000300,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SendPrivateText: %v", err)
|
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{
|
got, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
|
||||||
OwnerUserID: 1002,
|
OwnerUserID: 1002,
|
||||||
IDs: []int{sent.RecipientMessage.ID, domain.MaxMessageBoxID},
|
IDs: []int{sent.RecipientMessage.ID, domain.MaxMessageBoxID},
|
||||||
|
Date: 1700000400,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReadMessageContents: %v", err)
|
t.Fatalf("ReadMessageContents: %v", err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got.MessageIDs, []int{sent.RecipientMessage.ID}) {
|
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{
|
if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
|
||||||
OwnerUserID: 1002,
|
OwnerUserID: 1002,
|
||||||
|
|
|
||||||
|
|
@ -6570,6 +6570,9 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return domain.ChannelDifference{}, err
|
return domain.ChannelDifference{}, err
|
||||||
}
|
}
|
||||||
|
if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil {
|
||||||
|
return domain.ChannelDifference{}, err
|
||||||
|
}
|
||||||
if preview {
|
if preview {
|
||||||
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
|
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -6634,6 +6637,19 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
||||||
} else if lastPts > diff.Pts {
|
} else if lastPts > diff.Pts {
|
||||||
diff.Pts = lastPts
|
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))
|
users, err := listUsersByIDs(ctx, s.db, mapKeysInt64(userRefs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ChannelDifference{}, err
|
return domain.ChannelDifference{}, err
|
||||||
|
|
@ -6687,6 +6703,45 @@ LIMIT $3`, userID, afterChannelID, limit)
|
||||||
return out, rows.Err()
|
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) {
|
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 {
|
if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -7119,6 +7174,9 @@ func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
channelsByID := make(map[int64]domain.Channel, len(channels))
|
channelsByID := make(map[int64]domain.Channel, len(channels))
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
if ch.ID != 0 {
|
if ch.ID != 0 {
|
||||||
|
|
@ -7230,6 +7288,54 @@ ORDER BY message_id ASC, reaction_date DESC, reacted_user_id DESC, reaction_valu
|
||||||
return nil
|
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 {
|
func channelReactionOffset(row domain.ChannelMessagePeerReaction) string {
|
||||||
return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + row.Reaction.Emoticon
|
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]
|
candidates = candidates[:domain.MaxChannelMentionRecipients]
|
||||||
}
|
}
|
||||||
topID := channelMentionTopID(msg)
|
topID := channelMentionTopID(msg)
|
||||||
|
mediaUnread := !msg.Media.IsZero()
|
||||||
if _, err := tx.Exec(ctx, `
|
if _, err := tx.Exec(ctx, `
|
||||||
WITH input(user_id) AS (
|
WITH input(user_id) AS (
|
||||||
SELECT DISTINCT unnest($4::bigint[])
|
SELECT DISTINCT unnest($4::bigint[])
|
||||||
|
|
@ -8626,8 +8733,8 @@ active AS (
|
||||||
LIMIT $6
|
LIMIT $6
|
||||||
),
|
),
|
||||||
inserted AS (
|
inserted AS (
|
||||||
INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id)
|
INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id, media_unread)
|
||||||
SELECT user_id, $1, $2, $3
|
SELECT user_id, $1, $2, $3, $7
|
||||||
FROM active
|
FROM active
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
RETURNING user_id
|
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_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id),
|
||||||
top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date),
|
top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date),
|
||||||
unread_mentions_count = channel_dialogs.unread_mentions_count + 1,
|
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 fmt.Errorf("insert channel unread mentions: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
func contactListHash(contacts []domain.Contact) int64 {
|
||||||
if len(contacts) == 0 {
|
if len(contacts) == 0 {
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,8 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
|
||||||
|
|
||||||
var recipientBoxID, recipientPts int
|
var recipientBoxID, recipientPts int
|
||||||
selfMessage := req.RecipientUserID == req.SenderUserID
|
selfMessage := req.RecipientUserID == req.SenderUserID
|
||||||
if !selfMessage {
|
deliverRecipient := !selfMessage && !req.RecipientBlocked
|
||||||
|
if deliverRecipient {
|
||||||
recipientBoxID, err = s.boxIDs.NextBoxID(ctx, req.RecipientUserID)
|
recipientBoxID, err = s.boxIDs.NextBoxID(ctx, req.RecipientUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.recordPtsGaps(ctx, reserved, req.Date)
|
s.recordPtsGaps(ctx, reserved, req.Date)
|
||||||
|
|
@ -249,6 +250,8 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
|
||||||
EntitiesJson: entities,
|
EntitiesJson: entities,
|
||||||
Pts: int32(senderPts),
|
Pts: int32(senderPts),
|
||||||
MediaJson: mediaJSON,
|
MediaJson: mediaJSON,
|
||||||
|
MediaUnread: false,
|
||||||
|
ReactionUnread: false,
|
||||||
}
|
}
|
||||||
applyCreateMessageBoxMetadata(&senderArg, senderMeta)
|
applyCreateMessageBoxMetadata(&senderArg, senderMeta)
|
||||||
senderRow, err := qtx.CreateMessageBox(ctx, senderArg)
|
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)
|
return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue sender dispatch: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient := sender
|
recipient := domain.Message{}
|
||||||
if !selfMessage {
|
if selfMessage {
|
||||||
|
recipient = sender
|
||||||
|
}
|
||||||
|
if deliverRecipient {
|
||||||
recipientArg := sqlcgen.CreateMessageBoxParams{
|
recipientArg := sqlcgen.CreateMessageBoxParams{
|
||||||
OwnerUserID: req.RecipientUserID,
|
OwnerUserID: req.RecipientUserID,
|
||||||
BoxID: int32(recipientBoxID),
|
BoxID: int32(recipientBoxID),
|
||||||
|
|
@ -294,6 +300,8 @@ func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPriva
|
||||||
EntitiesJson: entities,
|
EntitiesJson: entities,
|
||||||
Pts: int32(recipientPts),
|
Pts: int32(recipientPts),
|
||||||
MediaJson: mediaJSON,
|
MediaJson: mediaJSON,
|
||||||
|
MediaUnread: !req.Media.IsZero(),
|
||||||
|
ReactionUnread: false,
|
||||||
}
|
}
|
||||||
applyCreateMessageBoxMetadata(&recipientArg, recipientMeta)
|
applyCreateMessageBoxMetadata(&recipientArg, recipientMeta)
|
||||||
recipientRow, err := qtx.CreateMessageBox(ctx, recipientArg)
|
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)
|
return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate sender box: %w", err)
|
||||||
}
|
}
|
||||||
sender := messageFromGetBoxRow(senderRow)
|
sender := messageFromGetBoxRow(senderRow)
|
||||||
recipient := sender
|
recipient := domain.Message{}
|
||||||
|
if recipientUserID == senderUserID {
|
||||||
|
recipient = sender
|
||||||
|
}
|
||||||
if recipientUserID != senderUserID {
|
if recipientUserID != senderUserID {
|
||||||
recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
|
recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
|
||||||
OwnerUserID: recipientUserID,
|
OwnerUserID: recipientUserID,
|
||||||
PrivateMessageID: pm.ID,
|
PrivateMessageID: pm.ID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate recipient box: %w", err)
|
||||||
}
|
}
|
||||||
recipient = messageFromGetBoxRow(recipientRow)
|
recipient = messageFromGetBoxRow(recipientRow)
|
||||||
|
|
@ -514,19 +532,20 @@ func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.Fo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||||
SenderUserID: req.OwnerUserID,
|
SenderUserID: req.OwnerUserID,
|
||||||
RecipientUserID: req.ToUserID,
|
RecipientUserID: req.ToUserID,
|
||||||
RandomID: req.RandomIDs[i],
|
RandomID: req.RandomIDs[i],
|
||||||
Message: source.Body,
|
Message: source.Body,
|
||||||
Entities: append([]domain.MessageEntity(nil), source.Entities...),
|
Entities: append([]domain.MessageEntity(nil), source.Entities...),
|
||||||
Media: source.Media,
|
Media: source.Media,
|
||||||
Silent: req.Silent,
|
Silent: req.Silent,
|
||||||
NoForwards: req.NoForwards,
|
NoForwards: req.NoForwards,
|
||||||
ReplyTo: req.ReplyTo,
|
ReplyTo: req.ReplyTo,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Date: req.Date,
|
Date: req.Date,
|
||||||
OriginAuthKeyID: req.OriginAuthKeyID,
|
OriginAuthKeyID: req.OriginAuthKeyID,
|
||||||
OriginSessionID: req.OriginSessionID,
|
OriginSessionID: req.OriginSessionID,
|
||||||
|
RecipientBlocked: req.RecipientBlocked,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
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)
|
return domain.MessageList{}, fmt.Errorf("decode message media: %w", err)
|
||||||
}
|
}
|
||||||
out.Messages = append(out.Messages, domain.Message{
|
out.Messages = append(out.Messages, domain.Message{
|
||||||
ID: int(row.BoxID),
|
ID: int(row.BoxID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Pts: int(row.Pts),
|
Pts: int(row.Pts),
|
||||||
Media: media,
|
Media: media,
|
||||||
|
MediaUnread: row.MediaUnread,
|
||||||
|
ReactionUnread: row.ReactionUnread,
|
||||||
})
|
})
|
||||||
if out.Count == 0 {
|
if out.Count == 0 {
|
||||||
out.Count = int(row.TotalCount)
|
out.Count = int(row.TotalCount)
|
||||||
|
|
@ -832,6 +853,9 @@ func (s *MessageStore) ReadMessageContents(ctx context.Context, req domain.ReadM
|
||||||
if req.OwnerUserID == 0 {
|
if req.OwnerUserID == 0 {
|
||||||
return res, fmt.Errorf("read message contents: missing owner user id")
|
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 {
|
if len(req.IDs) > domain.MaxGetMessageIDs {
|
||||||
return res, domain.ErrMessageIDInvalid
|
return res, domain.ErrMessageIDInvalid
|
||||||
}
|
}
|
||||||
|
|
@ -850,27 +874,128 @@ func (s *MessageStore) ReadMessageContents(ctx context.Context, req domain.ReadM
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
rows, err := s.db.Query(ctx, `
|
beginner, ok := s.db.(txBeginner)
|
||||||
SELECT box_id
|
if !ok {
|
||||||
FROM message_boxes
|
return res, fmt.Errorf("read message contents: db does not support transactions")
|
||||||
WHERE owner_user_id = $1
|
}
|
||||||
AND box_id = ANY($2::int[])
|
tx, err := beginner.Begin(ctx)
|
||||||
AND NOT deleted
|
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)
|
ORDER BY box_id`, req.OwnerUserID, ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, fmt.Errorf("read message contents: %w", err)
|
return res, fmt.Errorf("read message contents: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
affectedPeers := make(map[domain.Peer]struct{})
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int32
|
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)
|
return res, fmt.Errorf("scan read message contents: %w", err)
|
||||||
}
|
}
|
||||||
res.MessageIDs = append(res.MessageIDs, int(id))
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
return res, fmt.Errorf("read message contents rows: %w", err)
|
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
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -996,6 +1121,39 @@ DO UPDATE SET
|
||||||
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("insert message reaction: %w", err)
|
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{
|
boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{
|
||||||
MessageSenderID: target.messageSenderID,
|
MessageSenderID: target.messageSenderID,
|
||||||
|
|
@ -1911,22 +2069,24 @@ func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message {
|
||||||
row.FwdDate,
|
row.FwdDate,
|
||||||
)
|
)
|
||||||
return domain.Message{
|
return domain.Message{
|
||||||
Media: media,
|
Media: media,
|
||||||
ID: int(row.BoxID),
|
ID: int(row.BoxID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Pts: int(row.Pts),
|
Pts: int(row.Pts),
|
||||||
|
MediaUnread: row.MediaUnread,
|
||||||
|
ReactionUnread: row.ReactionUnread,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1949,22 +2109,24 @@ func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.M
|
||||||
row.FwdDate,
|
row.FwdDate,
|
||||||
)
|
)
|
||||||
return domain.Message{
|
return domain.Message{
|
||||||
Media: media,
|
Media: media,
|
||||||
ID: int(row.BoxID),
|
ID: int(row.BoxID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Pts: int(row.Pts),
|
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{}, fmt.Errorf("decode visible message media: %w", err)
|
||||||
}
|
}
|
||||||
return domain.Message{
|
return domain.Message{
|
||||||
Media: media,
|
Media: media,
|
||||||
ID: int(row.BoxID),
|
ID: int(row.BoxID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Pts: int(row.Pts),
|
Pts: int(row.Pts),
|
||||||
|
MediaUnread: row.MediaUnread,
|
||||||
|
ReactionUnread: row.ReactionUnread,
|
||||||
}, nil
|
}, 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{}, fmt.Errorf("decode edited message media: %w", err)
|
||||||
}
|
}
|
||||||
return domain.Message{
|
return domain.Message{
|
||||||
Media: media,
|
Media: media,
|
||||||
ID: int(row.BoxID),
|
ID: int(row.BoxID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Pts: int(row.Pts),
|
Pts: int(row.Pts),
|
||||||
|
MediaUnread: row.MediaUnread,
|
||||||
|
ReactionUnread: row.ReactionUnread,
|
||||||
}, nil
|
}, 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{}, fmt.Errorf("decode forward message media: %w", err)
|
||||||
}
|
}
|
||||||
return domain.Message{
|
return domain.Message{
|
||||||
Media: media,
|
Media: media,
|
||||||
ID: int(row.BoxID),
|
ID: int(row.BoxID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Pts: int(row.Pts),
|
Pts: int(row.Pts),
|
||||||
|
MediaUnread: row.MediaUnread,
|
||||||
|
ReactionUnread: row.ReactionUnread,
|
||||||
}, nil
|
}, 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{}, fmt.Errorf("decode message media: %w", err)
|
||||||
}
|
}
|
||||||
return domain.Message{
|
return domain.Message{
|
||||||
Media: media,
|
Media: media,
|
||||||
ID: int(row.BoxID),
|
ID: int(row.BoxID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Pts: int(row.Pts),
|
Pts: int(row.Pts),
|
||||||
|
MediaUnread: row.MediaUnread,
|
||||||
|
ReactionUnread: row.ReactionUnread,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,9 @@ INSERT INTO message_boxes (
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media
|
media,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, sqlc.arg(entities_json)::jsonb,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, sqlc.arg(entities_json)::jsonb,
|
||||||
sqlc.arg(silent)::boolean,
|
sqlc.arg(silent)::boolean,
|
||||||
|
|
@ -184,7 +186,9 @@ INSERT INTO message_boxes (
|
||||||
sqlc.arg(fwd_from_name)::text,
|
sqlc.arg(fwd_from_name)::text,
|
||||||
sqlc.arg(fwd_date)::int,
|
sqlc.arg(fwd_date)::int,
|
||||||
sqlc.arg(pts)::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
|
RETURNING
|
||||||
box_id,
|
box_id,
|
||||||
|
|
@ -212,7 +216,9 @@ RETURNING
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json;
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread;
|
||||||
|
|
||||||
-- name: GetMessageBoxByPrivateMessage :one
|
-- name: GetMessageBoxByPrivateMessage :one
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -241,7 +247,9 @@ SELECT
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
FROM message_boxes
|
FROM message_boxes
|
||||||
WHERE owner_user_id = $1
|
WHERE owner_user_id = $1
|
||||||
AND private_message_id = $2
|
AND private_message_id = $2
|
||||||
|
|
@ -295,7 +303,9 @@ SELECT
|
||||||
m.fwd_from_name,
|
m.fwd_from_name,
|
||||||
m.fwd_date,
|
m.fwd_date,
|
||||||
m.pts,
|
m.pts,
|
||||||
m.media::text AS media_json
|
m.media::text AS media_json,
|
||||||
|
m.media_unread,
|
||||||
|
m.reaction_unread
|
||||||
FROM requested r
|
FROM requested r
|
||||||
JOIN message_boxes m
|
JOIN message_boxes m
|
||||||
ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint
|
ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint
|
||||||
|
|
@ -351,6 +361,8 @@ base AS NOT MATERIALIZED (
|
||||||
m.fwd_date,
|
m.fwd_date,
|
||||||
m.pts,
|
m.pts,
|
||||||
m.media::text AS media_json,
|
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.id, 0)::bigint AS peer_user_id,
|
||||||
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
||||||
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
||||||
|
|
@ -483,6 +495,8 @@ SELECT
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media_json,
|
media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread,
|
||||||
peer_user_id,
|
peer_user_id,
|
||||||
peer_access_hash,
|
peer_access_hash,
|
||||||
peer_phone,
|
peer_phone,
|
||||||
|
|
@ -537,6 +551,8 @@ SELECT
|
||||||
m.fwd_date,
|
m.fwd_date,
|
||||||
m.pts,
|
m.pts,
|
||||||
m.media::text AS media_json,
|
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.id, 0)::bigint AS peer_user_id,
|
||||||
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
||||||
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
||||||
|
|
@ -594,7 +610,9 @@ SELECT
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
FROM message_boxes
|
FROM message_boxes
|
||||||
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
|
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
|
||||||
AND box_id = sqlc.arg(box_id)::int
|
AND box_id = sqlc.arg(box_id)::int
|
||||||
|
|
@ -632,7 +650,9 @@ SELECT
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
FROM message_boxes
|
FROM message_boxes
|
||||||
WHERE message_sender_id = sqlc.arg(message_sender_id)::bigint
|
WHERE message_sender_id = sqlc.arg(message_sender_id)::bigint
|
||||||
AND private_message_id = sqlc.arg(private_message_id)::bigint
|
AND private_message_id = sqlc.arg(private_message_id)::bigint
|
||||||
|
|
@ -684,7 +704,9 @@ RETURNING
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json;
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread;
|
||||||
|
|
||||||
-- name: GetDialogReadStateForUpdate :one
|
-- name: GetDialogReadStateForUpdate :one
|
||||||
SELECT
|
SELECT
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ SELECT
|
||||||
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
||||||
COALESCE(m.fwd_date, 0)::int AS fwd_date,
|
COALESCE(m.fwd_date, 0)::int AS fwd_date,
|
||||||
COALESCE(m.media::text, '{}')::text AS media_json,
|
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.id, 0)::bigint AS peer_user_id,
|
||||||
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
||||||
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
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_from_name, '')::text AS fwd_from_name,
|
||||||
COALESCE(m.fwd_date, 0)::int AS fwd_date,
|
COALESCE(m.fwd_date, 0)::int AS fwd_date,
|
||||||
COALESCE(m.media::text, '{}')::text AS media_json,
|
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.id, 0)::bigint AS peer_user_id,
|
||||||
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
||||||
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,9 @@ INSERT INTO message_boxes (
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media
|
media,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb,
|
||||||
$12::boolean,
|
$12::boolean,
|
||||||
|
|
@ -190,7 +192,9 @@ INSERT INTO message_boxes (
|
||||||
$23::text,
|
$23::text,
|
||||||
$24::int,
|
$24::int,
|
||||||
$25::int,
|
$25::int,
|
||||||
$26::jsonb
|
$26::jsonb,
|
||||||
|
$27::boolean,
|
||||||
|
$28::boolean
|
||||||
)
|
)
|
||||||
RETURNING
|
RETURNING
|
||||||
box_id,
|
box_id,
|
||||||
|
|
@ -218,7 +222,9 @@ RETURNING
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateMessageBoxParams struct {
|
type CreateMessageBoxParams struct {
|
||||||
|
|
@ -248,6 +254,8 @@ type CreateMessageBoxParams struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson []byte
|
MediaJson []byte
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateMessageBoxRow struct {
|
type CreateMessageBoxRow struct {
|
||||||
|
|
@ -277,6 +285,8 @@ type CreateMessageBoxRow struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxParams) (CreateMessageBoxRow, error) {
|
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.FwdDate,
|
||||||
arg.Pts,
|
arg.Pts,
|
||||||
arg.MediaJson,
|
arg.MediaJson,
|
||||||
|
arg.MediaUnread,
|
||||||
|
arg.ReactionUnread,
|
||||||
)
|
)
|
||||||
var i CreateMessageBoxRow
|
var i CreateMessageBoxRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
|
|
@ -336,6 +348,8 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara
|
||||||
&i.FwdDate,
|
&i.FwdDate,
|
||||||
&i.Pts,
|
&i.Pts,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -845,7 +859,9 @@ SELECT
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
FROM message_boxes
|
FROM message_boxes
|
||||||
WHERE owner_user_id = $1
|
WHERE owner_user_id = $1
|
||||||
AND private_message_id = $2
|
AND private_message_id = $2
|
||||||
|
|
@ -884,6 +900,8 @@ type GetMessageBoxByPrivateMessageRow struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMessageBoxByPrivateMessageParams) (GetMessageBoxByPrivateMessageRow, error) {
|
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.FwdDate,
|
||||||
&i.Pts,
|
&i.Pts,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -948,7 +968,9 @@ SELECT
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
FROM message_boxes
|
FROM message_boxes
|
||||||
WHERE owner_user_id = $1::bigint
|
WHERE owner_user_id = $1::bigint
|
||||||
AND box_id = $2::int
|
AND box_id = $2::int
|
||||||
|
|
@ -994,6 +1016,8 @@ type GetMessageBoxForEditRow struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxForEditParams) (GetMessageBoxForEditRow, error) {
|
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.FwdDate,
|
||||||
&i.Pts,
|
&i.Pts,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -1104,6 +1130,8 @@ SELECT
|
||||||
m.fwd_date,
|
m.fwd_date,
|
||||||
m.pts,
|
m.pts,
|
||||||
m.media::text AS media_json,
|
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.id, 0)::bigint AS peer_user_id,
|
||||||
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
||||||
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
||||||
|
|
@ -1167,6 +1195,8 @@ type GetMessageBoxesByIDsRow struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
PeerUserID int64
|
PeerUserID int64
|
||||||
PeerAccessHash int64
|
PeerAccessHash int64
|
||||||
PeerPhone string
|
PeerPhone string
|
||||||
|
|
@ -1226,6 +1256,8 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB
|
||||||
&i.FwdDate,
|
&i.FwdDate,
|
||||||
&i.Pts,
|
&i.Pts,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
&i.PeerUserID,
|
&i.PeerUserID,
|
||||||
&i.PeerAccessHash,
|
&i.PeerAccessHash,
|
||||||
&i.PeerPhone,
|
&i.PeerPhone,
|
||||||
|
|
@ -1292,7 +1324,9 @@ SELECT
|
||||||
m.fwd_from_name,
|
m.fwd_from_name,
|
||||||
m.fwd_date,
|
m.fwd_date,
|
||||||
m.pts,
|
m.pts,
|
||||||
m.media::text AS media_json
|
m.media::text AS media_json,
|
||||||
|
m.media_unread,
|
||||||
|
m.reaction_unread
|
||||||
FROM requested r
|
FROM requested r
|
||||||
JOIN message_boxes m
|
JOIN message_boxes m
|
||||||
ON m.owner_user_id = $1::bigint
|
ON m.owner_user_id = $1::bigint
|
||||||
|
|
@ -1339,6 +1373,8 @@ type GetMessageBoxesForForwardRow struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageBoxesForForwardParams) ([]GetMessageBoxesForForwardRow, error) {
|
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.FwdDate,
|
||||||
&i.Pts,
|
&i.Pts,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -1633,6 +1671,8 @@ base AS NOT MATERIALIZED (
|
||||||
m.fwd_date,
|
m.fwd_date,
|
||||||
m.pts,
|
m.pts,
|
||||||
m.media::text AS media_json,
|
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.id, 0)::bigint AS peer_user_id,
|
||||||
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
||||||
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
||||||
|
|
@ -1675,7 +1715,7 @@ total AS (
|
||||||
WHERE $12::boolean
|
WHERE $12::boolean
|
||||||
),
|
),
|
||||||
backward AS (
|
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
|
FROM base b
|
||||||
CROSS JOIN load_params p
|
CROSS JOIN load_params p
|
||||||
WHERE p.load_type = 'backward'
|
WHERE p.load_type = 'backward'
|
||||||
|
|
@ -1688,9 +1728,9 @@ backward AS (
|
||||||
LIMIT (SELECT limit_count FROM load_params)
|
LIMIT (SELECT limit_count FROM load_params)
|
||||||
),
|
),
|
||||||
around_forward AS (
|
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 (
|
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
|
FROM base b
|
||||||
CROSS JOIN load_params p
|
CROSS JOIN load_params p
|
||||||
WHERE p.load_type = 'around'
|
WHERE p.load_type = 'around'
|
||||||
|
|
@ -1703,7 +1743,7 @@ around_forward AS (
|
||||||
) f
|
) f
|
||||||
),
|
),
|
||||||
around_backward AS (
|
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
|
FROM base b
|
||||||
CROSS JOIN load_params p
|
CROSS JOIN load_params p
|
||||||
WHERE p.load_type = 'around'
|
WHERE p.load_type = 'around'
|
||||||
|
|
@ -1715,9 +1755,9 @@ around_backward AS (
|
||||||
LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0)
|
LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0)
|
||||||
),
|
),
|
||||||
forward AS (
|
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 (
|
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
|
FROM base b
|
||||||
CROSS JOIN load_params p
|
CROSS JOIN load_params p
|
||||||
WHERE p.load_type = 'forward'
|
WHERE p.load_type = 'forward'
|
||||||
|
|
@ -1730,13 +1770,13 @@ forward AS (
|
||||||
) f
|
) f
|
||||||
),
|
),
|
||||||
paged AS (
|
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
|
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
|
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
|
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
|
SELECT
|
||||||
box_id,
|
box_id,
|
||||||
|
|
@ -1765,6 +1805,8 @@ SELECT
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media_json,
|
media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread,
|
||||||
peer_user_id,
|
peer_user_id,
|
||||||
peer_access_hash,
|
peer_access_hash,
|
||||||
peer_phone,
|
peer_phone,
|
||||||
|
|
@ -1833,6 +1875,8 @@ type ListMessagesByUserRow struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
PeerUserID int64
|
PeerUserID int64
|
||||||
PeerAccessHash int64
|
PeerAccessHash int64
|
||||||
PeerPhone string
|
PeerPhone string
|
||||||
|
|
@ -1905,6 +1949,8 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
|
||||||
&i.FwdDate,
|
&i.FwdDate,
|
||||||
&i.Pts,
|
&i.Pts,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
&i.PeerUserID,
|
&i.PeerUserID,
|
||||||
&i.PeerAccessHash,
|
&i.PeerAccessHash,
|
||||||
&i.PeerPhone,
|
&i.PeerPhone,
|
||||||
|
|
@ -1965,7 +2011,9 @@ SELECT
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
FROM message_boxes
|
FROM message_boxes
|
||||||
WHERE message_sender_id = $1::bigint
|
WHERE message_sender_id = $1::bigint
|
||||||
AND private_message_id = $2::bigint
|
AND private_message_id = $2::bigint
|
||||||
|
|
@ -2007,6 +2055,8 @@ type ListVisibleMessageBoxesByPrivateMessageRow struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, arg ListVisibleMessageBoxesByPrivateMessageParams) ([]ListVisibleMessageBoxesByPrivateMessageRow, error) {
|
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.FwdDate,
|
||||||
&i.Pts,
|
&i.Pts,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -2218,7 +2270,9 @@ RETURNING
|
||||||
fwd_from_name,
|
fwd_from_name,
|
||||||
fwd_date,
|
fwd_date,
|
||||||
pts,
|
pts,
|
||||||
media::text AS media_json
|
media::text AS media_json,
|
||||||
|
media_unread,
|
||||||
|
reaction_unread
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateMessageBoxEditParams struct {
|
type UpdateMessageBoxEditParams struct {
|
||||||
|
|
@ -2258,6 +2312,8 @@ type UpdateMessageBoxEditRow struct {
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Pts int32
|
Pts int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBoxEditParams) (UpdateMessageBoxEditRow, error) {
|
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.FwdDate,
|
||||||
&i.Pts,
|
&i.Pts,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,7 @@ type ChannelUnreadMention struct {
|
||||||
MessageID int32
|
MessageID int32
|
||||||
TopMessageID int32
|
TopMessageID int32
|
||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
|
MediaUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelUpdateEvent struct {
|
type ChannelUpdateEvent struct {
|
||||||
|
|
@ -357,6 +358,13 @@ type Contact struct {
|
||||||
StoriesHidden bool
|
StoriesHidden bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContactBlock struct {
|
||||||
|
OwnerUserID int64
|
||||||
|
BlockedUserID int64
|
||||||
|
Date int32
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type Country struct {
|
type Country struct {
|
||||||
Iso2 string
|
Iso2 string
|
||||||
DefaultName string
|
DefaultName string
|
||||||
|
|
@ -514,6 +522,8 @@ type MessageBox struct {
|
||||||
FwdFromName string
|
FwdFromName string
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
Media []byte
|
Media []byte
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Photo struct {
|
type Photo struct {
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,8 @@ SELECT
|
||||||
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
||||||
COALESCE(m.fwd_date, 0)::int AS fwd_date,
|
COALESCE(m.fwd_date, 0)::int AS fwd_date,
|
||||||
COALESCE(m.media::text, '{}')::text AS media_json,
|
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.id, 0)::bigint AS peer_user_id,
|
||||||
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
||||||
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
||||||
|
|
@ -291,6 +293,8 @@ type BatchListDispatchEventsRow struct {
|
||||||
FwdFromName string
|
FwdFromName string
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
PeerUserID int64
|
PeerUserID int64
|
||||||
PeerAccessHash int64
|
PeerAccessHash int64
|
||||||
PeerPhone string
|
PeerPhone string
|
||||||
|
|
@ -432,6 +436,8 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
|
||||||
&i.FwdFromName,
|
&i.FwdFromName,
|
||||||
&i.FwdDate,
|
&i.FwdDate,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
&i.PeerUserID,
|
&i.PeerUserID,
|
||||||
&i.PeerAccessHash,
|
&i.PeerAccessHash,
|
||||||
&i.PeerPhone,
|
&i.PeerPhone,
|
||||||
|
|
@ -737,6 +743,8 @@ SELECT
|
||||||
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
||||||
COALESCE(m.fwd_date, 0)::int AS fwd_date,
|
COALESCE(m.fwd_date, 0)::int AS fwd_date,
|
||||||
COALESCE(m.media::text, '{}')::text AS media_json,
|
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.id, 0)::bigint AS peer_user_id,
|
||||||
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
|
||||||
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
COALESCE(peer_u.phone, '')::text AS peer_phone,
|
||||||
|
|
@ -885,6 +893,8 @@ type ListUserUpdateEventsAfterRow struct {
|
||||||
FwdFromName string
|
FwdFromName string
|
||||||
FwdDate int32
|
FwdDate int32
|
||||||
MediaJson string
|
MediaJson string
|
||||||
|
MediaUnread bool
|
||||||
|
ReactionUnread bool
|
||||||
PeerUserID int64
|
PeerUserID int64
|
||||||
PeerAccessHash int64
|
PeerAccessHash int64
|
||||||
PeerPhone string
|
PeerPhone string
|
||||||
|
|
@ -1024,6 +1034,8 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
|
||||||
&i.FwdFromName,
|
&i.FwdFromName,
|
||||||
&i.FwdDate,
|
&i.FwdDate,
|
||||||
&i.MediaJson,
|
&i.MediaJson,
|
||||||
|
&i.MediaUnread,
|
||||||
|
&i.ReactionUnread,
|
||||||
&i.PeerUserID,
|
&i.PeerUserID,
|
||||||
&i.PeerAccessHash,
|
&i.PeerAccessHash,
|
||||||
&i.PeerPhone,
|
&i.PeerPhone,
|
||||||
|
|
|
||||||
|
|
@ -260,21 +260,23 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
|
||||||
FolderPeers: folderPeers,
|
FolderPeers: folderPeers,
|
||||||
TagsEnabled: row.TagsEnabled,
|
TagsEnabled: row.TagsEnabled,
|
||||||
Message: domain.Message{
|
Message: domain.Message{
|
||||||
ID: int(row.MessageID),
|
ID: int(row.MessageID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Media: media,
|
Media: media,
|
||||||
|
MediaUnread: row.MediaUnread,
|
||||||
|
ReactionUnread: row.ReactionUnread,
|
||||||
},
|
},
|
||||||
Users: usersFromUpdateEventRow(row),
|
Users: usersFromUpdateEventRow(row),
|
||||||
Channels: channelsFromUpdateEventRow(row),
|
Channels: channelsFromUpdateEventRow(row),
|
||||||
|
|
@ -519,21 +521,23 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
|
||||||
FolderPeers: folderPeers,
|
FolderPeers: folderPeers,
|
||||||
TagsEnabled: row.TagsEnabled,
|
TagsEnabled: row.TagsEnabled,
|
||||||
Message: domain.Message{
|
Message: domain.Message{
|
||||||
ID: int(row.MessageID),
|
ID: int(row.MessageID),
|
||||||
UID: row.PrivateMessageID,
|
UID: row.PrivateMessageID,
|
||||||
OwnerUserID: row.OwnerUserID,
|
OwnerUserID: row.OwnerUserID,
|
||||||
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
||||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
||||||
Date: int(row.MessageDate),
|
Date: int(row.MessageDate),
|
||||||
EditDate: int(row.EditDate),
|
EditDate: int(row.EditDate),
|
||||||
Out: row.Outgoing,
|
Out: row.Outgoing,
|
||||||
Silent: silent,
|
Silent: silent,
|
||||||
NoForwards: noforwards,
|
NoForwards: noforwards,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
ReplyTo: reply,
|
ReplyTo: reply,
|
||||||
Forward: forward,
|
Forward: forward,
|
||||||
Media: media,
|
Media: media,
|
||||||
|
MediaUnread: row.MediaUnread,
|
||||||
|
ReactionUnread: row.ReactionUnread,
|
||||||
},
|
},
|
||||||
Users: usersFromBatchDispatchRow(row),
|
Users: usersFromBatchDispatchRow(row),
|
||||||
Channels: channelsFromBatchDispatchRow(row),
|
Channels: channelsFromBatchDispatchRow(row),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue