Files
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

608 lines
20 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"net/http"
"strings"
"time"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/mlog"
"github.com/hanzoteam/server/server/public/shared/request"
"github.com/hanzoteam/server/server/v8/channels/store"
)
// 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) {
if appErr := a.requireAIRecapsEnabled("CreateRecap"); appErr != nil {
return nil, appErr
}
userID := rctx.Session().UserId
limits, err := a.GetEffectiveLimits()
if err != nil {
return nil, err
}
// The channel count check runs first so it bounds the permission check below.
if model.IsLimitEnabled(limits.MaxChannelsPerRecap) && len(channelIDs) > limits.MaxChannelsPerRecap {
return nil, recapMaxChannelsExceededError("CreateRecap", limits.MaxChannelsPerRecap, len(channelIDs))
}
// Validate user can read all channels
if appErr := a.validateRecapChannelPermissions(rctx, channelIDs, "CreateRecap"); appErr != nil {
return nil, appErr
}
if appErr := a.checkManualRecapCooldown(userID, limits, "CreateRecap"); appErr != nil {
return nil, appErr
}
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,
}
var (
savedRecap *model.Recap
storeErr error
)
if model.IsLimitEnabled(limits.MaxRecapsPerDay) {
startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID)
if dayErr != nil {
return nil, dayErr
}
savedRecap, storeErr = a.Srv().Store().Recap().SaveRecapIfUnderDailyLimit(recap, startOfDayMillis, limits.MaxRecapsPerDay)
if storeErr != nil {
var limitErr *store.ErrLimitExceeded
if errors.As(storeErr, &limitErr) {
return nil, recapMaxRecapsReachedError("CreateRecap", limits.MaxRecapsPerDay)
}
return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr)
}
} else {
savedRecap, storeErr = a.Srv().Store().Recap().SaveRecap(recap)
if storeErr != nil {
return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr)
}
}
// 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 {
// The recap row is already committed but its job never enqueued, so flag it
// skipped to free the daily-limit slot for a recap that will never run.
if skipErr := a.Srv().Store().Recap().MarkRecapSkipped(savedRecap.Id, model.SkipReasonJobCreationFailed); skipErr != nil {
rctx.Logger().Warn("Failed to mark orphaned recap as skipped after job creation failure",
mlog.String("recap_id", savedRecap.Id),
mlog.Err(skipErr),
)
}
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) {
if appErr := a.requireAIRecapsEnabled("RegenerateRecap"); appErr != nil {
return nil, appErr
}
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
}
limits, limitsErr := a.GetEffectiveLimits()
if limitsErr != nil {
return nil, limitsErr
}
if model.IsLimitEnabled(limits.MaxChannelsPerRecap) && len(channelIDs) > limits.MaxChannelsPerRecap {
return nil, recapMaxChannelsExceededError("RegenerateRecap", limits.MaxChannelsPerRecap, len(channelIDs))
}
if appErr := a.checkManualRecapCooldown(userID, limits, "RegenerateRecap"); appErr != nil {
return nil, appErr
}
if model.IsLimitEnabled(limits.MaxRecapsPerDay) {
startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID)
if dayErr != nil {
return nil, dayErr
}
count, countErr := a.Srv().Store().Recap().CountForUserSince(userID, startOfDayMillis)
if countErr != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.get_daily_count.app_error", nil, "", http.StatusInternalServerError).Wrap(countErr)
}
if count >= int64(limits.MaxRecapsPerDay) {
return nil, recapMaxRecapsReachedError("RegenerateRecap", limits.MaxRecapsPerDay)
}
}
// 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 using default manual recap options.
func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) {
return a.ProcessRecapChannelWithOptions(rctx, recapID, channelID, userID, agentID, model.RecapProcessingOptions{})
}
// ProcessRecapChannelWithOptions 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) ProcessRecapChannelWithOptions(rctx request.CTX, recapID, channelID, userID, agentID string, options model.RecapProcessingOptions) (*model.RecapChannelResult, *model.AppError) {
result := &model.RecapChannelResult{
ChannelID: channelID,
Success: false,
}
// Re-verify read access at execution time. Scheduled recaps run long after
// creation, so a user's channel access may have been revoked since then.
if ok, _ := a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionReadChannel); !ok {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.permission_denied", nil, "", http.StatusForbidden)
}
// 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)
}
fetchSince, allowRecentFallback := recapFetchStartAt(options.TimePeriod, lastViewedAt, time.Now())
remainingPosts, limitErr := a.getRemainingPostsForRecap(userID, recapID)
if limitErr != nil {
return result, limitErr
}
if remainingPosts == 0 {
if appErr := a.saveRecapChannelRecord(recapID, channel.Id, channel.DisplayName, nil, nil, nil); appErr != nil {
return result, appErr
}
result.Success = true
return result, nil
}
// Fetch posts for recap
posts, postsErr := a.fetchPostsForRecapWithFallback(rctx, channelID, fetchSince, remainingPosts, allowRecentFallback)
if postsErr != nil {
return result, postsErr
}
// Enforce MaxTokensPerRecap as a per-channel cap. RecapChannel records persist only post
// IDs (not message content), so a true cross-channel token budget would require re-fetching
// every already-processed channel's posts; capping per channel bounds each LLM payload cheaply.
remainingTokens, tokensErr := a.getRemainingTokensForRecap(userID)
if tokensErr != nil {
return result, tokensErr
}
if model.IsLimitEnabled(remainingTokens) {
if trimmed, wasTrimmed := trimPostsToTokenLimit(posts, remainingTokens); wasTrimmed {
posts = trimmed
rctx.Logger().Debug("Recap posts trimmed to token limit",
mlog.Int("max_tokens", remainingTokens),
mlog.String("channel_id", channelID))
}
}
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.SummarizePostsWithInstructions(rctx, userID, posts, channel.DisplayName, team.Name, agentID, options.CustomInstructions)
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
}
func (a *App) fetchPostsForRecapWithFallback(rctx request.CTX, channelID string, since int64, limit int, allowRecentFallback bool) ([]*model.Post, *model.AppError) {
// Get posts after lastViewedAt
options := model.GetPostsSinceOptions{
ChannelId: channelID,
Time: since,
}
postList, err := a.GetPostsSince(rctx, options)
if err != nil {
return nil, err
}
if allowRecentFallback && 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
}
func recapFetchStartAt(timePeriod string, lastViewedAt int64, now time.Time) (int64, bool) {
switch timePeriod {
case model.TimePeriodLast24h:
return now.Add(-24 * time.Hour).UnixMilli(), false
case model.TimePeriodLastWeek:
return now.Add(-7 * 24 * time.Hour).UnixMilli(), false
case "", model.TimePeriodSinceLastRead:
return lastViewedAt, true
default:
return lastViewedAt, true
}
}
// 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
}
func recapMaxChannelsExceededError(where string, limit int, requested int) *model.AppError {
return model.NewAppError(where,
"app.recap.max_channels_exceeded.app_error",
map[string]any{
"Limit": limit,
"Requested": requested,
},
"", http.StatusBadRequest)
}
func recapMaxRecapsReachedError(where string, limit int) *model.AppError {
return model.NewAppError(where,
"app.recap.max_recaps_reached.app_error",
map[string]any{"Limit": limit},
"", http.StatusBadRequest)
}
func (a *App) checkManualRecapCooldown(userID string, limits *model.EffectiveRecapLimits, where string) *model.AppError {
if !model.IsLimitEnabled(limits.CooldownMinutes) || limits.CooldownMinutes <= 0 {
return nil
}
lastManualRecap, storeErr := a.Srv().Store().Recap().GetLastCompletedManualRecap(userID)
if storeErr != nil {
return model.NewAppError(where,
"app.recap.cooldown_check_failed.app_error",
nil, "", http.StatusInternalServerError).Wrap(storeErr)
}
if lastManualRecap == nil {
return nil
}
cooldownEndTime := lastManualRecap.CreateAt + int64(limits.CooldownMinutes)*60*1000
now := model.GetMillis()
if now >= cooldownEndTime {
return nil
}
remainingMs := cooldownEndTime - now
remainingMinutes := int((remainingMs + 60000 - 1) / 60000)
return model.NewAppError(where,
"app.recap.cooldown_active.app_error",
map[string]any{
"CooldownMinutes": limits.CooldownMinutes,
"RetryAfterMinutes": remainingMinutes,
},
"", http.StatusTooManyRequests)
}
func (a *App) getRemainingPostsForRecap(userID string, recapID string) (int, *model.AppError) {
const defaultFetchLimit = 100
limits, limitsErr := a.GetEffectiveLimits()
if limitsErr != nil {
return 0, limitsErr
}
remaining := defaultFetchLimit
currentRecapPosts, appErr := a.countCurrentRecapSourcePosts(recapID)
if appErr != nil {
return 0, appErr
}
if model.IsLimitEnabled(limits.MaxPostsPerRecap) {
remaining = min(remaining, limits.MaxPostsPerRecap-currentRecapPosts)
}
if model.IsLimitEnabled(limits.MaxPostsPerDay) {
startOfDayMillis, dayErr := a.getStartOfUserDayMillis(userID)
if dayErr != nil {
return 0, dayErr
}
usedToday, storeErr := a.Srv().Store().Recap().SumTotalMessageCountForUserSince(userID, startOfDayMillis)
if storeErr != nil {
return 0, model.NewAppError("ProcessRecapChannel", "app.recap.sum_daily_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr)
}
remaining = min(remaining, limits.MaxPostsPerDay-int(usedToday)-currentRecapPosts)
}
if remaining < 0 {
return 0, nil
}
return remaining, nil
}
// getRemainingTokensForRecap returns the per-channel token cap for a recap, or
// model.UnlimitedValue (-1) when token enforcement is disabled. It mirrors
// getRemainingPostsForRecap but is per-channel (see ProcessRecapChannelWithOptions).
func (a *App) getRemainingTokensForRecap(userID string) (int, *model.AppError) {
limits, limitsErr := a.GetEffectiveLimits()
if limitsErr != nil {
return 0, limitsErr
}
return limits.MaxTokensPerRecap, nil
}
func (a *App) countCurrentRecapSourcePosts(recapID string) (int, *model.AppError) {
recapChannels, storeErr := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
if storeErr != nil {
return 0, model.NewAppError("ProcessRecapChannel", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr)
}
count := 0
for _, recapChannel := range recapChannels {
count += len(recapChannel.SourcePostIds)
}
return count, nil
}
// estimateTokens estimates token count for text using conservative 4 chars/token heuristic.
// This is approximate - actual LLM tokenization varies by model.
func estimateTokens(text string) int {
// Conservative estimate: 4 characters per token for English
// This tends to overestimate, which is safer for context limits
return (len(text) + 3) / 4 // Ceiling division
}
// estimatePostTokens estimates tokens for a single post
func estimatePostTokens(post *model.Post) int {
return estimateTokens(post.Message)
}
// trimPostsToTokenLimit keeps the newest posts (front of the newest-first slice) whose
// cumulative estimated token count stays at or below maxTokens, dropping the rest.
// Returns the trimmed posts and whether any were dropped.
func trimPostsToTokenLimit(posts []*model.Post, maxTokens int) ([]*model.Post, bool) {
total := 0
for i, post := range posts {
total += estimatePostTokens(post)
if total > maxTokens {
return posts[:i], true
}
}
return posts, false
}