nexo/services/core/server/channels/app/recap.go

339 lines
11 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// CreateRecap creates a new recap job for the specified channels
func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, agentID string) (*model.Recap, *model.AppError) {
userID := rctx.Session().UserId
// Validate user is member of all channels
for _, channelID := range channelIDs {
if ok, _ := a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionReadChannel); !ok {
return nil, model.NewAppError("CreateRecap", "app.recap.permission_denied", nil, "", http.StatusForbidden)
}
}
timeNow := model.GetMillis()
// Create recap record
recap := &model.Recap{
Id: model.NewId(),
UserId: userID,
Title: title,
CreateAt: timeNow,
UpdateAt: timeNow,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 0,
Status: model.RecapStatusPending,
BotID: agentID,
}
savedRecap, err := a.Srv().Store().Recap().SaveRecap(recap)
if err != nil {
return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Create background job
jobData := map[string]string{
"recap_id": recap.Id,
"user_id": userID,
"channel_ids": strings.Join(channelIDs, ","),
"agent_id": agentID,
}
_, jobErr := a.CreateJob(rctx, &model.Job{
Type: model.JobTypeRecap,
Data: jobData,
})
if jobErr != nil {
return nil, jobErr
}
return savedRecap, nil
}
// GetRecap retrieves a recap by ID
func (a *App) GetRecap(rctx request.CTX, recapID string) (*model.Recap, *model.AppError) {
recap, err := a.Srv().Store().Recap().GetRecap(recapID)
if err != nil {
return nil, model.NewAppError("GetRecap", "app.recap.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
}
// Load channels
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
if err != nil {
return nil, model.NewAppError("GetRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
recap.Channels = channels
return recap, nil
}
// GetRecapsForUser retrieves all recaps for a user
func (a *App) GetRecapsForUser(rctx request.CTX, page, perPage int) ([]*model.Recap, *model.AppError) {
userID := rctx.Session().UserId
recaps, err := a.Srv().Store().Recap().GetRecapsForUser(userID, page, perPage)
if err != nil {
return nil, model.NewAppError("GetRecapsForUser", "app.recap.list.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return recaps, nil
}
// MarkRecapAsRead marks a recap as read
func (a *App) MarkRecapAsRead(rctx request.CTX, recap *model.Recap) (*model.Recap, *model.AppError) {
// Mark as read
if markErr := a.Srv().Store().Recap().MarkRecapAsRead(recap.Id); markErr != nil {
return nil, model.NewAppError("MarkRecapAsRead", "app.recap.mark_read.app_error", nil, "", http.StatusInternalServerError).Wrap(markErr)
}
// Update the passed recap with read timestamp
recap.ReadAt = model.GetMillis()
recap.UpdateAt = recap.ReadAt
// Load channels if not already loaded
if recap.Channels == nil {
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recap.Id)
if err != nil {
return nil, model.NewAppError("MarkRecapAsRead", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
recap.Channels = channels
}
return recap, nil
}
// MarkRecapsAsViewed marks all of the user's not-yet-viewed completed/failed
// recaps as viewed at the current timestamp and broadcasts a recap_updated
// WebSocket event for each affected recap so other clients can refresh.
func (a *App) MarkRecapsAsViewed(rctx request.CTX) ([]string, *model.AppError) {
userID := rctx.Session().UserId
statuses := []string{model.RecapStatusCompleted, model.RecapStatusFailed}
ids, err := a.Srv().Store().Recap().MarkRecapsAsViewed(userID, statuses)
if err != nil {
return nil, model.NewAppError("MarkRecapsAsViewed", "app.recap.mark_viewed.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, id := range ids {
message := model.NewWebSocketEvent(model.WebsocketEventRecapUpdated, "", "", userID, nil, "")
message.Add("recap_id", id)
a.Publish(message)
}
return ids, nil
}
// RegenerateRecap regenerates an existing recap
func (a *App) RegenerateRecap(rctx request.CTX, userID string, recap *model.Recap) (*model.Recap, *model.AppError) {
recapID := recap.Id
// Get existing recap channels to extract channel IDs
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
if err != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Extract channel IDs
channelIDs := make([]string, len(channels))
for i, channel := range channels {
channelIDs[i] = channel.ChannelId
}
// Delete existing recap channels
if deleteErr := a.Srv().Store().Recap().DeleteRecapChannels(recapID); deleteErr != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.delete_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
}
// Update recap status to pending and reset read/viewed status so the recap
// reappears in the badge once it completes again.
recap.Status = model.RecapStatusPending
recap.ReadAt = 0
recap.ViewedAt = 0
recap.UpdateAt = model.GetMillis()
recap.TotalMessageCount = 0
if _, updateErr := a.Srv().Store().Recap().UpdateRecap(recap); updateErr != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.update.app_error", nil, "", http.StatusInternalServerError).Wrap(updateErr)
}
// Create new job with same parameters
jobData := map[string]string{
"recap_id": recapID,
"user_id": userID,
"channel_ids": strings.Join(channelIDs, ","),
"agent_id": recap.BotID,
}
_, jobErr := a.CreateJob(rctx, &model.Job{
Type: model.JobTypeRecap,
Data: jobData,
})
if jobErr != nil {
return nil, jobErr
}
// Return updated recap
updatedRecap, getErr := a.GetRecap(rctx, recapID)
if getErr != nil {
return nil, getErr
}
return updatedRecap, nil
}
// DeleteRecap deletes a recap (soft delete)
func (a *App) DeleteRecap(rctx request.CTX, recapID string) *model.AppError {
// Delete recap
if deleteErr := a.Srv().Store().Recap().DeleteRecap(recapID); deleteErr != nil {
return model.NewAppError("DeleteRecap", "app.recap.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
}
return nil
}
// ProcessRecapChannel processes a single channel for a recap, fetching posts, summarizing them,
// and saving the recap channel record. Returns the number of messages processed.
func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) {
result := &model.RecapChannelResult{
ChannelID: channelID,
Success: false,
}
// Get channel info
channel, err := a.GetChannel(rctx, channelID)
if err != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Get user's last viewed timestamp
lastViewedAt, lastViewedErr := a.Srv().Store().Channel().GetMemberLastViewedAt(rctx, channelID, userID)
if lastViewedErr != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_last_viewed.app_error", nil, "", http.StatusInternalServerError).Wrap(lastViewedErr)
}
// Fetch posts for recap
posts, postsErr := a.fetchPostsForRecap(rctx, channelID, lastViewedAt, 100)
if postsErr != nil {
return result, postsErr
}
sourcePostIDs := extractPostIDs(posts)
// No posts to summarize - return success with 0 messages
if len(posts) == 0 {
if appErr := a.saveRecapChannelRecord(recapID, channel.Id, channel.DisplayName, nil, nil, sourcePostIDs); appErr != nil {
return result, appErr
}
result.Success = true
return result, nil
}
// Get team info for permalink generation
team, teamErr := a.GetTeam(channel.TeamId)
if teamErr != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_team.app_error", nil, "", http.StatusInternalServerError).Wrap(teamErr)
}
// Summarize posts
summary, err := a.SummarizePosts(rctx, userID, posts, channel.DisplayName, team.Name, agentID)
if err != nil {
if saveErr := a.saveRecapChannelRecord(recapID, channel.Id, channel.DisplayName, nil, nil, sourcePostIDs); saveErr != nil {
return result, saveErr
}
return result, err
}
if appErr := a.saveRecapChannelRecord(recapID, channelID, channel.DisplayName, summary.Highlights, summary.ActionItems, sourcePostIDs); appErr != nil {
return result, appErr
}
result.MessageCount = len(posts)
result.Success = true
return result, nil
}
func (a *App) saveRecapChannelRecord(recapID, channelID, channelName string, highlights, actionItems, sourcePostIDs []string) *model.AppError {
recapChannel := &model.RecapChannel{
Id: model.NewId(),
RecapId: recapID,
ChannelId: channelID,
ChannelName: channelName,
Highlights: highlights,
ActionItems: actionItems,
SourcePostIds: sourcePostIDs,
CreateAt: model.GetMillis(),
}
if err := a.Srv().Store().Recap().SaveRecapChannel(recapChannel); err != nil {
return model.NewAppError("ProcessRecapChannel", "app.recap.save_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// fetchPostsForRecap fetches posts for a channel after the given timestamp and enriches them with user information
func (a *App) fetchPostsForRecap(rctx request.CTX, channelID string, lastViewedAt int64, limit int) ([]*model.Post, *model.AppError) {
// Get posts after lastViewedAt
options := model.GetPostsSinceOptions{
ChannelId: channelID,
Time: lastViewedAt,
}
postList, err := a.GetPostsSince(rctx, options)
if err != nil {
return nil, err
}
if len(postList.Posts) == 0 {
// If there are no unread posts, get the most recent 15 posts to include in the recap
postList, err = a.GetPosts(rctx, channelID, 0, 20)
if err != nil {
return nil, err
}
}
// Convert to slice and limit
posts := make([]*model.Post, 0, len(postList.Posts))
for _, postID := range postList.Order {
if post, ok := postList.Posts[postID]; ok {
posts = append(posts, post)
if len(posts) >= limit {
break
}
}
}
// Enrich with usernames
for _, post := range posts {
user, _ := a.GetUser(post.UserId)
if user != nil {
if post.Props == nil {
post.Props = make(model.StringInterface)
}
post.AddProp("username", user.Username)
}
}
return posts, nil
}
// extractPostIDs extracts post IDs from a slice of posts
func extractPostIDs(posts []*model.Post) []string {
ids := make([]string, len(posts))
for i, post := range posts {
ids[i] = post.Id
}
return ids
}