595 lines
21 KiB
Go
595 lines
21 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"github.com/iamxvbaba/td/tg"
|
|
|
|
"github.com/iamxvbaba/td/tlprofile"
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
const (
|
|
maxStatsPublicForwardsLimit = 100
|
|
maxStatsOffsetLength = 128
|
|
maxStatsGraphTokenLength = 128
|
|
)
|
|
|
|
func (r *Router) registerStats(d *tlprofile.Dispatcher) {
|
|
registerRPC[*tg.StatsGetBroadcastStatsRequest](d, tlprofile.SemanticMethodStatsGetBroadcastStats, func(ctx context.Context, layerRequest *tg.StatsGetBroadcastStatsRequest) (any, error) {
|
|
return r.onStatsGetBroadcastStats(ctx, layerRequest)
|
|
})
|
|
registerRPC[*tg.StatsGetMegagroupStatsRequest](d, tlprofile.SemanticMethodStatsGetMegagroupStats, func(ctx context.Context, layerRequest *tg.StatsGetMegagroupStatsRequest) (any, error) {
|
|
return r.onStatsGetMegagroupStats(ctx, layerRequest)
|
|
})
|
|
registerRPC[*tg.StatsGetMessageStatsRequest](d, tlprofile.SemanticMethodStatsGetMessageStats, func(ctx context.Context, layerRequest *tg.StatsGetMessageStatsRequest) (any, error) {
|
|
return r.onStatsGetMessageStats(ctx, layerRequest)
|
|
})
|
|
registerRPC[*tg.StatsGetMessagePublicForwardsRequest](d, tlprofile.SemanticMethodStatsGetMessagePublicForwards, func(ctx context.Context, layerRequest *tg.StatsGetMessagePublicForwardsRequest) (any, error) {
|
|
return r.onStatsGetMessagePublicForwards(ctx, layerRequest)
|
|
})
|
|
registerRPC[*tg.StatsLoadAsyncGraphRequest](d, tlprofile.SemanticMethodStatsLoadAsyncGraph, func(ctx context.Context, layerRequest *tg.StatsLoadAsyncGraphRequest) (any, error) {
|
|
return r.onStatsLoadAsyncGraph(ctx, layerRequest)
|
|
})
|
|
registerRPC[*tg.StatsGetStoryStatsRequest](d, tlprofile.SemanticMethodStatsGetStoryStats, func(ctx context.Context, layerRequest *tg.StatsGetStoryStatsRequest) (any, error) {
|
|
return r.onStatsGetStoryStats(ctx, layerRequest)
|
|
})
|
|
registerRPC[*tg.StatsGetStoryPublicForwardsRequest](d, tlprofile.SemanticMethodStatsGetStoryPublicForwards, func(ctx context.Context, layerRequest *tg.StatsGetStoryPublicForwardsRequest) (any, error) {
|
|
return r.onStatsGetStoryPublicForwards(ctx, layerRequest)
|
|
})
|
|
registerRPC[*tg.StatsGetPollStatsRequest](d, tlprofile.SemanticMethodStatsGetPollStats, func(ctx context.Context, layerRequest *tg.StatsGetPollStatsRequest) (any, error) {
|
|
return r.onStatsGetPollStats(ctx, layerRequest)
|
|
})
|
|
}
|
|
|
|
func (r *Router) onStatsGetBroadcastStats(ctx context.Context, req *tg.StatsGetBroadcastStatsRequest) (*tg.StatsBroadcastStats, error) {
|
|
view, err := r.statsChannelView(ctx, req.Channel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !view.Channel.Broadcast {
|
|
return nil, tgerr400("BROADCAST_REQUIRED")
|
|
}
|
|
stats, err := r.deps.Channels.GetStats(ctx, view.Self.UserID, domain.ChannelStatsRequest{
|
|
ChannelID: view.Channel.ID,
|
|
Period: r.statsPeriod(),
|
|
})
|
|
if err != nil {
|
|
return nil, statsServiceErr(err)
|
|
}
|
|
return r.tgBroadcastStats(stats), nil
|
|
}
|
|
|
|
func (r *Router) onStatsGetMegagroupStats(ctx context.Context, req *tg.StatsGetMegagroupStatsRequest) (*tg.StatsMegagroupStats, error) {
|
|
view, err := r.statsChannelView(ctx, req.Channel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !view.Channel.Megagroup {
|
|
return nil, tgerr400("MEGAGROUP_REQUIRED")
|
|
}
|
|
stats, err := r.deps.Channels.GetStats(ctx, view.Self.UserID, domain.ChannelStatsRequest{
|
|
ChannelID: view.Channel.ID,
|
|
Period: r.statsPeriod(),
|
|
})
|
|
if err != nil {
|
|
return nil, statsServiceErr(err)
|
|
}
|
|
return r.tgMegagroupStats(ctx, view.Self.UserID, stats), nil
|
|
}
|
|
|
|
func (r *Router) onStatsGetMessageStats(ctx context.Context, req *tg.StatsGetMessageStatsRequest) (*tg.StatsMessageStats, error) {
|
|
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
|
return nil, messageIDInvalidErr()
|
|
}
|
|
view, err := r.statsChannelView(ctx, req.Channel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats, err := r.deps.Channels.GetMessageStats(ctx, view.Self.UserID, domain.ChannelMessageStatsRequest{
|
|
ChannelID: view.Channel.ID,
|
|
MessageID: req.MsgID,
|
|
Period: r.statsPeriod(),
|
|
})
|
|
if err != nil {
|
|
return nil, statsServiceErr(err)
|
|
}
|
|
return &tg.StatsMessageStats{
|
|
ViewsGraph: r.statsGraph(stats.Days, []statsGraphSeries{{
|
|
Key: "views", Label: "Views", Color: statsGraphColors[0], Value: func(day domain.ChannelStatsDay) int { return day.Views },
|
|
}}),
|
|
ReactionsByEmotionGraph: r.statsReactionGraph(stats.Days),
|
|
}, nil
|
|
}
|
|
|
|
func (r *Router) onStatsGetMessagePublicForwards(ctx context.Context, req *tg.StatsGetMessagePublicForwardsRequest) (*tg.StatsPublicForwards, error) {
|
|
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
|
return nil, messageIDInvalidErr()
|
|
}
|
|
if req.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit {
|
|
return nil, limitInvalidErr()
|
|
}
|
|
if len(req.Offset) > maxStatsOffsetLength {
|
|
return nil, tgerr400("OFFSET_INVALID")
|
|
}
|
|
view, err := r.statsChannelView(ctx, req.Channel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limit := req.Limit
|
|
if limit <= 0 {
|
|
limit = maxStatsPublicForwardsLimit
|
|
}
|
|
list, err := r.deps.Channels.ListMessagePublicForwards(ctx, view.Self.UserID, domain.ChannelMessagePublicForwardListRequest{
|
|
ChannelID: view.Channel.ID,
|
|
MessageID: req.MsgID,
|
|
Offset: req.Offset,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, statsServiceErr(err)
|
|
}
|
|
views := make([]domain.StoryView, 0, len(list.Messages))
|
|
for _, message := range list.Messages {
|
|
views = append(views, domain.StoryView{Date: message.Date, PublicForward: &domain.StoryPublicForward{Message: message}})
|
|
}
|
|
return r.tgStatsPublicForwards(ctx, view.Self.UserID, domain.StoryPublicForwardList{
|
|
Count: list.Count, Forwards: views, NextOffset: list.NextOffset,
|
|
}), nil
|
|
}
|
|
|
|
func (r *Router) onStatsLoadAsyncGraph(_ context.Context, req *tg.StatsLoadAsyncGraphRequest) (tg.StatsGraphClass, error) {
|
|
if len(req.Token) > maxStatsGraphTokenLength {
|
|
return &tg.StatsGraphError{Error: "GRAPH_INVALID_RELOAD"}, nil
|
|
}
|
|
return &tg.StatsGraphError{Error: "GRAPH_INVALID_RELOAD"}, nil
|
|
}
|
|
|
|
func (r *Router) onStatsGetStoryStats(ctx context.Context, req *tg.StatsGetStoryStatsRequest) (*tg.StatsStoryStats, error) {
|
|
if req.ID <= 0 || req.ID > domain.MaxStoryID {
|
|
return nil, storyIDInvalidErr()
|
|
}
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.deps.Stories != nil {
|
|
if err := r.deps.Stories.CanViewStoryStats(ctx, userID, peer); err != nil {
|
|
return nil, storyErr(err)
|
|
}
|
|
}
|
|
date := int(r.clock.Now().Unix()) - 1
|
|
views := 0
|
|
reactions := []domain.ChannelMessageReactionCount(nil)
|
|
if r.deps.Stories != nil {
|
|
list, loadErr := r.deps.Stories.GetStoriesByID(ctx, userID, peer, []int{req.ID}, int(r.clock.Now().Unix()))
|
|
if loadErr != nil {
|
|
return nil, storyErr(loadErr)
|
|
}
|
|
for _, story := range list.Stories {
|
|
if story.ID != req.ID || story.Owner != peer || story.Deleted {
|
|
continue
|
|
}
|
|
date = story.Date
|
|
views = story.Views.ViewsCount
|
|
reactions = story.Views.Reactions
|
|
break
|
|
}
|
|
}
|
|
return &tg.StatsStoryStats{
|
|
ViewsGraph: r.statsSnapshotGraph("views", "Views", date, views),
|
|
ReactionsByEmotionGraph: r.statsReactionSnapshotGraph(date, reactions),
|
|
}, nil
|
|
}
|
|
|
|
func (r *Router) onStatsGetStoryPublicForwards(ctx context.Context, req *tg.StatsGetStoryPublicForwardsRequest) (*tg.StatsPublicForwards, error) {
|
|
if req.ID <= 0 || req.ID > domain.MaxStoryID {
|
|
return nil, storyIDInvalidErr()
|
|
}
|
|
if req.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit || len(req.Offset) > maxStatsOffsetLength {
|
|
return nil, limitInvalidErr()
|
|
}
|
|
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
|
return nil, storyErr(err)
|
|
}
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.deps.Stories == nil || userID == 0 {
|
|
return emptyStatsPublicForwards(), nil
|
|
}
|
|
limit := req.Limit
|
|
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
|
|
limit = domain.MaxStoryInteractionListLimit
|
|
}
|
|
storyForwards, err := r.deps.Stories.GetStoryPublicForwards(ctx, userID, domain.StoryPublicForwardListRequest{
|
|
ViewerUserID: userID,
|
|
Owner: peer,
|
|
StoryID: req.ID,
|
|
Offset: req.Offset,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, storyErr(err)
|
|
}
|
|
messageForwards := domain.StoryMessageForwardList{}
|
|
if r.deps.Channels != nil {
|
|
messageForwards, err = r.deps.Channels.ListStoryMessageForwards(ctx, userID, domain.StoryMessageForwardListRequest{
|
|
ViewerUserID: userID,
|
|
Owner: peer,
|
|
StoryID: req.ID,
|
|
Offset: req.Offset,
|
|
Limit: limit,
|
|
ForwardsFirst: true,
|
|
})
|
|
if err != nil {
|
|
return nil, storyErr(err)
|
|
}
|
|
}
|
|
hasMore := storyInteractionSourcesHaveMore(len(storyForwards.Forwards), len(messageForwards.Forwards), limit, storyForwards.NextOffset != "" || messageForwards.NextOffset != "")
|
|
forwards := mergeStoryInteractionViews(storyForwards.Forwards, messageForwards.Forwards, limit, false, true)
|
|
return r.tgStatsPublicForwards(ctx, userID, domain.StoryPublicForwardList{
|
|
Count: storyForwards.Count + messageForwards.Count,
|
|
Forwards: forwards,
|
|
NextOffset: nextStoryInteractionOffset(forwards, limit, false, true, hasMore),
|
|
}), nil
|
|
}
|
|
|
|
func (r *Router) onStatsGetPollStats(ctx context.Context, req *tg.StatsGetPollStatsRequest) (*tg.StatsPollStats, error) {
|
|
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
|
return nil, messageIDInvalidErr()
|
|
}
|
|
userID, _, err := r.currentUserID(ctx)
|
|
if err != nil {
|
|
return nil, internalErr()
|
|
}
|
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
date, votes := int(r.clock.Now().Unix())-1, 0
|
|
if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil {
|
|
history, loadErr := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{req.MsgID})
|
|
if loadErr != nil {
|
|
return nil, channelInvalidErr(loadErr)
|
|
}
|
|
for _, message := range history.Messages {
|
|
if message.ID == req.MsgID && message.Media != nil && message.Media.Poll != nil {
|
|
date = message.Date
|
|
if message.Media.Poll.Results != nil {
|
|
votes = message.Media.Poll.Results.TotalVoters
|
|
}
|
|
break
|
|
}
|
|
}
|
|
} else if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil {
|
|
messages, loadErr := r.deps.Messages.GetMessages(ctx, userID, []int{req.MsgID})
|
|
if loadErr != nil {
|
|
return nil, internalErr()
|
|
}
|
|
for _, message := range messages.Messages {
|
|
if message.ID == req.MsgID && message.Peer == peer && message.Media != nil && message.Media.Poll != nil {
|
|
date = message.Date
|
|
if message.Media.Poll.Results != nil {
|
|
votes = message.Media.Poll.Results.TotalVoters
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return &tg.StatsPollStats{VotesGraph: r.statsSnapshotGraph("votes", "Votes", date, votes)}, nil
|
|
}
|
|
|
|
func (r *Router) statsChannelView(ctx context.Context, input tg.InputChannelClass) (domain.ChannelView, error) {
|
|
_, view, err := r.channelView(ctx, input)
|
|
if err != nil {
|
|
return domain.ChannelView{}, err
|
|
}
|
|
if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin {
|
|
return domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED")
|
|
}
|
|
return view, nil
|
|
}
|
|
|
|
var statsGraphColors = [...]string{
|
|
"#4A90E2", "#50E3C2", "#F5A623", "#D0021B", "#9013FE", "#7ED321", "#B8E986", "#BD10E0",
|
|
}
|
|
|
|
type statsGraphSeries struct {
|
|
Key string
|
|
Label string
|
|
Color string
|
|
Value func(domain.ChannelStatsDay) int
|
|
}
|
|
|
|
type statsGraphJSON struct {
|
|
Columns [][]any `json:"columns"`
|
|
Types map[string]string `json:"types"`
|
|
Names map[string]string `json:"names"`
|
|
Colors map[string]string `json:"colors"`
|
|
}
|
|
|
|
func (r *Router) statsPeriod() domain.StatsPeriod {
|
|
maxDate := int(r.clock.Now().Unix())
|
|
if maxDate <= 7*86400 {
|
|
maxDate = 7*86400 + 1
|
|
}
|
|
return domain.StatsPeriod{MinDate: maxDate - 7*86400, MaxDate: maxDate}
|
|
}
|
|
|
|
func tgStatsPeriod(period domain.StatsPeriod) tg.StatsDateRangeDays {
|
|
return tg.StatsDateRangeDays{MinDate: period.MinDate, MaxDate: period.MaxDate}
|
|
}
|
|
|
|
func tgStatsValue(value domain.StatsValueAndPrev) tg.StatsAbsValueAndPrev {
|
|
return tg.StatsAbsValueAndPrev{Current: value.Current, Previous: value.Previous}
|
|
}
|
|
|
|
func (r *Router) tgBroadcastStats(stats domain.ChannelStats) *tg.StatsBroadcastStats {
|
|
recent := make([]tg.PostInteractionCountersClass, 0, len(stats.RecentPosts))
|
|
for _, item := range stats.RecentPosts {
|
|
recent = append(recent, &tg.PostInteractionCountersMessage{
|
|
MsgID: item.MessageID, Views: item.Views, Forwards: item.Forwards, Reactions: item.Reactions,
|
|
})
|
|
}
|
|
return &tg.StatsBroadcastStats{
|
|
Period: tgStatsPeriod(stats.Period),
|
|
Followers: tgStatsValue(stats.Members),
|
|
ViewsPerPost: tgStatsValue(stats.ViewsPerPost),
|
|
SharesPerPost: tgStatsValue(stats.SharesPerPost),
|
|
ReactionsPerPost: tgStatsValue(stats.ReactionsPerPost),
|
|
ViewsPerStory: tg.StatsAbsValueAndPrev{},
|
|
SharesPerStory: tg.StatsAbsValueAndPrev{},
|
|
ReactionsPerStory: tg.StatsAbsValueAndPrev{},
|
|
EnabledNotifications: tg.StatsPercentValue{},
|
|
GrowthGraph: r.statsGraph(stats.Days, []statsGraphSeries{{
|
|
Key: "members", Label: "Followers", Color: statsGraphColors[0], Value: func(day domain.ChannelStatsDay) int { return day.Members },
|
|
}}),
|
|
FollowersGraph: r.statsGraph(stats.Days, []statsGraphSeries{{
|
|
Key: "new_members", Label: "New followers", Color: statsGraphColors[1], Value: func(day domain.ChannelStatsDay) int { return day.NewMembers },
|
|
}}),
|
|
MuteGraph: statsGraphUnavailable(),
|
|
TopHoursGraph: statsGraphUnavailable(),
|
|
InteractionsGraph: r.statsGraph(stats.Days, []statsGraphSeries{
|
|
{Key: "views", Label: "Views", Color: statsGraphColors[0], Value: func(day domain.ChannelStatsDay) int { return day.Views }},
|
|
{Key: "shares", Label: "Shares", Color: statsGraphColors[1], Value: func(day domain.ChannelStatsDay) int { return day.Shares }},
|
|
}),
|
|
IvInteractionsGraph: statsGraphUnavailable(),
|
|
ViewsBySourceGraph: statsGraphUnavailable(),
|
|
NewFollowersBySourceGraph: statsGraphUnavailable(),
|
|
LanguagesGraph: statsGraphUnavailable(),
|
|
ReactionsByEmotionGraph: r.statsReactionGraph(stats.Days),
|
|
StoryInteractionsGraph: statsGraphUnavailable(),
|
|
StoryReactionsByEmotionGraph: statsGraphUnavailable(),
|
|
RecentPostsInteractions: recent,
|
|
}
|
|
}
|
|
|
|
func (r *Router) tgMegagroupStats(ctx context.Context, viewerUserID int64, stats domain.ChannelStats) *tg.StatsMegagroupStats {
|
|
posterIDs := make([]int64, 0, len(stats.TopPosters))
|
|
topPosters := make([]tg.StatsGroupTopPoster, 0, len(stats.TopPosters))
|
|
for _, item := range stats.TopPosters {
|
|
posterIDs = append(posterIDs, item.UserID)
|
|
topPosters = append(topPosters, tg.StatsGroupTopPoster{UserID: item.UserID, Messages: item.Messages, AvgChars: item.AvgChars})
|
|
}
|
|
users := tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, posterIDs))
|
|
out := &tg.StatsMegagroupStats{
|
|
Period: tgStatsPeriod(stats.Period),
|
|
Members: tgStatsValue(stats.Members),
|
|
Messages: tgStatsValue(stats.Messages),
|
|
Viewers: tgStatsValue(stats.Viewers),
|
|
Posters: tgStatsValue(stats.Posters),
|
|
GrowthGraph: r.statsGraph(stats.Days, []statsGraphSeries{{
|
|
Key: "members", Label: "Members", Color: statsGraphColors[0], Value: func(day domain.ChannelStatsDay) int { return day.Members },
|
|
}}),
|
|
MembersGraph: r.statsGraph(stats.Days, []statsGraphSeries{{
|
|
Key: "new_members", Label: "New members", Color: statsGraphColors[1], Value: func(day domain.ChannelStatsDay) int { return day.NewMembers },
|
|
}}),
|
|
NewMembersBySourceGraph: statsGraphUnavailable(),
|
|
LanguagesGraph: statsGraphUnavailable(),
|
|
MessagesGraph: r.statsGraph(stats.Days, []statsGraphSeries{{
|
|
Key: "messages", Label: "Messages", Color: statsGraphColors[0], Value: func(day domain.ChannelStatsDay) int { return day.Messages },
|
|
}}),
|
|
ActionsGraph: statsGraphUnavailable(),
|
|
TopHoursGraph: statsGraphUnavailable(),
|
|
WeekdaysGraph: statsGraphUnavailable(),
|
|
TopPosters: topPosters,
|
|
TopAdmins: []tg.StatsGroupTopAdmin{},
|
|
TopInviters: []tg.StatsGroupTopInviter{},
|
|
Users: users,
|
|
}
|
|
r.applyPeerReadModels(ctx, viewerUserID, out.Users, nil)
|
|
return out
|
|
}
|
|
|
|
func (r *Router) statsGraph(days []domain.ChannelStatsDay, series []statsGraphSeries) tg.StatsGraphClass {
|
|
graph := statsGraphJSON{
|
|
Columns: make([][]any, 0, len(series)+1),
|
|
Types: map[string]string{"x": "x"},
|
|
Names: make(map[string]string, len(series)),
|
|
Colors: make(map[string]string, len(series)),
|
|
}
|
|
x := make([]any, 1, len(days)+1)
|
|
x[0] = "x"
|
|
for _, day := range days {
|
|
x = append(x, int64(day.Date)*1000)
|
|
}
|
|
graph.Columns = append(graph.Columns, x)
|
|
for i, item := range series {
|
|
key := "y" + strconv.Itoa(i)
|
|
column := make([]any, 1, len(days)+1)
|
|
column[0] = key
|
|
for _, day := range days {
|
|
column = append(column, item.Value(day))
|
|
}
|
|
graph.Columns = append(graph.Columns, column)
|
|
graph.Types[key] = "line"
|
|
graph.Names[key] = item.Label
|
|
color := item.Color
|
|
if color == "" {
|
|
color = statsGraphColors[i%len(statsGraphColors)]
|
|
}
|
|
graph.Colors[key] = color
|
|
}
|
|
data, err := json.Marshal(graph)
|
|
if err != nil {
|
|
return &tg.StatsGraphError{Error: "GRAPH_SERIALIZATION_FAILED"}
|
|
}
|
|
return &tg.StatsGraph{JSON: tg.DataJSON{Data: string(data)}}
|
|
}
|
|
|
|
func (r *Router) statsReactionGraph(days []domain.ChannelStatsDay) tg.StatsGraphClass {
|
|
byKey := make(map[string]domain.MessageReaction)
|
|
for _, day := range days {
|
|
for _, item := range day.ByReaction {
|
|
byKey[item.Reaction.Key()] = item.Reaction
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(byKey))
|
|
for key := range byKey {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
if len(keys) == 0 {
|
|
return r.statsGraph(days, []statsGraphSeries{{
|
|
Key: "reactions", Label: "Reactions", Color: statsGraphColors[2], Value: func(day domain.ChannelStatsDay) int { return day.Reactions },
|
|
}})
|
|
}
|
|
series := make([]statsGraphSeries, 0, len(keys))
|
|
for i, reactionKey := range keys {
|
|
key := reactionKey
|
|
reaction := byKey[key]
|
|
series = append(series, statsGraphSeries{
|
|
Key: key, Label: statsReactionLabel(reaction), Color: statsGraphColors[i%len(statsGraphColors)],
|
|
Value: func(day domain.ChannelStatsDay) int {
|
|
for _, item := range day.ByReaction {
|
|
if item.Reaction.Key() == key {
|
|
return item.Count
|
|
}
|
|
}
|
|
return 0
|
|
},
|
|
})
|
|
}
|
|
return r.statsGraph(days, series)
|
|
}
|
|
|
|
func statsReactionLabel(reaction domain.MessageReaction) string {
|
|
if reaction.Type == domain.MessageReactionCustomEmoji {
|
|
return fmt.Sprintf("Custom emoji %d", reaction.DocumentID)
|
|
}
|
|
if reaction.Emoticon != "" {
|
|
return reaction.Emoticon
|
|
}
|
|
return "Reaction"
|
|
}
|
|
|
|
func (r *Router) statsSnapshotGraph(key, label string, date, value int) tg.StatsGraphClass {
|
|
now := int(r.clock.Now().Unix())
|
|
if date <= 0 || date >= now {
|
|
date = now - 1
|
|
}
|
|
days := []domain.ChannelStatsDay{{Date: date}, {Date: now, Views: value, Reactions: value}}
|
|
return r.statsGraph(days, []statsGraphSeries{{
|
|
Key: key, Label: label, Color: statsGraphColors[0],
|
|
Value: func(day domain.ChannelStatsDay) int {
|
|
if key == "views" {
|
|
return day.Views
|
|
}
|
|
return day.Reactions
|
|
},
|
|
}})
|
|
}
|
|
|
|
func (r *Router) statsReactionSnapshotGraph(date int, counts []domain.ChannelMessageReactionCount) tg.StatsGraphClass {
|
|
now := int(r.clock.Now().Unix())
|
|
if date <= 0 || date >= now {
|
|
date = now - 1
|
|
}
|
|
end := domain.ChannelStatsDay{Date: now}
|
|
for _, item := range counts {
|
|
end.Reactions += item.Count
|
|
end.ByReaction = append(end.ByReaction, domain.StatsReactionCount{Reaction: item.Reaction, Count: item.Count})
|
|
}
|
|
return r.statsReactionGraph([]domain.ChannelStatsDay{{Date: date}, end})
|
|
}
|
|
|
|
func statsGraphUnavailable() tg.StatsGraphClass {
|
|
return &tg.StatsGraphError{Error: "GRAPH_NOT_AVAILABLE"}
|
|
}
|
|
|
|
func statsServiceErr(err error) error {
|
|
switch {
|
|
case errors.Is(err, domain.ErrMessageIDInvalid):
|
|
return messageIDInvalidErr()
|
|
case errors.Is(err, domain.ErrStatsOffsetInvalid):
|
|
return tgerr400("OFFSET_INVALID")
|
|
default:
|
|
return channelInvalidErr(err)
|
|
}
|
|
}
|
|
|
|
func emptyStatsPublicForwards() *tg.StatsPublicForwards {
|
|
return &tg.StatsPublicForwards{
|
|
Count: 0,
|
|
Forwards: []tg.PublicForwardClass{},
|
|
Chats: []tg.ChatClass{},
|
|
Users: []tg.UserClass{},
|
|
}
|
|
}
|
|
|
|
func (r *Router) tgStatsPublicForwards(ctx context.Context, viewerUserID int64, list domain.StoryPublicForwardList) *tg.StatsPublicForwards {
|
|
users := r.domainUsersForIDs(ctx, viewerUserID, storyViewUserIDs(list.Forwards))
|
|
peerUsers, peerChannels := r.storyPeerObjects(ctx, viewerUserID, storyViewPeers(list.Forwards))
|
|
users = mergeDomainUsers(users, peerUsers...)
|
|
out := &tg.StatsPublicForwards{
|
|
Count: list.Count,
|
|
Forwards: tgPublicForwardItems(viewerUserID, list.Forwards),
|
|
Chats: tgChannels(viewerUserID, peerChannels),
|
|
Users: tgUsersForViewer(viewerUserID, users),
|
|
}
|
|
if list.NextOffset != "" {
|
|
out.SetNextOffset(list.NextOffset)
|
|
}
|
|
r.applyPeerReadModels(ctx, viewerUserID, out.Users, out.Chats)
|
|
return out
|
|
}
|
|
|
|
func tgPublicForwardItems(viewerUserID int64, views []domain.StoryView) []tg.PublicForwardClass {
|
|
out := make([]tg.PublicForwardClass, 0, len(views))
|
|
for _, view := range views {
|
|
if view.PublicForward != nil {
|
|
msg := tgChannelMessage(viewerUserID, view.PublicForward.Message)
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
out = append(out, &tg.PublicForwardMessage{Message: msg})
|
|
continue
|
|
}
|
|
if view.Repost != nil {
|
|
peer := tgPeer(view.Repost.Owner)
|
|
if peer == nil {
|
|
continue
|
|
}
|
|
out = append(out, &tg.PublicForwardStory{
|
|
Peer: peer,
|
|
Story: tgStoryItem(*view.Repost),
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|