owpengram-server/internal/store/postgres/channel_updates.go
2026-09-01 12:06:31 +03:00

699 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
var errChannelDifferenceCutChanged = errors.New("channel difference stable cut changed")
func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) {
for attempt := 0; attempt < 3; attempt++ {
diff, retry, err := s.listChannelDifferenceAttempt(ctx, req)
if !retry {
return diff, err
}
if s.rowCache != nil {
s.rowCache.delete(req.ChannelID)
}
if s.differenceCache != nil {
s.differenceCache.deleteChannel(req.ChannelID)
}
}
return domain.ChannelDifference{}, fmt.Errorf("list channel difference: stable cut changed repeatedly for channel %d", req.ChannelID)
}
func (s *ChannelStore) listChannelDifferenceAttempt(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, bool, error) {
channel, member, preview, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelDifference{}, false, err
}
if req.Pts < 0 || req.Pts > channel.Pts {
return domain.ChannelDifference{}, false, domain.ErrPersistentTimestamp
}
if !preview && member.AvailableMinPts > req.Pts {
req.Pts = minInt(member.AvailableMinPts, channel.Pts)
}
limit := req.Limit
if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
limit = domain.MaxChannelDifferenceLimit
}
// A current-PTS request has no retention range to prove. Access, membership,
// available_min_pts and future/stale bounds were already checked above; do
// not spend two pool acquisitions reading a checkpoint and an empty event
// range during reconnect storms.
if req.Pts == channel.Pts {
diff := domain.ChannelDifference{
Channel: channel,
Self: member,
Pts: channel.Pts,
Final: true,
Timeout: 30,
}
if preview {
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
} else {
dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel)
if err != nil {
return domain.ChannelDifference{}, false, err
}
diff.Dialog = dialog
}
return diff, false, nil
}
key := channelDifferenceBaseKey{
channelID: req.ChannelID,
requestPts: req.Pts,
capturedPts: channel.Pts,
capturedTopID: channel.TopMessageID,
limit: limit,
}
sharedBase := !channel.Monoforum && s.differenceCache != nil
load := func() (channelDifferenceBase, error) {
return s.loadChannelDifferenceBase(ctx, channel, member, req.UserID, req.Pts, limit, sharedBase)
}
var base channelDifferenceBase
if channel.Monoforum || s.differenceCache == nil {
base, err = load()
} else {
base, err = s.differenceCache.getOrLoad(ctx, key, load)
}
if errors.Is(err, errChannelDifferenceCutChanged) {
return domain.ChannelDifference{}, true, nil
}
if err != nil {
return domain.ChannelDifference{}, false, err
}
if base.tooLong {
diff := domain.ChannelDifference{
Channel: channel,
Self: member,
Pts: channel.Pts,
Final: true,
TooLong: true,
Timeout: 30,
}
for _, msg := range base.messages {
if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID {
continue
}
if !channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
continue
}
diff.NewMessages = append(diff.NewMessages, msg)
}
if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages, base); err != nil {
return domain.ChannelDifference{}, false, err
}
if preview {
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
} else {
dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel)
if err != nil {
return domain.ChannelDifference{}, false, err
}
diff.Dialog = dialog
}
return diff, false, nil
}
diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30}
var visibleMonoforumMessageIDs map[int]struct{}
if channel.Monoforum && !member.CanManageDirectMessages() {
messageIDs := make([]int, 0)
for _, event := range base.events {
messageIDs = append(messageIDs, event.MessageIDs...)
}
visibleMonoforumMessageIDs, err = s.monoforumVisibleMessageIDs(ctx, req.ChannelID, req.UserID, messageIDs)
if err != nil {
return domain.ChannelDifference{}, false, err
}
}
for _, event := range base.events {
visibleEvent, ok := domain.FilterChannelUpdateEventForAvailableMinID(event, member.AvailableMinID)
if !ok {
continue
}
event = visibleEvent
if channel.Monoforum && !member.CanManageDirectMessages() {
event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
if !ok {
continue
}
}
if preview && event.Type == domain.ChannelUpdateParticipant {
continue
}
diff.Events = append(diff.Events, event)
diff.Pts = event.Pts
switch event.Type {
case domain.ChannelUpdateNewMessage:
diff.NewMessages = append(diff.NewMessages, event.Message)
default:
diff.OtherUpdates = append(diff.OtherUpdates, event)
}
}
if len(diff.Events) == 0 {
diff.Pts = base.lastPts
} else if base.lastPts > diff.Pts {
diff.Pts = base.lastPts
}
if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages, base); err != nil {
return domain.ChannelDifference{}, false, err
}
// OtherUpdates 里带消息的事件未读/提及标记一次批量回填(原来逐事件一条 SQL 的 N+1
otherMsgs := make([]domain.ChannelMessage, 0, len(diff.OtherUpdates))
otherIdx := make([]int, 0, len(diff.OtherUpdates))
for i := range diff.OtherUpdates {
if diff.OtherUpdates[i].Message.ID == 0 {
continue
}
otherMsgs = append(otherMsgs, diff.OtherUpdates[i].Message)
otherIdx = append(otherIdx, i)
}
if len(otherMsgs) > 0 {
if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, otherMsgs, base); err != nil {
return domain.ChannelDifference{}, false, err
}
for j, i := range otherIdx {
diff.OtherUpdates[i].Message = otherMsgs[j]
}
}
if preview {
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
} else {
dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel)
if err != nil {
return domain.ChannelDifference{}, false, err
}
diff.Dialog = dialog
}
diff.Final = base.lastPts >= channel.Pts
return diff, false, nil
}
func (s *ChannelStore) loadChannelDifferenceBase(
ctx context.Context,
channel domain.Channel,
member domain.ChannelMember,
viewerUserID int64,
requestPts int,
limit int,
loadMentionCandidates bool,
) (channelDifferenceBase, error) {
checkpoint, err := getChannelUpdateCheckpoint(ctx, s.db, channel.ID)
if err != nil {
return channelDifferenceBase{}, err
}
base := channelDifferenceBase{
retainedThroughPts: checkpoint.RetainedThroughPts,
lastPts: requestPts,
}
if requestPts < checkpoint.RetainedThroughPts || channel.Pts-requestPts > limit {
base.tooLong = true
args := []any{channel.ID, channel.TopMessageID}
where := "channel_id = $1 AND id <= $2 AND NOT deleted"
// Non-monoforum pages are viewer-independent: apply available_min after
// the shared lookup. Monoforum latest-100 selection is viewer-specific,
// so that path bypasses the shared cache and keeps its predicate here.
if channel.Monoforum {
if member.AvailableMinID > 0 {
args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args))
}
if !member.CanManageDirectMessages() {
args = append(args, viewerUserID)
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
}
}
args = append(args, domain.MaxChannelDifferenceTooLongMessages)
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE `+where+`
ORDER BY id DESC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return channelDifferenceBase{}, fmt.Errorf("list channel too long messages: %w", err)
}
for rows.Next() {
msg, err := scanChannelMessage(rows)
if err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
base.messages = append(base.messages, msg)
}
if err := rows.Err(); err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
rows.Close()
if loadMentionCandidates {
if err := s.loadChannelDifferenceMentionCandidates(ctx, channel.ID, &base); err != nil {
return channelDifferenceBase{}, err
}
}
if err := s.verifyChannelDifferenceCut(ctx, channel, checkpoint.RetainedThroughPts); err != nil {
return channelDifferenceBase{}, err
}
return base, nil
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text
FROM channel_update_events
WHERE channel_id = $1 AND pts > $2 AND pts <= $3
ORDER BY pts ASC
LIMIT $4`, channel.ID, requestPts, channel.Pts, limit)
if err != nil {
return channelDifferenceBase{}, fmt.Errorf("list channel difference: %w", err)
}
for rows.Next() {
event, messageID, err := scanChannelEvent(rows)
if err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
ptsCount := event.PtsCount
if ptsCount <= 0 {
ptsCount = 1
}
if event.Pts != base.lastPts+ptsCount {
s.log.Warn("channel_difference_stopped_at_gap",
zap.String("scope", "channel"),
zap.Int64("channel_id", channel.ID),
zap.Int("request_pts", requestPts),
zap.Int("channel_pts", channel.Pts),
zap.Int("returned_pts", base.lastPts),
zap.Int("expected_pts", base.lastPts+ptsCount),
zap.Int("got_pts", event.Pts),
zap.Int("got_pts_count", ptsCount),
zap.String("event_type", string(event.Type)),
zap.Int("limit", limit),
)
break
}
if messageID != 0 && event.Message.ID == 0 {
event.Message, err = s.getChannelMessageAtOrBeforePts(ctx, channel.ID, messageID, channel.Pts)
if err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
}
base.lastPts = event.Pts
base.events = append(base.events, event)
}
if err := rows.Err(); err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
rows.Close()
if loadMentionCandidates {
if err := s.loadChannelDifferenceMentionCandidates(ctx, channel.ID, &base); err != nil {
return channelDifferenceBase{}, err
}
}
if err := s.verifyChannelDifferenceCut(ctx, channel, checkpoint.RetainedThroughPts); err != nil {
return channelDifferenceBase{}, err
}
return base, nil
}
func (s *ChannelStore) loadChannelDifferenceMentionCandidates(ctx context.Context, channelID int64, base *channelDifferenceBase) error {
if base == nil {
return nil
}
base.candidatesKnown = true
base.mentionCandidateIDs = make(map[int]struct{})
messageIDs := make([]int, 0, len(base.messages)+len(base.events))
seen := make(map[int]struct{}, cap(messageIDs))
add := func(id int) {
if id <= 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
messageIDs = append(messageIDs, id)
}
for _, message := range base.messages {
add(message.ID)
}
for _, event := range base.events {
add(event.Message.ID)
}
if len(messageIDs) == 0 {
return nil
}
rows, err := s.db.Query(ctx, `
SELECT DISTINCT message_id
FROM channel_unread_mention_index
WHERE channel_id = $1 AND message_id = ANY($2::int[])`, channelID, int32s(messageIDs))
if err != nil {
return fmt.Errorf("load channel difference mention candidates: %w", err)
}
defer rows.Close()
for rows.Next() {
var messageID int
if err := rows.Scan(&messageID); err != nil {
return err
}
base.mentionCandidateIDs[messageID] = struct{}{}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("read channel difference mention candidates: %w", err)
}
return nil
}
func populateChannelDifferenceUnreadFlags(
ctx context.Context,
db sqlcgen.DBTX,
viewerUserID int64,
messages []domain.ChannelMessage,
base channelDifferenceBase,
) error {
if !base.candidatesKnown {
return populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages)
}
selected := make([]domain.ChannelMessage, 0, len(messages))
indexes := make([]int, 0, len(messages))
for i, message := range messages {
if _, ok := base.mentionCandidateIDs[message.ID]; !ok {
continue
}
selected = append(selected, message)
indexes = append(indexes, i)
}
if len(selected) == 0 {
return nil
}
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, selected); err != nil {
return err
}
for i, messageIndex := range indexes {
messages[messageIndex].Mentioned = selected[i].Mentioned
messages[messageIndex].MediaUnread = selected[i].MediaUnread
}
return nil
}
func (s *ChannelStore) verifyChannelDifferenceCut(ctx context.Context, captured domain.Channel, retainedThroughPts int) error {
var pts, topMessageID, floor int
err := s.db.QueryRow(ctx, `
SELECT c.pts, c.top_message_id, cp.retained_through_pts
FROM channels c
JOIN channel_update_checkpoints cp ON cp.channel_id = c.id
WHERE c.id = $1 AND NOT c.deleted`, captured.ID).Scan(&pts, &topMessageID, &floor)
if err != nil {
return fmt.Errorf("verify channel difference cut: %w", err)
}
if pts != captured.Pts || topMessageID != captured.TopMessageID || floor != retainedThroughPts {
return errChannelDifferenceCutChanged
}
return nil
}
func (s *ChannelStore) getChannelMessageAtOrBeforePts(ctx context.Context, channelID int64, messageID, capturedPts int) (domain.ChannelMessage, error) {
msg, err := scanChannelMessage(s.db.QueryRow(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE channel_id = $1 AND id = $2 AND pts <= $3`, channelID, messageID, capturedPts))
if err != nil {
return domain.ChannelMessage{}, fmt.Errorf("load channel difference legacy message at stable cut: %w", err)
}
return msg, nil
}
func (s *ChannelStore) monoforumVisibleMessageIDs(ctx context.Context, channelID, userID int64, ids []int) (map[int]struct{}, error) {
visible := make(map[int]struct{})
if len(ids) == 0 {
return visible, nil
}
rows, err := s.db.Query(ctx, `
SELECT id
FROM channel_messages
WHERE channel_id = $1
AND id = ANY($2::int[])
AND saved_peer_type = 'user'
AND saved_peer_id = $3`, channelID, int32s(ids), userID)
if err != nil {
return nil, fmt.Errorf("list visible monoforum message ids: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, err
}
visible[id] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, err
}
return visible, nil
}
func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) {
if event.Message.ID != 0 {
return event, event.Message.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID})
}
if len(event.MessageIDs) == 0 {
return event, false
}
ids := make([]int, 0, len(event.MessageIDs))
for _, id := range event.MessageIDs {
if _, ok := visibleMessageIDs[id]; ok {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return event, false
}
event.MessageIDs = ids
return event, true
}
func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) {
var pts int
err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts)
return pts, err
}
func (s *ChannelStore) MaxChannelPtsBatch(ctx context.Context, channelIDs []int64) (map[int64]int, error) {
out := make(map[int64]int, len(channelIDs))
if len(channelIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `SELECT id, pts FROM channels WHERE id = ANY($1::bigint[])`, channelIDs)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var channelID int64
var pts int
if err := rows.Scan(&channelID, &pts); err != nil {
return nil, err
}
out[channelID] = pts
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func transientChannelParticipantEvent(channelID, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent {
return domain.ChannelUpdateEvent{
ChannelID: channelID,
Type: domain.ChannelUpdateParticipant,
Date: date,
SenderUserID: actorUserID,
UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID),
Previous: previous,
Participant: participant,
}
}
func (s *ChannelStore) reserveChannelPts(ctx context.Context, db sqlcgen.DBTX, channelID int64) (int, error) {
return s.reserveChannelPtsN(ctx, db, channelID, 1)
}
func (s *ChannelStore) reserveChannelPtsN(ctx context.Context, db sqlcgen.DBTX, channelID int64, count int) (int, error) {
count = normalizePtsCount(count)
caller := traceCaller(2)
pts, err := reserveChannelPts(ctx, db, channelID, count)
if err != nil {
s.log.Warn("pts_reserve_failed",
zap.String("scope", "channel"),
zap.Int64("channel_id", channelID),
zap.Int("pts_count", count),
zap.String("caller", traceCaller(2)),
zap.Error(err),
zap.Error(ctx.Err()),
)
return 0, err
}
s.log.Debug("pts_reserve",
zap.String("scope", "channel"),
zap.Int64("channel_id", channelID),
zap.Int("pts", pts),
zap.Int("pts_count", maxInt(count, 1)),
zap.String("caller", caller),
)
return pts, nil
}
func reserveChannelPts(ctx context.Context, db sqlcgen.DBTX, channelID int64, count int) (int, error) {
count = normalizePtsCount(count)
if channelID == 0 {
return 0, fmt.Errorf("channel pts: missing channel id")
}
var pts int
if err := db.QueryRow(ctx, `
UPDATE channels
SET pts = pts + $2,
updated_at = now()
WHERE id = $1
RETURNING pts`, channelID, count).Scan(&pts); err != nil {
return 0, fmt.Errorf("reserve channel pts: %w", err)
}
return pts, nil
}
func insertChannelEventTx(ctx context.Context, tx pgx.Tx, event domain.ChannelUpdateEvent) error {
ids, err := marshalJSON(event.MessageIDs, "[]")
if err != nil {
return err
}
userIDs, err := marshalJSON(event.UserIDs, "[]")
if err != nil {
return err
}
payloadData := map[string]any{
"message_id": event.Message.ID,
"pinned": event.Pinned,
}
if event.Message.ID != 0 {
payloadData["message"] = event.Message
}
if event.Previous.UserID != 0 {
payloadData["previous_participant"] = event.Previous
}
if event.Participant.UserID != 0 {
payloadData["participant"] = event.Participant
}
payload, err := marshalJSON(payloadData, "{}")
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO channel_update_events (
channel_id, pts, pts_count, date, event_type, message_id, message_ids, sender_user_id, user_ids, payload
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
event.ChannelID, event.Pts, event.PtsCount, event.Date, string(event.Type), event.Message.ID,
ids, event.SenderUserID, userIDs, payload); err != nil {
return fmt.Errorf("insert channel event: %w", err)
}
// The checkpoint is updated in the same business transaction as the event row. Retention may
// later remove the row, but account-level dirty-channel recovery still has the latest date/pts.
if _, err := tx.Exec(ctx, `
INSERT INTO channel_update_checkpoints (
channel_id, retained_through_pts, latest_event_date, latest_pts
) VALUES ($1, 0, $2, $3)
ON CONFLICT (channel_id) DO UPDATE SET
latest_event_date = GREATEST(channel_update_checkpoints.latest_event_date, EXCLUDED.latest_event_date),
latest_pts = GREATEST(channel_update_checkpoints.latest_pts, EXCLUDED.latest_pts),
updated_at = now()`, event.ChannelID, event.Date, event.Pts); err != nil {
return fmt.Errorf("upsert channel update checkpoint: %w", err)
}
return nil
}
func scanChannelEvent(row rowScanner) (domain.ChannelUpdateEvent, int, error) {
var event domain.ChannelUpdateEvent
var typ string
var messageID int
var messageIDs, userIDs, payload string
if err := row.Scan(
&event.ChannelID, &event.Pts, &event.PtsCount, &event.Date, &typ, &messageID,
&messageIDs, &event.SenderUserID, &userIDs, &payload,
); err != nil {
return domain.ChannelUpdateEvent{}, 0, err
}
event.Type = domain.ChannelUpdateEventType(typ)
_ = json.Unmarshal([]byte(messageIDs), &event.MessageIDs)
_ = json.Unmarshal([]byte(userIDs), &event.UserIDs)
var data struct {
Pinned bool `json:"pinned"`
Message domain.ChannelMessage `json:"message"`
PreviousParticipant domain.ChannelMember `json:"previous_participant"`
Participant domain.ChannelMember `json:"participant"`
}
_ = json.Unmarshal([]byte(payload), &data)
event.Pinned = data.Pinned
if data.Message.ID != 0 {
event.Message = data.Message
}
event.Previous = data.PreviousParticipant
event.Participant = data.Participant
return event, messageID, nil
}
func channelInitialAvailableMinPts(channel domain.Channel) int {
return channel.Pts
}
func adminLogEventTypesForFilter(filter domain.ChannelAdminLogFilter) []string {
if filter.Empty() {
return nil
}
types := make([]string, 0, 16)
add := func(enabled bool, typ domain.ChannelAdminLogEventType) {
if enabled {
types = append(types, string(typ))
}
}
add(filter.Join, domain.ChannelAdminLogParticipantJoin)
add(filter.Leave, domain.ChannelAdminLogParticipantLeave)
add(filter.Invite || filter.Invites, domain.ChannelAdminLogParticipantInvite)
add(filter.Ban, domain.ChannelAdminLogParticipantBan)
add(filter.Unban, domain.ChannelAdminLogParticipantUnban)
add(filter.Kick, domain.ChannelAdminLogParticipantKick)
add(filter.Unkick, domain.ChannelAdminLogParticipantUnkick)
add(filter.Promote, domain.ChannelAdminLogParticipantPromote)
add(filter.Demote, domain.ChannelAdminLogParticipantDemote)
add(filter.EditRank, domain.ChannelAdminLogParticipantEditRank)
if filter.Info {
types = append(types,
string(domain.ChannelAdminLogChangeTitle),
string(domain.ChannelAdminLogChangeUsername),
string(domain.ChannelAdminLogChangeLinkedChat),
string(domain.ChannelAdminLogToggleSlowMode),
)
}
if filter.Settings {
types = append(types,
string(domain.ChannelAdminLogToggleSignatures),
string(domain.ChannelAdminLogTogglePreHistoryHidden),
string(domain.ChannelAdminLogToggleAntiSpam),
string(domain.ChannelAdminLogToggleAutotranslation),
)
}
add(filter.Forums || filter.Settings, domain.ChannelAdminLogToggleForum)
add(filter.Pinned, domain.ChannelAdminLogUpdatePinned)
add(filter.Edit, domain.ChannelAdminLogEditMessage)
add(filter.Delete, domain.ChannelAdminLogDeleteMessage)
add(filter.Send, domain.ChannelAdminLogSendMessage)
return types
}