chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -16,12 +16,20 @@ import (
// DialogStore 用 PostgreSQL 实现 store.DialogStore。
type DialogStore struct {
q *sqlcgen.Queries
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。
func NewDialogStore(db sqlcgen.DBTX) *DialogStore {
return &DialogStore{q: sqlcgen.New(db)}
return &DialogStore{db: db, q: sqlcgen.New(db)}
}
func (s *DialogStore) enrichDialogTopMessages(ctx context.Context, userID int64, messages []domain.Message) error {
if len(messages) == 0 {
return nil
}
return NewMessageStore(s.db).enrichPrivateMessageReactions(ctx, s.db, userID, messages)
}
func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
@ -69,6 +77,9 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
TTLPeriod: int(row.TtlPeriod),
ThemeEmoticon: row.ThemeEmoticon,
HasScheduled: row.HasScheduled,
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
@ -125,6 +136,9 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
TTLPeriod: int(row.TtlPeriod),
ThemeEmoticon: row.ThemeEmoticon,
HasScheduled: row.HasScheduled,
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
@ -135,18 +149,23 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai
if _, ok := seenUsers[row.PeerUserID]; !ok {
seenUsers[row.PeerUserID] = struct{}{}
out.Users = append(out.Users, 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,
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
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),
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
})
}
}
@ -159,19 +178,72 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message media: %w", err)
}
markup, err := decodeReplyMarkup(row.MessageReplyMarkupJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message reply markup: %w", err)
}
rich, err := decodeRichMessage(row.MessageRichMessageJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message rich message: %w", err)
}
// top message 必须带全量元数据:TDesktop 把 getDialogs 的消息
// 先入缓存且不被后续 difference/getHistory 覆盖,缺 reply 等
// 字段会让置顶/回复服务消息永久渲染成 "Deleted message"。
silent, noforwards, reply, forward, err := messageMetadataFromFields(
row.MessageSilent,
row.MessageNoforwards,
row.MessageReplyToMsgID,
row.MessageReplyToPeerType,
row.MessageReplyToPeerID,
row.MessageReplyToTopID,
row.MessageReplyToStoryID,
row.MessageQuoteText,
row.MessageQuoteEntitiesJson,
row.MessageQuoteOffset,
row.MessageFwdFromPeerType,
row.MessageFwdFromPeerID,
row.MessageFwdFromName,
row.MessageFwdDate,
row.MessageFwdSavedFromPeerType,
row.MessageFwdSavedFromPeerID,
row.MessageFwdSavedFromMsgID,
)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message metadata: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.MessageID),
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
Out: row.MessageOutgoing,
Body: row.MessageBody,
Entities: entities,
Media: media,
ID: int(row.MessageID),
UID: row.MessagePrivateMessageID,
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
EditDate: int(row.MessageEditDate),
Out: row.MessageOutgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.MessageBody,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
TTLPeriod: int(row.MessageTtlPeriod),
ExpiresAt: int(row.MessageExpiresAt),
MediaUnread: row.MessageMediaUnread,
ReactionUnread: row.MessageReactionUnread,
ViaBotID: row.MessageViaBotID,
GroupedID: row.MessageGroupedID,
Effect: row.MessageEffect,
ReplyMarkup: markup,
RichMessage: rich,
Pinned: row.MessagePinned,
SavedPeer: savedPeerFromFields(row.MessageSavedPeerType, row.MessageSavedPeerID),
})
}
}
if err := s.enrichDialogTopMessages(ctx, userID, out.Messages); err != nil {
return domain.DialogList{}, err
}
return out, nil
}
@ -216,6 +288,9 @@ func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []dom
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
TTLPeriod: int(row.TtlPeriod),
ThemeEmoticon: row.ThemeEmoticon,
HasScheduled: row.HasScheduled,
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
@ -226,18 +301,23 @@ func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []dom
if _, ok := seenUsers[row.PeerUserID]; !ok {
seenUsers[row.PeerUserID] = struct{}{}
out.Users = append(out.Users, 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,
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
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),
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
})
}
}
@ -250,19 +330,72 @@ func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []dom
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message media: %w", err)
}
markup, err := decodeReplyMarkup(row.MessageReplyMarkupJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message reply markup: %w", err)
}
rich, err := decodeRichMessage(row.MessageRichMessageJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message rich message: %w", err)
}
// top message 必须带全量元数据:TDesktop 把 getDialogs 的消息
// 先入缓存且不被后续 difference/getHistory 覆盖,缺 reply 等
// 字段会让置顶/回复服务消息永久渲染成 "Deleted message"。
silent, noforwards, reply, forward, err := messageMetadataFromFields(
row.MessageSilent,
row.MessageNoforwards,
row.MessageReplyToMsgID,
row.MessageReplyToPeerType,
row.MessageReplyToPeerID,
row.MessageReplyToTopID,
row.MessageReplyToStoryID,
row.MessageQuoteText,
row.MessageQuoteEntitiesJson,
row.MessageQuoteOffset,
row.MessageFwdFromPeerType,
row.MessageFwdFromPeerID,
row.MessageFwdFromName,
row.MessageFwdDate,
row.MessageFwdSavedFromPeerType,
row.MessageFwdSavedFromPeerID,
row.MessageFwdSavedFromMsgID,
)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message metadata: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.MessageID),
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
Out: row.MessageOutgoing,
Body: row.MessageBody,
Entities: entities,
Media: media,
ID: int(row.MessageID),
UID: row.MessagePrivateMessageID,
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
EditDate: int(row.MessageEditDate),
Out: row.MessageOutgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.MessageBody,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
TTLPeriod: int(row.MessageTtlPeriod),
ExpiresAt: int(row.MessageExpiresAt),
MediaUnread: row.MessageMediaUnread,
ReactionUnread: row.MessageReactionUnread,
ViaBotID: row.MessageViaBotID,
GroupedID: row.MessageGroupedID,
Effect: row.MessageEffect,
ReplyMarkup: markup,
RichMessage: rich,
Pinned: row.MessagePinned,
SavedPeer: savedPeerFromFields(row.MessageSavedPeerType, row.MessageSavedPeerID),
})
}
}
if err := s.enrichDialogTopMessages(ctx, userID, out.Messages); err != nil {
return domain.DialogList{}, err
}
out.Count = len(out.Dialogs)
out.Hash = dialogListHash(out.Dialogs)
return out, nil
@ -288,6 +421,19 @@ func (s *DialogStore) Upsert(ctx context.Context, userID int64, dialog domain.Di
return nil
}
func (s *DialogStore) UpsertInbox(ctx context.Context, userID int64, dialog domain.Dialog) error {
if err := s.q.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{
UserID: userID,
PeerType: string(dialog.Peer.Type),
PeerID: dialog.Peer.ID,
TopMessageID: int32(dialog.TopMessage),
TopMessageDate: int32(dialog.TopMessageDate),
}); err != nil {
return fmt.Errorf("upsert inbox dialog: %w", err)
}
return nil
}
func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
data, err := json.Marshal(draft)
if err != nil {
@ -306,6 +452,26 @@ func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain.
return nil
}
func (s *DialogStore) GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) {
var data []byte
err := s.db.QueryRow(ctx, `
SELECT draft
FROM dialog_drafts
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3 AND top_message_id = $4`,
userID, string(peer.Type), peer.ID, int32(topMessageID)).Scan(&data)
if errors.Is(err, pgx.ErrNoRows) {
return domain.DialogDraft{}, false, nil
}
if err != nil {
return domain.DialogDraft{}, false, fmt.Errorf("get dialog draft: %w", err)
}
var draft domain.DialogDraft
if err := json.Unmarshal(data, &draft); err != nil {
return domain.DialogDraft{}, false, fmt.Errorf("decode dialog draft: %w", err)
}
return draft, true, nil
}
func (s *DialogStore) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
changed, err := s.q.DeleteDialogDraft(ctx, sqlcgen.DeleteDialogDraftParams{
UserID: userID,
@ -366,41 +532,79 @@ func (s *DialogStore) MarkRead(ctx context.Context, userID int64, peer domain.Pe
}, nil
}
func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
changed, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{
func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) {
row, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
Pinned: pinned,
})
if err != nil {
return false, fmt.Errorf("set dialog pinned: %w", err)
return false, 0, fmt.Errorf("set dialog pinned: %w", err)
}
return changed, nil
return row.Changed, int(row.FolderID), nil
}
func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) {
peerTypes, peerIDs := peerArrays(order)
changed := false
if force {
if err := s.q.ClearPinnedDialogsNotInOrder(ctx, sqlcgen.ClearPinnedDialogsNotInOrderParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
}); err != nil {
return fmt.Errorf("clear pinned dialogs not in order: %w", err)
tag, err := s.db.Exec(ctx, `
WITH requested AS (
SELECT ($3::text[])[i] AS peer_type, ($4::bigint[])[i] AS peer_id
FROM generate_subscripts($4::bigint[], 1) AS g(i)
WHERE i <= cardinality($3::text[])
)
UPDATE dialogs d
SET pinned = false, pinned_order = 0, updated_at = now()
WHERE d.user_id = $1
AND d.pinned
AND d.folder_id = $2::int
AND NOT EXISTS (
SELECT 1
FROM requested r
WHERE r.peer_type = d.peer_type
AND r.peer_id = d.peer_id
)`, userID, folderID, peerTypes, peerIDs)
if err != nil {
return false, fmt.Errorf("clear pinned dialogs not in order: %w", err)
}
if tag.RowsAffected() > 0 {
changed = true
}
}
if len(peerTypes) == 0 {
return nil
return changed, nil
}
if err := s.q.ReorderPinnedDialogs(ctx, sqlcgen.ReorderPinnedDialogsParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
}); err != nil {
return fmt.Errorf("reorder pinned dialogs: %w", err)
tag, err := s.db.Exec(ctx, `
WITH requested AS (
SELECT ($3::text[])[i] AS peer_type, ($4::bigint[])[i] AS peer_id, i::int AS pos
FROM generate_subscripts($4::bigint[], 1) AS g(i)
WHERE i <= cardinality($3::text[])
),
deduped AS (
SELECT DISTINCT ON (peer_type, peer_id)
peer_type,
peer_id,
(cardinality($4::bigint[]) - pos + 1)::int AS ord
FROM requested
ORDER BY peer_type, peer_id, pos
)
UPDATE dialogs d
SET pinned = true, pinned_order = deduped.ord, updated_at = now()
FROM deduped
WHERE d.user_id = $1
AND d.peer_type = deduped.peer_type
AND d.peer_id = deduped.peer_id
AND d.folder_id = $2::int
AND (NOT d.pinned OR d.pinned_order IS DISTINCT FROM deduped.ord)`, userID, folderID, peerTypes, peerIDs)
if err != nil {
return false, fmt.Errorf("reorder pinned dialogs: %w", err)
}
return nil
if tag.RowsAffected() > 0 {
changed = true
}
return changed, nil
}
func (s *DialogStore) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
@ -428,6 +632,44 @@ func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]dom
return out, nil
}
func (s *DialogStore) SetChatTheme(ctx context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) {
if userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
}
var current string
err := s.db.QueryRow(ctx, `
SELECT theme_emoticon
FROM dialogs
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`,
userID, string(peer.Type), peer.ID).Scan(&current)
if errors.Is(err, pgx.ErrNoRows) {
if emoticon == "" {
return false, nil
}
if _, err := s.db.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, theme_emoticon)
VALUES ($1, $2, $3, $4)`,
userID, string(peer.Type), peer.ID, emoticon); err != nil {
return false, fmt.Errorf("insert dialog chat theme: %w", err)
}
return true, nil
}
if err != nil {
return false, fmt.Errorf("get dialog chat theme: %w", err)
}
if current == emoticon {
return false, nil
}
if _, err := s.db.Exec(ctx, `
UPDATE dialogs
SET theme_emoticon = $4, updated_at = now()
WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`,
userID, string(peer.Type), peer.ID, emoticon); err != nil {
return false, fmt.Errorf("update dialog chat theme: %w", err)
}
return true, nil
}
func (s *DialogStore) SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
changed, err := s.q.SetPeerSettingsBarHidden(ctx, sqlcgen.SetPeerSettingsBarHiddenParams{
UserID: userID,
@ -579,6 +821,37 @@ func (s *DialogStore) EditPeerFolders(ctx context.Context, userID int64, peers [
return nil
}
func (s *DialogStore) SetArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) {
changed, err := s.q.SetDialogArchivePinned(ctx, sqlcgen.SetDialogArchivePinnedParams{
UserID: userID,
Pinned: pinned,
})
if err != nil {
return false, fmt.Errorf("set dialog archive pinned: %w", err)
}
return changed, nil
}
func (s *DialogStore) ArchivePinned(ctx context.Context, userID int64) (bool, error) {
pinned, err := s.q.GetDialogArchivePinned(ctx, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// 官方默认 archive folder 行在置顶区。
return true, nil
}
return false, fmt.Errorf("get dialog archive pinned: %w", err)
}
return pinned, nil
}
func (s *DialogStore) CountArchiveUnread(ctx context.Context, userID int64) (int, int, error) {
row, err := s.q.CountArchiveUnreadDialogs(ctx, userID)
if err != nil {
return 0, 0, fmt.Errorf("count archive unread dialogs: %w", err)
}
return int(row.UnreadPeers), int(row.UnreadMessages), nil
}
type dialogFolderParams struct {
contacts bool
nonContacts bool
@ -727,6 +1000,7 @@ func dialogListHash(dialogs []domain.Dialog) int64 {
buf[46] = 0
}
_, _ = h.Write(buf[:])
_, _ = h.Write([]byte(d.ThemeEmoticon))
}
return int64(h.Sum64())
}