1078 lines
35 KiB
Go
1078 lines
35 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
// UpdateEventStore 用 PostgreSQL 实现 store.UpdateEventStore。
|
|
type UpdateEventStore struct {
|
|
db sqlcgen.DBTX
|
|
q *sqlcgen.Queries
|
|
log *zap.Logger
|
|
}
|
|
|
|
type UpdateEventStoreOption func(*UpdateEventStore)
|
|
|
|
// WithUpdateEventLogger 注入 durable update log 的日志器。
|
|
func WithUpdateEventLogger(log *zap.Logger) UpdateEventStoreOption {
|
|
return func(s *UpdateEventStore) {
|
|
s.log = log
|
|
}
|
|
}
|
|
|
|
// NewUpdateEventStore 基于 pgx 连接池(或事务)创建 UpdateEventStore。
|
|
func NewUpdateEventStore(db sqlcgen.DBTX, opts ...UpdateEventStoreOption) *UpdateEventStore {
|
|
s := &UpdateEventStore{db: db, q: sqlcgen.New(db)}
|
|
for _, opt := range opts {
|
|
opt(s)
|
|
}
|
|
if s.log == nil {
|
|
s.log = zap.NewNop()
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *UpdateEventStore) Append(ctx context.Context, userID int64, event domain.UpdateEvent) error {
|
|
_, err := s.append(ctx, userID, event, false, [8]byte{}, 0, false)
|
|
return err
|
|
}
|
|
|
|
func (s *UpdateEventStore) AppendAllocated(ctx context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, error) {
|
|
return s.append(ctx, userID, event, false, [8]byte{}, 0, true)
|
|
}
|
|
|
|
// AppendAllocatedWithDispatch 在同一个 PG 事务里完成 pts 分配、durable event 写入与
|
|
// dispatch outbox 入队,返回带最终 pts 的事件。
|
|
func (s *UpdateEventStore) AppendAllocatedWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) {
|
|
return s.append(ctx, userID, event, true, excludeAuthKeyID, excludeSessionID, true)
|
|
}
|
|
|
|
func (s *UpdateEventStore) append(ctx context.Context, userID int64, event domain.UpdateEvent, dispatch bool, excludeAuthKeyID [8]byte, excludeSessionID int64, allocate bool) (domain.UpdateEvent, error) {
|
|
if event.PtsCount <= 0 {
|
|
event.PtsCount = 1
|
|
}
|
|
beginner, ok := s.db.(interface {
|
|
Begin(context.Context) (pgx.Tx, error)
|
|
})
|
|
if !ok {
|
|
event, err := s.appendInTx(ctx, s.db, s.q, userID, event, dispatch, excludeAuthKeyID, excludeSessionID, allocate)
|
|
if err != nil {
|
|
s.logAppendFailure(ctx, userID, event, dispatch, "append", err)
|
|
return domain.UpdateEvent{}, err
|
|
}
|
|
s.logAppendSuccess(userID, event, dispatch, excludeSessionID)
|
|
return event, nil
|
|
}
|
|
tx, err := beginner.Begin(ctx)
|
|
if err != nil {
|
|
s.log.Warn("update_event_append_failed",
|
|
zap.String("scope", "user"),
|
|
zap.Int64("user_id", userID),
|
|
zap.Int("pts", event.Pts),
|
|
zap.Int("pts_count", event.PtsCount),
|
|
zap.String("event_type", string(event.Type)),
|
|
zap.String("phase", "begin"),
|
|
zap.Error(err),
|
|
zap.Error(ctx.Err()),
|
|
)
|
|
return domain.UpdateEvent{}, fmt.Errorf("begin append update event: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback(ctx)
|
|
}
|
|
}()
|
|
qtx := sqlcgen.New(tx)
|
|
event, err = s.appendInTx(ctx, tx, qtx, userID, event, dispatch, excludeAuthKeyID, excludeSessionID, allocate)
|
|
if err != nil {
|
|
s.logAppendFailure(ctx, userID, event, dispatch, "append", err)
|
|
return domain.UpdateEvent{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
s.logAppendFailure(ctx, userID, event, dispatch, "commit", err)
|
|
return domain.UpdateEvent{}, fmt.Errorf("commit append update event: %w", err)
|
|
}
|
|
committed = true
|
|
s.logAppendSuccess(userID, event, dispatch, excludeSessionID)
|
|
return event, nil
|
|
}
|
|
|
|
func (s *UpdateEventStore) appendInTx(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent, dispatch bool, excludeAuthKeyID [8]byte, excludeSessionID int64, allocate bool) (domain.UpdateEvent, error) {
|
|
var err error
|
|
if allocate || event.Pts == 0 {
|
|
event.Pts, err = reserveUserPts(ctx, db, userID, event.PtsCount)
|
|
if err != nil {
|
|
return domain.UpdateEvent{}, fmt.Errorf("reserve user pts: %w", err)
|
|
}
|
|
} else if err := advanceUserPtsTo(ctx, db, userID, event.Pts, event.PtsCount); err != nil {
|
|
return domain.UpdateEvent{}, err
|
|
}
|
|
event.UserID = userID
|
|
if err := appendUserUpdateEvent(ctx, db, q, userID, event); err != nil {
|
|
return domain.UpdateEvent{}, fmt.Errorf("append update event: %w", err)
|
|
}
|
|
if dispatch {
|
|
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
|
|
TargetUserID: userID,
|
|
Pts: int32(event.Pts),
|
|
EventType: string(event.Type),
|
|
ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID),
|
|
ExcludeSessionID: excludeSessionID,
|
|
}); err != nil {
|
|
return domain.UpdateEvent{}, fmt.Errorf("enqueue dispatch: %w", err)
|
|
}
|
|
}
|
|
return event, nil
|
|
}
|
|
|
|
func (s *UpdateEventStore) logAppendFailure(ctx context.Context, userID int64, event domain.UpdateEvent, dispatch bool, phase string, err error) {
|
|
s.log.Warn("update_event_append_failed",
|
|
zap.String("scope", "user"),
|
|
zap.Int64("user_id", userID),
|
|
zap.Int("pts", event.Pts),
|
|
zap.Int("pts_count", event.PtsCount),
|
|
zap.String("event_type", string(event.Type)),
|
|
zap.String("phase", phase),
|
|
zap.Bool("dispatch", dispatch),
|
|
zap.Error(err),
|
|
zap.Error(ctx.Err()),
|
|
)
|
|
}
|
|
|
|
func (s *UpdateEventStore) logAppendSuccess(userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) {
|
|
s.log.Debug("update_event_appended",
|
|
zap.String("scope", "user"),
|
|
zap.Int64("user_id", userID),
|
|
zap.Int("pts", event.Pts),
|
|
zap.Int("pts_count", event.PtsCount),
|
|
zap.String("event_type", string(event.Type)),
|
|
zap.Bool("dispatch", dispatch),
|
|
zap.Int64("exclude_session_id", excludeSessionID),
|
|
)
|
|
}
|
|
|
|
func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent) error {
|
|
var messageID *int32
|
|
if event.Message.ID != 0 {
|
|
id := int32(event.Message.ID)
|
|
messageID = &id
|
|
}
|
|
var peerType *string
|
|
var peerID *int64
|
|
peer := event.Peer
|
|
if peer.ID == 0 {
|
|
peer = event.Message.Peer
|
|
}
|
|
if peer.ID != 0 {
|
|
t := string(peer.Type)
|
|
id := peer.ID
|
|
peerType = &t
|
|
peerID = &id
|
|
}
|
|
peers, err := encodeEventPeers(event.Peers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
settings, err := encodePeerSettings(event.Settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
messageIDs, err := encodeEventMessageIDs(event.MessageIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dialogFilter, err := encodeEventDialogFilter(event.DialogFilter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
filterOrder, err := encodeEventFilterOrder(event.FilterOrder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
folderPeers, err := encodeEventFolderPeers(event.FolderPeers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
storyPayload, err := encodeEventStory(event.Story)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reactionPayload, err := encodeEventReaction(event.Reaction)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
emojiStatusPayload, err := encodeEventEmojiStatus(event.EmojiStatus)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
|
|
UserID: userID,
|
|
Pts: int32(event.Pts),
|
|
PtsCount: int32(event.PtsCount),
|
|
Date: int32(event.Date),
|
|
EventType: string(event.Type),
|
|
EventBool: event.Bool,
|
|
EventPhone: event.Phone,
|
|
EventPeers: peers,
|
|
PeerSettings: settings,
|
|
MessageIds: messageIDs,
|
|
DialogFilter: dialogFilter,
|
|
FilterOrder: filterOrder,
|
|
FolderPeers: folderPeers,
|
|
StoryPayload: storyPayload,
|
|
ReactionPayload: reactionPayload,
|
|
EmojiStatusPayload: emojiStatusPayload,
|
|
MaxID: pgInt32NonNegative(event.MaxID),
|
|
StillUnreadCount: int32(event.StillUnreadCount),
|
|
ChannelPts: int32(event.ChannelPts),
|
|
FilterID: pgInt32NonNegative(event.FilterID),
|
|
TagsEnabled: event.TagsEnabled,
|
|
FolderID: pgInt32NonNegative(event.FolderID),
|
|
MessageBoxID: messageID,
|
|
PeerType: peerType,
|
|
PeerID: peerID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := appendQuickReplyPayload(ctx, db, userID, event); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func appendQuickReplyPayload(ctx context.Context, db sqlcgen.DBTX, userID int64, event domain.UpdateEvent) error {
|
|
switch event.Type {
|
|
case domain.UpdateEventQuickReplies,
|
|
domain.UpdateEventNewQuickReply,
|
|
domain.UpdateEventDeleteQuickReply,
|
|
domain.UpdateEventQuickReplyMessage,
|
|
domain.UpdateEventDeleteQuickReplyMessages:
|
|
default:
|
|
return nil
|
|
}
|
|
replies, err := json.Marshal(event.QuickReplies)
|
|
if err != nil {
|
|
return fmt.Errorf("encode quick replies: %w", err)
|
|
}
|
|
message, err := json.Marshal(event.QuickReplyMessage)
|
|
if err != nil {
|
|
return fmt.Errorf("encode quick reply message: %w", err)
|
|
}
|
|
if _, err := db.Exec(ctx, `
|
|
UPDATE user_update_events
|
|
SET quick_replies = $3::jsonb,
|
|
quick_reply_message = $4::jsonb
|
|
WHERE user_id = $1
|
|
AND pts = $2`, userID, event.Pts, string(replies), string(message)); err != nil {
|
|
return fmt.Errorf("save quick reply update payload: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *UpdateEventStore) hydrateQuickReplyEvent(ctx context.Context, event *domain.UpdateEvent) error {
|
|
if event == nil {
|
|
return nil
|
|
}
|
|
switch event.Type {
|
|
case domain.UpdateEventQuickReplies,
|
|
domain.UpdateEventNewQuickReply,
|
|
domain.UpdateEventQuickReplyMessage,
|
|
domain.UpdateEventDeleteQuickReply:
|
|
default:
|
|
return nil
|
|
}
|
|
var repliesJSON, messageJSON string
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT
|
|
COALESCE(quick_replies::text, '[]')::text,
|
|
COALESCE(quick_reply_message::text, '{}')::text
|
|
FROM user_update_events
|
|
WHERE user_id = $1
|
|
AND pts = $2`, event.UserID, event.Pts).Scan(&repliesJSON, &messageJSON); err != nil {
|
|
return fmt.Errorf("get quick reply update payload: %w", err)
|
|
}
|
|
if err := json.Unmarshal([]byte(repliesJSON), &event.QuickReplies); err != nil {
|
|
return fmt.Errorf("decode quick replies: %w", err)
|
|
}
|
|
if err := json.Unmarshal([]byte(messageJSON), &event.QuickReplyMessage); err != nil {
|
|
return fmt.Errorf("decode quick reply message: %w", err)
|
|
}
|
|
if event.Type == domain.UpdateEventNewQuickReply && event.QuickReply.ID == 0 {
|
|
for _, item := range event.QuickReplies {
|
|
if item.ID == event.MaxID {
|
|
event.QuickReply = item
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
rows, err := s.q.ListUserUpdateEventsAfter(ctx, sqlcgen.ListUserUpdateEventsAfterParams{
|
|
UserID: userID,
|
|
Pts: int32(pts),
|
|
LimitCount: int32(limit),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list update events: %w", err)
|
|
}
|
|
out := make([]domain.UpdateEvent, 0, len(rows))
|
|
for _, row := range rows {
|
|
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message entities: %w", err)
|
|
}
|
|
silent, noforwards, reply, forward, err := messageMetadataFromFields(
|
|
row.Silent,
|
|
row.Noforwards,
|
|
row.ReplyToMsgID,
|
|
row.ReplyToPeerType,
|
|
row.ReplyToPeerID,
|
|
row.ReplyToTopID,
|
|
row.ReplyToStoryID,
|
|
row.QuoteText,
|
|
row.QuoteEntitiesJson,
|
|
row.QuoteOffset,
|
|
row.FwdFromPeerType,
|
|
row.FwdFromPeerID,
|
|
row.FwdFromName,
|
|
row.FwdDate,
|
|
row.FwdSavedFromPeerType,
|
|
row.FwdSavedFromPeerID,
|
|
row.FwdSavedFromMsgID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message metadata: %w", err)
|
|
}
|
|
peers, err := decodeEventPeers(row.EventPeersJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode event peers: %w", err)
|
|
}
|
|
settings, err := decodePeerSettings(row.PeerSettingsJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode peer settings: %w", err)
|
|
}
|
|
messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message ids: %w", err)
|
|
}
|
|
dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode dialog filter: %w", err)
|
|
}
|
|
filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode filter order: %w", err)
|
|
}
|
|
folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode folder peers: %w", err)
|
|
}
|
|
story, err := decodeEventStory(row.StoryPayloadJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode story payload: %w", err)
|
|
}
|
|
reaction, err := decodeEventReaction(row.ReactionPayloadJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode reaction payload: %w", err)
|
|
}
|
|
emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode emoji status payload: %w", err)
|
|
}
|
|
media, err := decodeMessageMedia(row.MediaJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message media: %w", err)
|
|
}
|
|
markup, err := decodeReplyMarkup(row.ReplyMarkupJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message reply markup: %w", err)
|
|
}
|
|
rich, err := decodeRichMessage(row.RichMessageJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message rich message: %w", err)
|
|
}
|
|
event := domain.UpdateEvent{
|
|
UserID: row.UserID,
|
|
Type: domain.UpdateEventType(row.EventType),
|
|
Pts: int(row.Pts),
|
|
PtsCount: int(row.PtsCount),
|
|
Date: int(row.Date),
|
|
Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID},
|
|
Story: story,
|
|
Peers: peers,
|
|
Bool: row.EventBool,
|
|
Phone: row.EventPhone,
|
|
Settings: settings,
|
|
MessageIDs: messageIDs,
|
|
MaxID: int(row.MaxID),
|
|
StillUnreadCount: int(row.StillUnreadCount),
|
|
ChannelPts: int(row.ChannelPts),
|
|
FilterID: int(row.FilterID),
|
|
DialogFilter: dialogFilter,
|
|
FilterOrder: filterOrder,
|
|
FolderPeers: folderPeers,
|
|
TagsEnabled: row.TagsEnabled,
|
|
FolderID: int(row.FolderID),
|
|
Reaction: reaction,
|
|
EmojiStatus: emojiStatus,
|
|
Message: domain.Message{
|
|
ID: int(row.MessageID),
|
|
UID: row.PrivateMessageID,
|
|
OwnerUserID: row.OwnerUserID,
|
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
|
Date: int(row.MessageDate),
|
|
EditDate: int(row.EditDate),
|
|
HideEdited: row.HideEdited,
|
|
Out: row.Outgoing,
|
|
Silent: silent,
|
|
NoForwards: noforwards,
|
|
Body: row.Body,
|
|
Entities: entities,
|
|
ReplyTo: reply,
|
|
Forward: forward,
|
|
Media: media,
|
|
ReplyMarkup: markup,
|
|
RichMessage: rich,
|
|
MediaUnread: row.MediaUnread,
|
|
ReactionUnread: row.ReactionUnread,
|
|
ViaBotID: row.ViaBotID,
|
|
GroupedID: row.GroupedID,
|
|
Effect: row.Effect,
|
|
Pinned: row.Pinned,
|
|
SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID),
|
|
TTLPeriod: int(row.TtlPeriod),
|
|
ExpiresAt: int(row.ExpiresAt),
|
|
},
|
|
Users: usersFromUpdateEventRow(row),
|
|
}
|
|
if err := s.hydrateQuickReplyEvent(ctx, &event); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, event)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *UpdateEventStore) FindNewMessageEvent(ctx context.Context, userID int64, messageBoxID int) (domain.UpdateEvent, bool, error) {
|
|
if userID == 0 || messageBoxID <= 0 {
|
|
return domain.UpdateEvent{}, false, nil
|
|
}
|
|
var pts int
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT pts
|
|
FROM user_update_events
|
|
WHERE user_id = $1
|
|
AND event_type = $2
|
|
AND message_box_id = $3
|
|
ORDER BY pts ASC
|
|
LIMIT 1`, userID, string(domain.UpdateEventNewMessage), messageBoxID).Scan(&pts); err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return domain.UpdateEvent{}, false, nil
|
|
}
|
|
return domain.UpdateEvent{}, false, fmt.Errorf("find new message update event: %w", err)
|
|
}
|
|
events, err := s.ListAfter(ctx, userID, pts-1, 1)
|
|
if err != nil {
|
|
return domain.UpdateEvent{}, false, err
|
|
}
|
|
if len(events) == 0 || events[0].Pts != pts {
|
|
return domain.UpdateEvent{}, false, fmt.Errorf("hydrate new message update event: expected pts %d", pts)
|
|
}
|
|
return events[0], true, nil
|
|
}
|
|
|
|
// MaxContiguousPts 见 store.UpdateEventStore 接口说明。PG 写路径保证水位与
|
|
// durable event 同事务提交;缺行代表该账号还没有 update。
|
|
func (s *UpdateEventStore) MaxContiguousPts(ctx context.Context, userID int64) (int, error) {
|
|
pts, err := s.q.GetUserUpdateWatermark(ctx, userID)
|
|
if err == nil {
|
|
return int(pts), nil
|
|
}
|
|
if err != pgx.ErrNoRows {
|
|
return 0, fmt.Errorf("get update watermark: %w", err)
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// BatchByCursor 按 (user_id, pts) 一次性批量取多条账号事件,供 outbox worker 取代逐条 ListAfter。
|
|
// 返回顺序不保证与 cursors 一致,调用方按 (UserID,Pts) 自行索引。
|
|
func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) {
|
|
if len(cursors) == 0 {
|
|
return nil, nil
|
|
}
|
|
userIDs := make([]int64, len(cursors))
|
|
ptsList := make([]int32, len(cursors))
|
|
for i, c := range cursors {
|
|
userIDs[i] = c.UserID
|
|
ptsList[i] = int32(c.Pts)
|
|
}
|
|
rows, err := s.q.BatchListDispatchEvents(ctx, sqlcgen.BatchListDispatchEventsParams{
|
|
UserIds: userIDs,
|
|
PtsList: ptsList,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("batch list dispatch events: %w", err)
|
|
}
|
|
out := make([]domain.UpdateEvent, 0, len(rows))
|
|
for _, row := range rows {
|
|
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message entities: %w", err)
|
|
}
|
|
silent, noforwards, reply, forward, err := messageMetadataFromFields(
|
|
row.Silent,
|
|
row.Noforwards,
|
|
row.ReplyToMsgID,
|
|
row.ReplyToPeerType,
|
|
row.ReplyToPeerID,
|
|
row.ReplyToTopID,
|
|
row.ReplyToStoryID,
|
|
row.QuoteText,
|
|
row.QuoteEntitiesJson,
|
|
row.QuoteOffset,
|
|
row.FwdFromPeerType,
|
|
row.FwdFromPeerID,
|
|
row.FwdFromName,
|
|
row.FwdDate,
|
|
row.FwdSavedFromPeerType,
|
|
row.FwdSavedFromPeerID,
|
|
row.FwdSavedFromMsgID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message metadata: %w", err)
|
|
}
|
|
peers, err := decodeEventPeers(row.EventPeersJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode event peers: %w", err)
|
|
}
|
|
settings, err := decodePeerSettings(row.PeerSettingsJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode peer settings: %w", err)
|
|
}
|
|
messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message ids: %w", err)
|
|
}
|
|
dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode dialog filter: %w", err)
|
|
}
|
|
filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode filter order: %w", err)
|
|
}
|
|
folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode folder peers: %w", err)
|
|
}
|
|
story, err := decodeEventStory(row.StoryPayloadJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode story payload: %w", err)
|
|
}
|
|
reaction, err := decodeEventReaction(row.ReactionPayloadJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode reaction payload: %w", err)
|
|
}
|
|
emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode emoji status payload: %w", err)
|
|
}
|
|
media, err := decodeMessageMedia(row.MediaJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message media: %w", err)
|
|
}
|
|
markup, err := decodeReplyMarkup(row.ReplyMarkupJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message reply markup: %w", err)
|
|
}
|
|
rich, err := decodeRichMessage(row.RichMessageJson)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode message rich message: %w", err)
|
|
}
|
|
event := domain.UpdateEvent{
|
|
UserID: row.UserID,
|
|
Type: domain.UpdateEventType(row.EventType),
|
|
Pts: int(row.Pts),
|
|
PtsCount: int(row.PtsCount),
|
|
Date: int(row.Date),
|
|
Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID},
|
|
Story: story,
|
|
Peers: peers,
|
|
Bool: row.EventBool,
|
|
Phone: row.EventPhone,
|
|
Settings: settings,
|
|
MessageIDs: messageIDs,
|
|
MaxID: int(row.MaxID),
|
|
StillUnreadCount: int(row.StillUnreadCount),
|
|
ChannelPts: int(row.ChannelPts),
|
|
FilterID: int(row.FilterID),
|
|
DialogFilter: dialogFilter,
|
|
FilterOrder: filterOrder,
|
|
FolderPeers: folderPeers,
|
|
TagsEnabled: row.TagsEnabled,
|
|
FolderID: int(row.FolderID),
|
|
Reaction: reaction,
|
|
EmojiStatus: emojiStatus,
|
|
Message: domain.Message{
|
|
ID: int(row.MessageID),
|
|
UID: row.PrivateMessageID,
|
|
OwnerUserID: row.OwnerUserID,
|
|
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
|
|
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
|
|
Date: int(row.MessageDate),
|
|
EditDate: int(row.EditDate),
|
|
HideEdited: row.HideEdited,
|
|
Out: row.Outgoing,
|
|
Silent: silent,
|
|
NoForwards: noforwards,
|
|
Body: row.Body,
|
|
Entities: entities,
|
|
ReplyTo: reply,
|
|
Forward: forward,
|
|
Media: media,
|
|
ReplyMarkup: markup,
|
|
RichMessage: rich,
|
|
MediaUnread: row.MediaUnread,
|
|
ReactionUnread: row.ReactionUnread,
|
|
ViaBotID: row.ViaBotID,
|
|
GroupedID: row.GroupedID,
|
|
Effect: row.Effect,
|
|
Pinned: row.Pinned,
|
|
SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID),
|
|
TTLPeriod: int(row.TtlPeriod),
|
|
ExpiresAt: int(row.ExpiresAt),
|
|
},
|
|
Users: usersFromBatchDispatchRow(row),
|
|
}
|
|
if err := s.hydrateQuickReplyEvent(ctx, &event); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, event)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.User {
|
|
return mergeEventUsers(
|
|
domain.User{
|
|
ID: row.PeerUserID,
|
|
AccessHash: row.PeerAccessHash,
|
|
Phone: row.PeerPhone,
|
|
FirstName: row.PeerFirstName,
|
|
LastName: row.PeerLastName,
|
|
Username: row.PeerUsername,
|
|
CountryCode: row.PeerCountryCode,
|
|
Verified: row.PeerVerified,
|
|
Support: row.PeerSupport,
|
|
Bot: row.PeerIsBot,
|
|
BotInfoVersion: int(row.PeerBotInfoVersion),
|
|
PremiumUntil: int(row.PeerPremiumUntil),
|
|
EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID,
|
|
EmojiStatusUntil: int(row.PeerEmojiStatusUntil),
|
|
},
|
|
domain.User{
|
|
ID: row.FromUserUserID,
|
|
AccessHash: row.FromUserAccessHash,
|
|
Phone: row.FromUserPhone,
|
|
FirstName: row.FromUserFirstName,
|
|
LastName: row.FromUserLastName,
|
|
Username: row.FromUserUsername,
|
|
CountryCode: row.FromUserCountryCode,
|
|
Verified: row.FromUserVerified,
|
|
Support: row.FromUserSupport,
|
|
Bot: row.FromUserIsBot,
|
|
BotInfoVersion: int(row.FromUserBotInfoVersion),
|
|
PremiumUntil: int(row.FromUserPremiumUntil),
|
|
EmojiStatusDocumentID: row.FromUserEmojiStatusDocumentID,
|
|
EmojiStatusUntil: int(row.FromUserEmojiStatusUntil),
|
|
},
|
|
domain.User{
|
|
ID: row.FwdUserID,
|
|
AccessHash: row.FwdUserAccessHash,
|
|
Phone: row.FwdUserPhone,
|
|
FirstName: row.FwdUserFirstName,
|
|
LastName: row.FwdUserLastName,
|
|
Username: row.FwdUserUsername,
|
|
CountryCode: row.FwdUserCountryCode,
|
|
Verified: row.FwdUserVerified,
|
|
Support: row.FwdUserSupport,
|
|
Bot: row.FwdUserIsBot,
|
|
BotInfoVersion: int(row.FwdUserBotInfoVersion),
|
|
PremiumUntil: int(row.FwdUserPremiumUntil),
|
|
EmojiStatusDocumentID: row.FwdUserEmojiStatusDocumentID,
|
|
EmojiStatusUntil: int(row.FwdUserEmojiStatusUntil),
|
|
},
|
|
domain.User{
|
|
ID: row.ReplyUserID,
|
|
AccessHash: row.ReplyUserAccessHash,
|
|
Phone: row.ReplyUserPhone,
|
|
FirstName: row.ReplyUserFirstName,
|
|
LastName: row.ReplyUserLastName,
|
|
Username: row.ReplyUserUsername,
|
|
CountryCode: row.ReplyUserCountryCode,
|
|
Verified: row.ReplyUserVerified,
|
|
Support: row.ReplyUserSupport,
|
|
Bot: row.ReplyUserIsBot,
|
|
BotInfoVersion: int(row.ReplyUserBotInfoVersion),
|
|
PremiumUntil: int(row.ReplyUserPremiumUntil),
|
|
EmojiStatusDocumentID: row.ReplyUserEmojiStatusDocumentID,
|
|
EmojiStatusUntil: int(row.ReplyUserEmojiStatusUntil),
|
|
},
|
|
)
|
|
}
|
|
|
|
// usersFromBatchDispatchRow 与 usersFromUpdateEventRow 等价,只是行类型为 BatchListDispatchEventsRow
|
|
// (两条查询列完全一致;改一处列时务必同步另一处)。
|
|
func usersFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.User {
|
|
return mergeEventUsers(
|
|
domain.User{
|
|
ID: row.PeerUserID,
|
|
AccessHash: row.PeerAccessHash,
|
|
Phone: row.PeerPhone,
|
|
FirstName: row.PeerFirstName,
|
|
LastName: row.PeerLastName,
|
|
Username: row.PeerUsername,
|
|
CountryCode: row.PeerCountryCode,
|
|
Verified: row.PeerVerified,
|
|
Support: row.PeerSupport,
|
|
Bot: row.PeerIsBot,
|
|
BotInfoVersion: int(row.PeerBotInfoVersion),
|
|
PremiumUntil: int(row.PeerPremiumUntil),
|
|
EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID,
|
|
EmojiStatusUntil: int(row.PeerEmojiStatusUntil),
|
|
},
|
|
domain.User{
|
|
ID: row.FromUserUserID,
|
|
AccessHash: row.FromUserAccessHash,
|
|
Phone: row.FromUserPhone,
|
|
FirstName: row.FromUserFirstName,
|
|
LastName: row.FromUserLastName,
|
|
Username: row.FromUserUsername,
|
|
CountryCode: row.FromUserCountryCode,
|
|
Verified: row.FromUserVerified,
|
|
Support: row.FromUserSupport,
|
|
Bot: row.FromUserIsBot,
|
|
BotInfoVersion: int(row.FromUserBotInfoVersion),
|
|
PremiumUntil: int(row.FromUserPremiumUntil),
|
|
EmojiStatusDocumentID: row.FromUserEmojiStatusDocumentID,
|
|
EmojiStatusUntil: int(row.FromUserEmojiStatusUntil),
|
|
},
|
|
domain.User{
|
|
ID: row.FwdUserID,
|
|
AccessHash: row.FwdUserAccessHash,
|
|
Phone: row.FwdUserPhone,
|
|
FirstName: row.FwdUserFirstName,
|
|
LastName: row.FwdUserLastName,
|
|
Username: row.FwdUserUsername,
|
|
CountryCode: row.FwdUserCountryCode,
|
|
Verified: row.FwdUserVerified,
|
|
Support: row.FwdUserSupport,
|
|
Bot: row.FwdUserIsBot,
|
|
BotInfoVersion: int(row.FwdUserBotInfoVersion),
|
|
PremiumUntil: int(row.FwdUserPremiumUntil),
|
|
EmojiStatusDocumentID: row.FwdUserEmojiStatusDocumentID,
|
|
EmojiStatusUntil: int(row.FwdUserEmojiStatusUntil),
|
|
},
|
|
domain.User{
|
|
ID: row.ReplyUserID,
|
|
AccessHash: row.ReplyUserAccessHash,
|
|
Phone: row.ReplyUserPhone,
|
|
FirstName: row.ReplyUserFirstName,
|
|
LastName: row.ReplyUserLastName,
|
|
Username: row.ReplyUserUsername,
|
|
CountryCode: row.ReplyUserCountryCode,
|
|
Verified: row.ReplyUserVerified,
|
|
Support: row.ReplyUserSupport,
|
|
Bot: row.ReplyUserIsBot,
|
|
BotInfoVersion: int(row.ReplyUserBotInfoVersion),
|
|
PremiumUntil: int(row.ReplyUserPremiumUntil),
|
|
EmojiStatusDocumentID: row.ReplyUserEmojiStatusDocumentID,
|
|
EmojiStatusUntil: int(row.ReplyUserEmojiStatusUntil),
|
|
},
|
|
)
|
|
}
|
|
|
|
// mergeEventUsers 合并事件依赖用户,跳过 ID=0 并按 ID 去重。
|
|
func mergeEventUsers(items ...domain.User) []domain.User {
|
|
users := make([]domain.User, 0, len(items))
|
|
add := func(u domain.User) {
|
|
if u.ID == 0 {
|
|
return
|
|
}
|
|
for _, existing := range users {
|
|
if existing.ID == u.ID {
|
|
return
|
|
}
|
|
}
|
|
users = append(users, u)
|
|
}
|
|
for _, item := range items {
|
|
add(item)
|
|
}
|
|
return users
|
|
}
|
|
|
|
type eventPeerJSON struct {
|
|
Type string `json:"type"`
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
func encodeEventPeers(peers []domain.Peer) ([]byte, error) {
|
|
if len(peers) == 0 {
|
|
return []byte("[]"), nil
|
|
}
|
|
wire := make([]eventPeerJSON, 0, len(peers))
|
|
for _, peer := range peers {
|
|
if peer.ID == 0 {
|
|
continue
|
|
}
|
|
wire = append(wire, eventPeerJSON{Type: string(peer.Type), ID: peer.ID})
|
|
}
|
|
raw, err := json.Marshal(wire)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal event peers: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodeEventPeers(raw string) ([]domain.Peer, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
var wire []eventPeerJSON
|
|
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]domain.Peer, 0, len(wire))
|
|
for _, peer := range wire {
|
|
if peer.ID == 0 {
|
|
continue
|
|
}
|
|
out = append(out, domain.Peer{Type: domain.PeerType(peer.Type), ID: peer.ID})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func encodeEventMessageIDs(ids []int) ([]byte, error) {
|
|
if len(ids) == 0 {
|
|
return []byte("[]"), nil
|
|
}
|
|
raw, err := json.Marshal(ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal event message ids: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodeEventMessageIDs(raw string) ([]int, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
var ids []int
|
|
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func encodeEventDialogFilter(folder *domain.DialogFolder) ([]byte, error) {
|
|
if folder == nil {
|
|
return []byte("{}"), nil
|
|
}
|
|
raw, err := json.Marshal(folder)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal event dialog filter: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodeEventDialogFilter(raw string) (*domain.DialogFolder, error) {
|
|
if raw == "" || raw == "{}" {
|
|
return nil, nil
|
|
}
|
|
var folder domain.DialogFolder
|
|
if err := json.Unmarshal([]byte(raw), &folder); err != nil {
|
|
return nil, err
|
|
}
|
|
return &folder, nil
|
|
}
|
|
|
|
func encodeEventFilterOrder(order []int) ([]byte, error) {
|
|
if len(order) == 0 {
|
|
return []byte("[]"), nil
|
|
}
|
|
raw, err := json.Marshal(order)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal event filter order: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodeEventFilterOrder(raw string) ([]int, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
var order []int
|
|
if err := json.Unmarshal([]byte(raw), &order); err != nil {
|
|
return nil, err
|
|
}
|
|
return order, nil
|
|
}
|
|
|
|
func encodeEventFolderPeers(peers []domain.FolderPeerUpdate) ([]byte, error) {
|
|
if len(peers) == 0 {
|
|
return []byte("[]"), nil
|
|
}
|
|
raw, err := json.Marshal(peers)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal event folder peers: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodeEventFolderPeers(raw string) ([]domain.FolderPeerUpdate, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
var peers []domain.FolderPeerUpdate
|
|
if err := json.Unmarshal([]byte(raw), &peers); err != nil {
|
|
return nil, err
|
|
}
|
|
return peers, nil
|
|
}
|
|
|
|
func encodeEventStory(story domain.Story) ([]byte, error) {
|
|
if story.Owner.ID == 0 || story.ID == 0 {
|
|
return []byte("{}"), nil
|
|
}
|
|
raw, err := json.Marshal(story)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal event story: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodeEventStory(raw string) (domain.Story, error) {
|
|
if raw == "" || raw == "{}" || raw == "null" {
|
|
return domain.Story{}, nil
|
|
}
|
|
var story domain.Story
|
|
if err := json.Unmarshal([]byte(raw), &story); err != nil {
|
|
return domain.Story{}, err
|
|
}
|
|
if story.Owner.ID == 0 || story.ID == 0 {
|
|
return domain.Story{}, nil
|
|
}
|
|
return story, nil
|
|
}
|
|
|
|
func encodeEventReaction(reaction *domain.MessageReaction) ([]byte, error) {
|
|
raw, err := encodeStoryReaction(reaction)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal event reaction: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodeEventReaction(raw string) (*domain.MessageReaction, error) {
|
|
return decodeStoryReaction(raw)
|
|
}
|
|
|
|
func encodeEventEmojiStatus(status domain.UserEmojiStatus) ([]byte, error) {
|
|
if !status.Valid() {
|
|
return nil, domain.ErrEmojiStatusCollectibleInvalid
|
|
}
|
|
raw, err := json.Marshal(status)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal event emoji status: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodeEventEmojiStatus(raw string) (domain.UserEmojiStatus, error) {
|
|
if raw == "" || raw == "{}" || raw == "null" {
|
|
return domain.UserEmojiStatus{}, nil
|
|
}
|
|
var status domain.UserEmojiStatus
|
|
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
|
return domain.UserEmojiStatus{}, err
|
|
}
|
|
if !status.Valid() {
|
|
return domain.UserEmojiStatus{}, domain.ErrEmojiStatusCollectibleInvalid
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
type peerSettingsJSON struct {
|
|
AddContact bool `json:"add_contact,omitempty"`
|
|
BlockContact bool `json:"block_contact,omitempty"`
|
|
ShareContact bool `json:"share_contact,omitempty"`
|
|
NeedContactsException bool `json:"need_contacts_exception,omitempty"`
|
|
HiddenPeerSettingsBar bool `json:"hidden_peer_settings_bar,omitempty"`
|
|
BusinessBotID int64 `json:"business_bot_id,omitempty"`
|
|
BusinessBotManageURL string `json:"business_bot_manage_url,omitempty"`
|
|
BusinessBotPaused bool `json:"business_bot_paused,omitempty"`
|
|
BusinessBotCanReply bool `json:"business_bot_can_reply,omitempty"`
|
|
}
|
|
|
|
func encodePeerSettings(settings domain.PeerSettings) ([]byte, error) {
|
|
raw, err := json.Marshal(peerSettingsJSON{
|
|
AddContact: settings.AddContact,
|
|
BlockContact: settings.BlockContact,
|
|
ShareContact: settings.ShareContact,
|
|
NeedContactsException: settings.NeedContactsException,
|
|
HiddenPeerSettingsBar: settings.HiddenPeerSettingsBar,
|
|
BusinessBotID: settings.BusinessBotID,
|
|
BusinessBotManageURL: settings.BusinessBotManageURL,
|
|
BusinessBotPaused: settings.BusinessBotPaused,
|
|
BusinessBotCanReply: settings.BusinessBotCanReply,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal peer settings: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func decodePeerSettings(raw string) (domain.PeerSettings, error) {
|
|
if raw == "" {
|
|
return domain.PeerSettings{}, nil
|
|
}
|
|
var wire peerSettingsJSON
|
|
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
|
|
return domain.PeerSettings{}, err
|
|
}
|
|
return domain.PeerSettings{
|
|
AddContact: wire.AddContact,
|
|
BlockContact: wire.BlockContact,
|
|
ShareContact: wire.ShareContact,
|
|
NeedContactsException: wire.NeedContactsException,
|
|
HiddenPeerSettingsBar: wire.HiddenPeerSettingsBar,
|
|
BusinessBotID: wire.BusinessBotID,
|
|
BusinessBotManageURL: wire.BusinessBotManageURL,
|
|
BusinessBotPaused: wire.BusinessBotPaused,
|
|
BusinessBotCanReply: wire.BusinessBotCanReply,
|
|
}, nil
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|