New supergroups are created with "chat history for new members" hidden (the client sets this right after creation, matching official Telegram). The official server then forces it back to visible when the group is made public; owpengram's UpdateUsername left the flag alone, leaving public groups in a state where non-members (and post-join members) see no history at all. UpdateUsername now clears pre_history_hidden whenever a non-empty username is assigned, in the same transaction, with a matching admin-log event. Removing the username leaves the flag untouched, so the creator can hide history again once the group is private.
1319 lines
45 KiB
Go
1319 lines
45 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
func (s *ChannelStore) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) {
|
||
if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" {
|
||
return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.EditChannelTitleResult{}, fmt.Errorf("edit channel title: db does not support transactions")
|
||
}
|
||
if req.Date == 0 {
|
||
req.Date = nowUnix()
|
||
}
|
||
title := strings.TrimSpace(req.Title)
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.EditChannelTitleResult{}, fmt.Errorf("begin edit channel title: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||
if err != nil {
|
||
return domain.EditChannelTitleResult{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if channel.Title == title {
|
||
return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified
|
||
}
|
||
prevTitle := channel.Title
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET title = $2, updated_at = now() WHERE id = $1`, req.ChannelID, title); err != nil {
|
||
return domain.EditChannelTitleResult{}, fmt.Errorf("update channel title: %w", err)
|
||
}
|
||
channel.Title = title
|
||
msg, event, err := s.insertServiceMessage(ctx, tx, channel, req.UserID, req.Date, domain.ChannelMessageAction{
|
||
Type: domain.ChannelActionEditTitle,
|
||
Title: title,
|
||
})
|
||
if err != nil {
|
||
return domain.EditChannelTitleResult{}, err
|
||
}
|
||
channel.TopMessageID = msg.ID
|
||
channel.Pts = event.Pts
|
||
// read_outbox 由公共已读水位派生;服务消息只自读(inbox),不能把
|
||
// actor 的 outbox 水位顶到自己刚发的消息上造成虚假双勾。
|
||
if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, msg.ID, 0); err != nil {
|
||
return domain.EditChannelTitleResult{}, err
|
||
}
|
||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||
ChannelID: req.ChannelID,
|
||
UserID: req.UserID,
|
||
Date: req.Date,
|
||
Type: domain.ChannelAdminLogChangeTitle,
|
||
PrevString: prevTitle,
|
||
NewString: title,
|
||
}); err != nil {
|
||
return domain.EditChannelTitleResult{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.EditChannelTitleResult{}, fmt.Errorf("commit edit channel title: %w", err)
|
||
}
|
||
committed = true
|
||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0)
|
||
return domain.EditChannelTitleResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetChannelWallpaper(ctx context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) {
|
||
if req.UserID == 0 || req.ChannelID == 0 {
|
||
return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.SetChannelWallpaperResult{}, fmt.Errorf("set channel wallpaper: db does not support transactions")
|
||
}
|
||
if req.Date == 0 {
|
||
req.Date = nowUnix()
|
||
}
|
||
wallpaper := domain.CloneWallpaperPtr(req.Wallpaper)
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.SetChannelWallpaperResult{}, fmt.Errorf("begin set channel wallpaper: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||
if err != nil {
|
||
return domain.SetChannelWallpaperResult{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.SetChannelWallpaperResult{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if domain.WallpaperEqual(channel.Wallpaper, wallpaper) {
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.SetChannelWallpaperResult{}, fmt.Errorf("commit unchanged channel wallpaper: %w", err)
|
||
}
|
||
committed = true
|
||
return domain.SetChannelWallpaperResult{Channel: channel}, nil
|
||
}
|
||
var wallpaperParam any
|
||
if wallpaper != nil {
|
||
raw, err := json.Marshal(wallpaper)
|
||
if err != nil {
|
||
return domain.SetChannelWallpaperResult{}, fmt.Errorf("encode channel wallpaper: %w", err)
|
||
}
|
||
wallpaperParam = string(raw)
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET wallpaper = $2::jsonb, updated_at = now() WHERE id = $1`, req.ChannelID, wallpaperParam); err != nil {
|
||
return domain.SetChannelWallpaperResult{}, fmt.Errorf("update channel wallpaper: %w", err)
|
||
}
|
||
channel.Wallpaper = domain.CloneWallpaperPtr(wallpaper)
|
||
var (
|
||
msg domain.ChannelMessage
|
||
event domain.ChannelUpdateEvent
|
||
)
|
||
if wallpaper != nil {
|
||
msg, event, err = s.insertServiceMessage(ctx, tx, channel, req.UserID, req.Date, domain.ChannelMessageAction{
|
||
Type: domain.ChannelActionSetChatWallpaper,
|
||
Wallpaper: domain.CloneWallpaperPtr(wallpaper),
|
||
})
|
||
if err != nil {
|
||
return domain.SetChannelWallpaperResult{}, err
|
||
}
|
||
channel.TopMessageID = msg.ID
|
||
channel.Pts = event.Pts
|
||
if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, msg.ID, 0); err != nil {
|
||
return domain.SetChannelWallpaperResult{}, err
|
||
}
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.SetChannelWallpaperResult{}, fmt.Errorf("commit set channel wallpaper: %w", err)
|
||
}
|
||
committed = true
|
||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0)
|
||
return domain.SetChannelWallpaperResult{
|
||
Channel: channel,
|
||
Message: msg,
|
||
Event: event,
|
||
Recipients: recipients,
|
||
Changed: true,
|
||
}, nil
|
||
}
|
||
|
||
func (s *ChannelStore) EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) {
|
||
if req.UserID == 0 || req.ChannelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("edit channel about: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin edit channel about: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET about = $2, updated_at = now() WHERE id = $1`, req.ChannelID, req.About); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel about: %w", err)
|
||
}
|
||
channel.About = req.About
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit edit channel about: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) {
|
||
if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" {
|
||
return false, domain.ErrChannelInvalid
|
||
}
|
||
if _, _, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil {
|
||
return false, err
|
||
}
|
||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||
return peerUsernameAvailable(ctx, s.db, usernameLower, peerUsernameTypeChannel, channelID)
|
||
}
|
||
|
||
func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
|
||
if req.UserID == 0 || req.ChannelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("update channel username: db does not support transactions")
|
||
}
|
||
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
|
||
usernameLower := strings.ToLower(username)
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin update channel username: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if member.Role != domain.ChannelRoleCreator {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if strings.EqualFold(channel.Username, username) {
|
||
return domain.Channel{}, domain.ErrChannelNotModified
|
||
}
|
||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, username, usernameLower); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
// A public group cannot keep pre-history hidden: assigning a username forces
|
||
// "chat history for new members" back to visible (matches the official
|
||
// server). Removing the username leaves the flag untouched, so the creator
|
||
// can hide history again once the group is private.
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), pre_history_hidden = (pre_history_hidden AND $2 = ''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel username: %w", err)
|
||
}
|
||
if err := markUserChannelMemberIndexPublicTx(ctx, tx, req.ChannelID, username != ""); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if username != "" && channel.PreHistoryHidden {
|
||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||
ChannelID: req.ChannelID,
|
||
UserID: req.UserID,
|
||
Date: nowUnix(),
|
||
Type: domain.ChannelAdminLogTogglePreHistoryHidden,
|
||
PrevBool: true,
|
||
NewBool: false,
|
||
}); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
channel.PreHistoryHidden = false
|
||
}
|
||
prevUsername := channel.Username
|
||
channel.Username = username
|
||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||
ChannelID: req.ChannelID,
|
||
UserID: req.UserID,
|
||
Date: nowUnix(),
|
||
Type: domain.ChannelAdminLogChangeUsername,
|
||
PrevString: prevUsername,
|
||
NewString: username,
|
||
}); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit update channel username: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetChannelVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) {
|
||
if channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
channel, err := s.channelByID(ctx, s.db, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if channel.Verified == verified {
|
||
return channel, nil
|
||
}
|
||
if _, err := s.db.Exec(ctx, `UPDATE channels SET verified = $2, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, verified); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("set channel verified: %w", err)
|
||
}
|
||
if s.rowCache != nil {
|
||
s.rowCache.delete(channelID)
|
||
}
|
||
channel.Verified = verified
|
||
return channel, nil
|
||
}
|
||
|
||
// SetChannelScamFake 设置/取消频道的 scam 与 fake 标记。
|
||
func (s *ChannelStore) SetChannelScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
|
||
if channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
if scam && fake {
|
||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("set channel scam/fake: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin set channel scam/fake: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
var currentScam, currentFake bool
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT scam, fake
|
||
FROM channels
|
||
WHERE id = $1 AND NOT deleted
|
||
FOR UPDATE`, channelID).Scan(¤tScam, ¤tFake); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
return domain.Channel{}, fmt.Errorf("lock channel scam/fake: %w", err)
|
||
}
|
||
channel, err := s.channelByID(ctx, tx, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if channel.Scam == scam && channel.Fake == fake {
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit unchanged channel scam/fake: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
if currentScam != channel.Scam || currentFake != channel.Fake {
|
||
return domain.Channel{}, fmt.Errorf("channel scam/fake snapshot changed while locked")
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit channel scam/fake: %w", err)
|
||
}
|
||
committed = true
|
||
if s.rowCache != nil {
|
||
s.rowCache.delete(channelID)
|
||
}
|
||
channel.Scam = scam
|
||
channel.Fake = fake
|
||
return channel, nil
|
||
}
|
||
|
||
// SetChannelAdminSettings applies an admin-direct moderation-settings patch
|
||
// (no membership/permission checks). nil fields are left unchanged.
|
||
func (s *ChannelStore) SetChannelAdminSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
|
||
if channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
if patch.Empty() {
|
||
return s.channelByID(ctx, s.db, channelID)
|
||
}
|
||
sets := make([]string, 0, 7)
|
||
args := []any{channelID}
|
||
idx := 2
|
||
add := func(col string, val any) {
|
||
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
|
||
args = append(args, val)
|
||
idx++
|
||
}
|
||
if patch.Gigagroup != nil {
|
||
add("gigagroup", *patch.Gigagroup)
|
||
}
|
||
if patch.AntiSpam != nil {
|
||
add("antispam", *patch.AntiSpam)
|
||
}
|
||
if patch.ParticipantsHidden != nil {
|
||
add("participants_hidden", *patch.ParticipantsHidden)
|
||
}
|
||
if patch.NoForwards != nil {
|
||
add("noforwards", *patch.NoForwards)
|
||
}
|
||
if patch.JoinToSend != nil {
|
||
add("join_to_send", *patch.JoinToSend)
|
||
}
|
||
if patch.JoinRequest != nil {
|
||
add("join_request", *patch.JoinRequest)
|
||
}
|
||
if patch.SlowmodeSeconds != nil {
|
||
add("slowmode_seconds", *patch.SlowmodeSeconds)
|
||
}
|
||
query := "UPDATE channels SET " + strings.Join(sets, ", ") + ", updated_at = now() WHERE id = $1 AND NOT deleted"
|
||
if _, err := s.db.Exec(ctx, query, args...); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("set channel admin settings: %w", err)
|
||
}
|
||
if s.rowCache != nil {
|
||
s.rowCache.delete(channelID)
|
||
}
|
||
return s.channelByID(ctx, s.db, channelID)
|
||
}
|
||
|
||
// SetChannelUsernameAdmin force-sets or clears (empty) a channel username with
|
||
// no permission checks. Username uniqueness is still enforced by peer_usernames.
|
||
func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
|
||
if channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("set channel username: db does not support transactions")
|
||
}
|
||
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
|
||
usernameLower := strings.ToLower(username)
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin set channel username: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, err := s.channelByID(ctx, tx, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if strings.EqualFold(channel.Username, username) {
|
||
return channel, nil
|
||
}
|
||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, username, usernameLower); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, channelID, username); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("set channel username: %w", err)
|
||
}
|
||
if err := markUserChannelMemberIndexPublicTx(ctx, tx, channelID, username != ""); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit set channel username: %w", err)
|
||
}
|
||
committed = true
|
||
if s.rowCache != nil {
|
||
s.rowCache.delete(channelID)
|
||
}
|
||
channel.Username = username
|
||
return channel, nil
|
||
}
|
||
|
||
// SetChannelColorAdmin force-sets a channel name/profile color (no permission checks).
|
||
func (s *ChannelStore) SetChannelColorAdmin(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||
if channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
channel, err := s.channelByID(ctx, s.db, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if forProfile {
|
||
if _, err := s.db.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
|
||
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("set channel profile color: %w", err)
|
||
}
|
||
channel.ProfileColor = color
|
||
} else {
|
||
if _, err := s.db.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
|
||
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("set channel color: %w", err)
|
||
}
|
||
channel.Color = color
|
||
}
|
||
if s.rowCache != nil {
|
||
s.rowCache.delete(channelID)
|
||
}
|
||
return channel, nil
|
||
}
|
||
|
||
// SetChannelEmojiStatusAdmin force-sets or clears a channel emoji status (no permission checks).
|
||
func (s *ChannelStore) SetChannelEmojiStatusAdmin(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||
if channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
if status.DocumentID == 0 {
|
||
status.Until = 0
|
||
}
|
||
channel, err := s.channelByID(ctx, s.db, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if _, err := s.db.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`,
|
||
channelID, status.DocumentID, status.Until); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("set channel emoji status: %w", err)
|
||
}
|
||
if s.rowCache != nil {
|
||
s.rowCache.delete(channelID)
|
||
}
|
||
channel.EmojiStatus = status
|
||
return channel, nil
|
||
}
|
||
|
||
// SetChannelPhotoAdmin force-sets a channel's avatar with no permission
|
||
// checks and no "changed photo" service message — unlike SetChannelPhoto,
|
||
// which requires an acting admin member and broadcasts to the channel's
|
||
// timeline. Mirrors SetChannelColorAdmin/SetChannelEmojiStatusAdmin's shape.
|
||
func (s *ChannelStore) SetChannelPhotoAdmin(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
|
||
if channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
channel, err := s.channelByID(ctx, s.db, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
stripped := domain.StrippedFromSizes(photo.Sizes)
|
||
if stripped == nil {
|
||
stripped = []byte{}
|
||
}
|
||
if _, err := s.db.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`,
|
||
channelID, photo.ID, photo.DCID, stripped); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("set channel photo admin: %w", err)
|
||
}
|
||
if s.rowCache != nil {
|
||
s.rowCache.delete(channelID)
|
||
}
|
||
channel.PhotoID = photo.ID
|
||
channel.PhotoDCID = photo.DCID
|
||
channel.PhotoStripped = stripped
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) {
|
||
_ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent.
|
||
usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||
if usernameLower == "" {
|
||
return domain.Channel{}, false, nil
|
||
}
|
||
owner, found, err := getPeerUsernameOwner(ctx, s.db, usernameLower, false)
|
||
if err != nil {
|
||
return domain.Channel{}, false, fmt.Errorf("resolve public channel username: %w", err)
|
||
}
|
||
if !found || owner.peerType != peerUsernameTypeChannel {
|
||
return domain.Channel{}, false, nil
|
||
}
|
||
if !owner.active {
|
||
return domain.Channel{}, false, nil
|
||
}
|
||
ch, err := getChannelByID(ctx, s.db, owner.peerID)
|
||
if err != nil {
|
||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||
return domain.Channel{}, false, nil
|
||
}
|
||
return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err)
|
||
}
|
||
if !publicPreviewableChannel(ch, true) {
|
||
return domain.Channel{}, false, nil
|
||
}
|
||
// A collectible row is authoritative for its own name: the channel's scalar
|
||
// username column only ever mirrors the editable slot, so re-checking it here
|
||
// would make collectible names unresolvable. The scalar comparison stays for
|
||
// the editable slot, where it guards against a stale registry row.
|
||
if !owner.collectible && !strings.EqualFold(ch.Username, usernameLower) {
|
||
return domain.Channel{}, false, nil
|
||
}
|
||
return ch, true, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("toggle channel signatures: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin toggle channel signatures: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
prev := channel.Signatures
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET signatures = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel signatures: %w", err)
|
||
}
|
||
channel.Signatures = enabled
|
||
if prev != enabled {
|
||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||
ChannelID: channelID,
|
||
UserID: userID,
|
||
Date: nowUnix(),
|
||
Type: domain.ChannelAdminLogToggleSignatures,
|
||
PrevBool: prev,
|
||
NewBool: enabled,
|
||
}); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit toggle channel signatures: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
// SetChannelPhoto 设置/清除频道头像,并生成对应频道服务消息。photo==nil 表示清除。
|
||
func (s *ChannelStore) SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo, date int) (domain.SetChannelPhotoResult, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return domain.SetChannelPhotoResult{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.SetChannelPhotoResult{}, fmt.Errorf("set channel photo: db does not support transactions")
|
||
}
|
||
if date == 0 {
|
||
date = nowUnix()
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.SetChannelPhotoResult{}, fmt.Errorf("begin set channel photo: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.SetChannelPhotoResult{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.SetChannelPhotoResult{}, domain.ErrChannelAdminRequired
|
||
}
|
||
var (
|
||
photoID int64
|
||
dcID int
|
||
stripped []byte
|
||
action domain.ChannelMessageAction
|
||
)
|
||
if photo != nil && photo.ID != 0 {
|
||
if channel.PhotoID == photo.ID {
|
||
return domain.SetChannelPhotoResult{}, domain.ErrChannelNotModified
|
||
}
|
||
photoID = photo.ID
|
||
dcID = photo.DCID
|
||
stripped = domain.StrippedFromSizes(photo.Sizes)
|
||
action = domain.ChannelMessageAction{
|
||
Type: domain.ChannelActionChatEditPhoto,
|
||
Photo: domain.ClonePhotoPtr(photo),
|
||
}
|
||
} else {
|
||
if channel.PhotoID == 0 {
|
||
return domain.SetChannelPhotoResult{}, domain.ErrChannelNotModified
|
||
}
|
||
action = domain.ChannelMessageAction{Type: domain.ChannelActionChatDeletePhoto}
|
||
}
|
||
if stripped == nil {
|
||
stripped = []byte{}
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`,
|
||
channelID, photoID, dcID, stripped); err != nil {
|
||
return domain.SetChannelPhotoResult{}, fmt.Errorf("update channel photo: %w", err)
|
||
}
|
||
if s.rowCache != nil {
|
||
s.rowCache.delete(channelID)
|
||
}
|
||
channel.PhotoID = photoID
|
||
channel.PhotoDCID = dcID
|
||
channel.PhotoStripped = stripped
|
||
msg, event, err := s.insertServiceMessage(ctx, tx, channel, userID, date, action)
|
||
if err != nil {
|
||
return domain.SetChannelPhotoResult{}, err
|
||
}
|
||
channel.TopMessageID = msg.ID
|
||
channel.Pts = event.Pts
|
||
if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, msg.ID, 0); err != nil {
|
||
return domain.SetChannelPhotoResult{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.SetChannelPhotoResult{}, fmt.Errorf("commit set channel photo: %w", err)
|
||
}
|
||
committed = true
|
||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0)
|
||
return domain.SetChannelPhotoResult{
|
||
Channel: channel,
|
||
Message: msg,
|
||
Event: event,
|
||
Recipients: recipients,
|
||
Changed: true,
|
||
}, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("toggle channel autotranslation: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin toggle channel autotranslation: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
prev := channel.Autotranslation
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET autotranslation = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel autotranslation: %w", err)
|
||
}
|
||
channel.Autotranslation = enabled
|
||
if prev != enabled {
|
||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||
ChannelID: channelID,
|
||
UserID: userID,
|
||
Date: nowUnix(),
|
||
Type: domain.ChannelAdminLogToggleAutotranslation,
|
||
PrevBool: prev,
|
||
NewBool: enabled,
|
||
}); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit toggle channel autotranslation: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("toggle channel restricted sponsored: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin toggle channel restricted sponsored: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET restricted_sponsored = $2, updated_at = now() WHERE id = $1`, channelID, restricted); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel restricted sponsored: %w", err)
|
||
}
|
||
channel.RestrictedSponsored = restricted
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit toggle channel restricted sponsored: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("toggle channel antispam: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin toggle channel antispam: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !channel.Megagroup || !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
prev := channel.AntiSpam
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET antispam = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel antispam: %w", err)
|
||
}
|
||
channel.AntiSpam = enabled
|
||
if prev != enabled {
|
||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||
ChannelID: channelID,
|
||
UserID: userID,
|
||
Date: nowUnix(),
|
||
Type: domain.ChannelAdminLogToggleAntiSpam,
|
||
PrevBool: prev,
|
||
NewBool: enabled,
|
||
}); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit toggle channel antispam: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) {
|
||
if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("toggle channel slowmode: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin toggle channel slowmode: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !channel.Megagroup || !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
prev := channel.SlowmodeSeconds
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET slowmode_seconds = $2, updated_at = now() WHERE id = $1`, channelID, seconds); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel slowmode: %w", err)
|
||
}
|
||
channel.SlowmodeSeconds = seconds
|
||
if prev != seconds {
|
||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||
ChannelID: channelID,
|
||
UserID: userID,
|
||
Date: nowUnix(),
|
||
Type: domain.ChannelAdminLogToggleSlowMode,
|
||
PrevInt: prev,
|
||
NewInt: seconds,
|
||
}); err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit toggle channel slowmode: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("toggle channel noforwards: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin toggle channel noforwards: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET noforwards = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel noforwards: %w", err)
|
||
}
|
||
channel.NoForwards = enabled
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit toggle channel noforwards: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("set channel color: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin set channel color: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if forProfile {
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
|
||
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel profile color: %w", err)
|
||
}
|
||
channel.ProfileColor = color
|
||
} else {
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`,
|
||
channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel color: %w", err)
|
||
}
|
||
channel.Color = color
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit set channel color: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return domain.Channel{}, domain.ErrChannelInvalid
|
||
}
|
||
if status.DocumentID == 0 {
|
||
status.Until = 0
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.Channel{}, fmt.Errorf("set channel emoji status: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.Channel{}, fmt.Errorf("begin set channel emoji status: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
|
||
if err != nil {
|
||
return domain.Channel{}, err
|
||
}
|
||
if !canChangeChannelInfo(member) {
|
||
return domain.Channel{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`,
|
||
channelID, status.DocumentID, status.Until); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update channel emoji status: %w", err)
|
||
}
|
||
channel.EmojiStatus = status
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("commit set channel emoji status: %w", err)
|
||
}
|
||
committed = true
|
||
return channel, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) {
|
||
if req.UserID == 0 || req.SourceChannelID < 0 {
|
||
return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid
|
||
}
|
||
limit := req.Limit
|
||
if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit {
|
||
limit = domain.DefaultChannelRecommendationsLimit
|
||
}
|
||
probeLimit := domain.MaxChannelRecommendationsLimit
|
||
if probeLimit < limit {
|
||
probeLimit = limit
|
||
}
|
||
args := []any{req.UserID, req.SourceChannelID}
|
||
where := []string{
|
||
"($1::bigint <> 0)",
|
||
"c.broadcast",
|
||
"NOT c.megagroup",
|
||
"NOT c.deleted",
|
||
"COALESCE(c.username, '') <> ''",
|
||
"($2::bigint = 0 OR c.id <> $2)",
|
||
}
|
||
if req.SourceChannelID == 0 {
|
||
where = append(where, `NOT EXISTS (
|
||
SELECT 1
|
||
FROM user_channel_member_index i
|
||
WHERE i.user_id = $1
|
||
AND i.channel_id = c.id
|
||
AND i.status = 'active'
|
||
)`)
|
||
}
|
||
whereSQL := strings.Join(where, " AND ")
|
||
args = append(args, probeLimit)
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+channelColumns+`
|
||
FROM channels c
|
||
WHERE `+whereSQL+`
|
||
ORDER BY c.participants_count DESC, c.date DESC, c.id DESC
|
||
LIMIT $3`, args...)
|
||
if err != nil {
|
||
return domain.ChannelRecommendationsResult{}, fmt.Errorf("list channel recommendations: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := domain.ChannelRecommendationsResult{Channels: make([]domain.Channel, 0, limit)}
|
||
for rows.Next() {
|
||
ch, err := scanChannel(rows)
|
||
if err != nil {
|
||
return domain.ChannelRecommendationsResult{}, err
|
||
}
|
||
out.Count++
|
||
if len(out.Channels) < limit {
|
||
out.Channels = append(out.Channels, ch)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return domain.ChannelRecommendationsResult{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) {
|
||
if userID == 0 {
|
||
return nil, domain.ErrChannelInvalid
|
||
}
|
||
if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit {
|
||
limit = domain.MaxDiscussionGroupsLimit
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT channel_id
|
||
FROM user_channel_member_index
|
||
WHERE user_id = $1
|
||
AND status = 'active'
|
||
AND megagroup
|
||
AND NOT broadcast
|
||
AND NOT forum
|
||
AND NOT deleted
|
||
AND (role = 'creator' OR can_pin_messages)
|
||
ORDER BY channel_id DESC
|
||
LIMIT $2`, userID, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list discussion groups: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
ids := make([]int64, 0, limit)
|
||
for rows.Next() {
|
||
var channelID int64
|
||
if err := rows.Scan(&channelID); err != nil {
|
||
return nil, err
|
||
}
|
||
ids = append(ids, channelID)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
channels, err := listChannelsByIDsInOrder(ctx, s.db, ids)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list discussion group details: %w", err)
|
||
}
|
||
out := make([]domain.Channel, 0, len(channels))
|
||
for _, channel := range channels {
|
||
if !channel.Megagroup || channel.Broadcast || channel.Forum || channel.Deleted {
|
||
continue
|
||
}
|
||
out = append(out, channel)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) {
|
||
if userID == 0 {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid
|
||
}
|
||
if broadcastID == 0 && groupID == 0 {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("set discussion group: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("begin set discussion group: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
|
||
changed := make(map[int64]domain.Channel)
|
||
markChanged := func(channel domain.Channel) {
|
||
if channel.ID != 0 {
|
||
changed[channel.ID] = channel
|
||
}
|
||
}
|
||
setLinked := func(channel domain.Channel, linkedID int64) (domain.Channel, error) {
|
||
if channel.LinkedChatID == linkedID {
|
||
return channel, nil
|
||
}
|
||
if _, err := tx.Exec(ctx, `UPDATE channels SET linked_chat_id = $2, updated_at = now() WHERE id = $1`, channel.ID, linkedID); err != nil {
|
||
return domain.Channel{}, fmt.Errorf("update linked chat: %w", err)
|
||
}
|
||
channel.LinkedChatID = linkedID
|
||
markChanged(channel)
|
||
return channel, nil
|
||
}
|
||
logLinkChange := func(channelID, prev, next int64) error {
|
||
if prev == next || channelID == 0 {
|
||
return nil
|
||
}
|
||
return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||
ChannelID: channelID,
|
||
UserID: userID,
|
||
Date: nowUnix(),
|
||
Type: domain.ChannelAdminLogChangeLinkedChat,
|
||
PrevInt: int(prev),
|
||
NewInt: int(next),
|
||
})
|
||
}
|
||
|
||
if broadcastID == 0 {
|
||
group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID)
|
||
if err != nil || !validDiscussionGroup(group) {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid
|
||
}
|
||
if !canManageDiscussionGroup(groupMember) {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired
|
||
}
|
||
oldBroadcastID := group.LinkedChatID
|
||
if oldBroadcastID == 0 {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
|
||
}
|
||
oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID)
|
||
if err == nil && oldBroadcast.LinkedChatID == groupID {
|
||
updated, err := setLinked(oldBroadcast, 0)
|
||
if err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if err := logLinkChange(updated.ID, groupID, 0); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
} else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if _, err := setLinked(group, 0); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err)
|
||
}
|
||
committed = true
|
||
return discussionGroupUpdateResult(changed), nil
|
||
}
|
||
|
||
broadcast, broadcastMember, err := s.getChannelForMember(ctx, tx, userID, broadcastID)
|
||
if err != nil || !broadcast.Broadcast || broadcast.Megagroup {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid
|
||
}
|
||
if !canManageDiscussionBroadcast(broadcastMember) {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired
|
||
}
|
||
oldGroupID := broadcast.LinkedChatID
|
||
if groupID == 0 {
|
||
if oldGroupID == 0 {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
|
||
}
|
||
updated, err := setLinked(broadcast, 0)
|
||
if err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if err := logLinkChange(updated.ID, oldGroupID, 0); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
oldGroup, err := getChannelByID(ctx, tx, oldGroupID)
|
||
if err == nil && oldGroup.LinkedChatID == broadcastID {
|
||
if _, err := setLinked(oldGroup, 0); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
} else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err)
|
||
}
|
||
committed = true
|
||
return discussionGroupUpdateResult(changed), nil
|
||
}
|
||
|
||
group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID)
|
||
if err != nil || !validDiscussionGroup(group) {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid
|
||
}
|
||
if group.PreHistoryHidden {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden
|
||
}
|
||
if !canManageDiscussionGroup(groupMember) {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired
|
||
}
|
||
if oldGroupID == groupID && group.LinkedChatID == broadcastID {
|
||
return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
|
||
}
|
||
oldBroadcastID := group.LinkedChatID
|
||
if oldGroupID != 0 && oldGroupID != groupID {
|
||
oldGroup, err := getChannelByID(ctx, tx, oldGroupID)
|
||
if err == nil && oldGroup.LinkedChatID == broadcastID {
|
||
if _, err := setLinked(oldGroup, 0); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
} else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
}
|
||
if oldBroadcastID != 0 && oldBroadcastID != broadcastID {
|
||
oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID)
|
||
if err == nil && oldBroadcast.LinkedChatID == groupID {
|
||
updated, err := setLinked(oldBroadcast, 0)
|
||
if err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if err := logLinkChange(updated.ID, groupID, 0); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
} else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
}
|
||
updatedBroadcast, err := setLinked(broadcast, groupID)
|
||
if err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if _, err := setLinked(group, broadcastID); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if err := logLinkChange(updatedBroadcast.ID, oldGroupID, groupID); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err)
|
||
}
|
||
committed = true
|
||
return discussionGroupUpdateResult(changed), nil
|
||
}
|
||
|
||
func validDiscussionGroup(channel domain.Channel) bool {
|
||
return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted
|
||
}
|
||
|
||
func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int {
|
||
if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin {
|
||
return 0
|
||
}
|
||
next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds
|
||
if now >= next {
|
||
return 0
|
||
}
|
||
return next - now
|
||
}
|