mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* MM-68339: Add XML struct tags and multi-remote registration for shared channels plugin API (#36126) * Add XML struct tags and multi-remote registration for shared channels plugin API Phase 1: Add xml struct tags to model types used in SyncMsg (Post, User, Reaction, Status, PostAcknowledgement, FileInfo, SyncResponse, MembershipChangeMsg). Add custom MarshalXML/UnmarshalXML for SyncMsg (Users map, MentionTransforms map), StringMap, and StringInterface. Exclude Post.Metadata, PrevStatus, and server-internal FileInfo fields from XML. JSON serialization is unaffected. Phase 2: Lift the one-remote-per-plugin constraint so plugins can register multiple remotes with different SiteURLs. Add SiteURL field to RegisterPluginOpts (defaults to "plugin_<PluginID>" for backward compatibility). Add GetAllByPluginID and GetBySiteURL store methods. Rewrite registration to dedup by SiteURL instead of PluginID. Add UnregisterPluginRemoteForSharedChannels for single-remote removal with plugin ownership validation. Validate SiteURL is non-empty in RemoteCluster.IsValid. Simplify IsPlugin() to check PluginID only. (cherry picked from commita760aefcd0) * MM-68339: slugify RemoteCluster.Name in plugin registration (#36309) The new TestRegisterPluginForSharedChannels tests added in #36126 broke master CI because RegisterPluginForSharedChannels assigned opts.Displayname directly to RemoteCluster.Name, which IsValid validates against the slug regex ^[a-zA-Z0-9.\-_]+$. Display names with spaces (e.g. "legacy plugin") fail validation. The tests didn't run in the PR's final CI shard and the issue surfaced post-merge. Add CleanRemoteName to the public model, mirroring CleanTeamName and CleanUsername: lowercase, replace spaces and other disallowed characters with hyphens, trim, truncate to RemoteNameMaxLength, fall back to NewId when the result is empty. Use it in RegisterPluginForSharedChannels so Name is always slug-valid while DisplayName keeps the human-readable label. This also lets real plugins register with display names containing spaces. (cherry picked from commit078f6b31b5) * MM-68622: start inter-cluster services before plugin activation (#36366) * MM-68622: start inter-cluster services before plugin activation Move startInterClusterServices from the end of Server.Start() to the beginning, before Channels().Start() initializes plugins. This lets plugins call shared channels APIs (ShareChannel, InviteRemoteToChannel, UninviteRemoteFromChannel, UnshareChannel, UpdateSharedChannel, CheckCanInviteToSharedChannel) during OnActivate instead of failing with "Shared Channels Service is disabled". Side-effect analysis: * Plugin API gating: getSharedChannelsService in channels/app/shared_channel.go:32 only requires the service to be non-nil. The plugin-facing wrappers all pass ensureIsActive=false, so Active() is bypassed. Once SetSharedChannelService runs, calls succeed on both leader and follower nodes. This is the fix path. * Multi-node leader timing: the enterprise cluster impl in enterprise/cluster/cluster.go:70 initializes currentLeader="", so IsLeader() returns false before StartInterNodeCommunication runs. The immediate onClusterLeaderChange in scs.Start at platform/services/sharedchannel/service.go:151 therefore takes the pause path, which is a no-op since the service was never active. When memberlist.Create fires NotifyJoin for the local node, addPotentialLeader runs and InvokeClusterLeaderChangedListeners drives the registered listener to resume() the sync loop on the elected leader. End state matches the prior ordering. * Single-node: IsLeader() returns true unconditionally per channels/app/platform/cluster.go:33, so SharedChannelSyncHandler is active during plugin OnActivate. Events emitted by plugins during activation (posts to shared channels, DM creation) now flow through sync where they were previously dropped. This is intended correctness, not a regression. * Transport and handlers: api4 remote-cluster routes are registered before Server.Start, so HTTP handlers exist when rcs.Start runs early. rcs and scs do not send cluster-broadcast messages during Start; they only register topic listeners on the rcs transport, which is independent of cluster gossip. registerClusterHandlers ordering is unaffected. * Config: scs reads only ConnectedWorkspacesSettings and the License at construction, both stable from the initial config load. ReloadConfig at server.go:912 has no bearing on inter-cluster service init. Errors from startInterClusterServices remain logged and non-fatal, matching prior behavior. (cherry picked from commit4e967cb7ad) * MM-68536: Show actual remote names in system console channel list (#36298) * MM-68536: Show actual remote names in system console channel list The system console "Channels" list rendered SharedChannelIndicator without a remoteNames prop, so every shared channel showed the generic "Shared with trusted organizations" fallback even after PR 35908. Add a small connected wrapper that selects remote names via getRemoteNamesForChannel and dispatches fetchChannelRemotes on mount, mirroring the pattern used by the LHS sidebar. The shared_channel_remote_updated websocket event added in PR 35908 already refreshes the same Redux slice, so the system console list now stays in sync automatically. (cherry picked from commit15d7fbd8cb) * [MM-68697] Preserve sender file ID in plugin-relayed shared channel attachments (#36468) Set ReqFileId on the UploadSession created in ReceiveSharedChannelAttachmentSyncMsg so the attachment is persisted under the sender's file ID. Without this, the receiving server stored the bytes under a freshly generated ID while the synced post's FileIds still referenced the sender's ID, leaving the attachment invisible in the UI even though the file and FileInfo row existed on disk. Mirrors the existing cluster-to-cluster path in platform/services/sharedchannel/attachment.go. Also tightens TestPluginAPIReceiveSharedChannelAttachmentSyncMsg to pass a sender-side fi.Id and assert the saved FileInfo keeps it. The prior assertion only checked that some ID was assigned, which the buggy code also satisfied. (cherry picked from commita72b1db4c5) * MM-68705 - Order-tolerant Shared Channel plugin API's for receiving attachments (#36486) * MM-68705 - Make ReceiveSharedChannelAttachmentSyncMsg order tolerant Allow plugin remotes to invoke ReceiveSharedChannelAttachmentSyncMsg and ReceiveSharedChannelSyncMsg in either order without losing the post/file binding, and make repeat deliveries of the same file id idempotent. Two localised changes inside ReceiveSharedChannelAttachmentSyncMsg: - Idempotency check before CreateUploadSession. If a FileInfo with the sender's id already exists for the same channel and creator, return it instead of inserting a duplicate (which would violate the FileInfo PK and force the caller to retry indefinitely after any transient ack failure). A mismatched channel or creator on the same id is rejected. - Lazy bind to post after UploadData. When the matching post is already present (post-then-file ordering), CreatePost will have stripped the unmatched file id from Post.FileIds; AttachToPost plus Post.Overwrite restore the binding. When the post is not yet present (file-then-post ordering), the FileInfo is left unbound so the eventual post arrival's CreatePost path binds it. The post is fetched first because AttachToPost is a blind UPDATE that does not validate post existence, so calling it before the post exists would orphan the FileInfo. No other paths change. Cluster (non-plugin) shared-channel attachments, ReceiveSharedChannelProfileImageSyncMsg, and UI uploads are unaffected. New tests in shared_channel_test.go cover both orderings, repeated receive success and rejection on channel/creator mismatch, and the empty-PostId no-op. (cherry picked from commit5c58953e8e) * MM-68708 - Fix TestCreatePost shared DM/GM subtests when env pins feature flag (#36488) The two shared DM/GM subtests in TestCreatePost rely on FeatureFlags.EnableSharedChannelsDMs being false, but the config store reapplies MM_FEATUREFLAGS_* env overrides on every Set, so an UpdateConfig pin gets clobbered. Use t.Setenv to force the flag false for the duration of each subtest, and drop the parent's mainHelper.Parallel(t) so Go's "no Setenv under a parallel ancestor" rule is satisfied. Sibling subtests still parallelize via their own mainHelper.Parallel(t) calls. (cherry picked from commita98e01ebac) * MM-68838: Ping a restored plugin remote immediately on re-register (#36592) * MM-68838: ping restored plugin remote immediately on re-register RegisterPluginForSharedChannels' restore branch updated the row but did not call PingNow, leaving the restored remote offline until the next pingLoop tick (up to PingFreq, default 1 minute). The new-connection branch already calls PingNow; the restore branch now mirrors it so sync attempts immediately after a plugin restart no longer fail with "offline remote cluster". * MM-68838: gob-encode error returns in apiRPCServer.ReceiveSharedChannelAttachmentSyncMsg The apiRPCServer wrapper for ReceiveSharedChannelAttachmentSyncMsg assigned the hook's error return directly to the gob-encoded response struct. When the framework's App.ReceiveSharedChannelAttachmentSyncMsg returned an error wrapped with %w (*fmt.wrapError, an unexported type), gob refused to encode it and the RPC server broke the connection with "type not registered for interface: fmt.wrapError". Every subsequent plugin/server RPC call then returned the zero-value response struct, causing plugins that dereferenced the nil returns to crash. Apply the existing encodableError() helper so the returned error becomes a gob-safe ErrorString, matching every other apiRPCServer method in this file. (cherry picked from commitea7f488c64)
1388 lines
45 KiB
Go
1388 lines
45 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package model
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode/utf8"
|
|
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/mattermost/mattermost/server/public/shared/markdown"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
)
|
|
|
|
type PostContextKey string
|
|
|
|
const (
|
|
PostSystemMessagePrefix = "system_"
|
|
PostTypeDefault = ""
|
|
PostTypeMessageAttachment = "slack_attachment"
|
|
PostTypeSystemGeneric = "system_generic"
|
|
PostTypeJoinLeave = "system_join_leave" // Deprecated, use PostJoinChannel or PostLeaveChannel instead
|
|
PostTypeJoinChannel = "system_join_channel"
|
|
PostTypeGuestJoinChannel = "system_guest_join_channel"
|
|
PostTypeLeaveChannel = "system_leave_channel"
|
|
PostTypeJoinTeam = "system_join_team"
|
|
PostTypeLeaveTeam = "system_leave_team"
|
|
PostTypeAutoResponder = "system_auto_responder"
|
|
PostTypeAutotranslationChange = "system_autotranslation"
|
|
PostTypeAddRemove = "system_add_remove" // Deprecated, use PostAddToChannel or PostRemoveFromChannel instead
|
|
PostTypeAddToChannel = "system_add_to_channel"
|
|
PostTypeAddGuestToChannel = "system_add_guest_to_chan"
|
|
PostTypeRemoveFromChannel = "system_remove_from_channel"
|
|
PostTypeMoveChannel = "system_move_channel"
|
|
PostTypeAddToTeam = "system_add_to_team"
|
|
PostTypeRemoveFromTeam = "system_remove_from_team"
|
|
PostTypeHeaderChange = "system_header_change"
|
|
PostTypeDisplaynameChange = "system_displayname_change"
|
|
PostTypeConvertChannel = "system_convert_channel"
|
|
PostTypePurposeChange = "system_purpose_change"
|
|
PostTypeChannelDeleted = "system_channel_deleted"
|
|
PostTypeChannelRestored = "system_channel_restored"
|
|
PostTypeEphemeral = "system_ephemeral"
|
|
PostTypeChangeChannelPrivacy = "system_change_chan_privacy"
|
|
PostTypeWrangler = "system_wrangler"
|
|
PostTypeGMConvertedToChannel = "system_gm_to_channel"
|
|
PostTypeAddBotTeamsChannels = "add_bot_teams_channels"
|
|
PostTypeMe = "me"
|
|
PostCustomTypePrefix = "custom_"
|
|
PostTypeReminder = "reminder"
|
|
PostTypeBurnOnRead = "burn_on_read"
|
|
PostTypeCard = "card"
|
|
|
|
PostFileidsMaxRunes = 300
|
|
PostFilenamesMaxRunes = 4000
|
|
PostHashtagsMaxRunes = 1000
|
|
PostMessageMaxRunesV1 = 4000
|
|
PostMessageMaxBytesV2 = 65535
|
|
PostMessageMaxRunesV2 = PostMessageMaxBytesV2 / 4 // Assume a worst-case representation
|
|
|
|
// Reporting API constants
|
|
MaxReportingPerPage = 1000 // Maximum number of posts that can be requested per page in reporting endpoints
|
|
ReportingTimeFieldCreateAt = "create_at"
|
|
ReportingTimeFieldUpdateAt = "update_at"
|
|
ReportingSortDirectionAsc = "asc"
|
|
ReportingSortDirectionDesc = "desc"
|
|
PostPropsMaxRunes = 800000
|
|
PostPropsMaxUserRunes = PostPropsMaxRunes - 40000 // Leave some room for system / pre-save modifications
|
|
|
|
PropsAddChannelMember = "add_channel_member"
|
|
|
|
PostPropsAddedUserId = "addedUserId"
|
|
PostPropsDeleteBy = "deleteBy"
|
|
PostPropsOverrideIconURL = "override_icon_url"
|
|
PostPropsOverrideIconEmoji = "override_icon_emoji"
|
|
PostPropsOverrideUsername = "override_username"
|
|
PostPropsFromWebhook = "from_webhook"
|
|
PostPropsFromBot = "from_bot"
|
|
PostPropsFromOAuthApp = "from_oauth_app"
|
|
PostPropsWebhookDisplayName = "webhook_display_name"
|
|
PostPropsAttachments = "attachments"
|
|
PostPropsFromPlugin = "from_plugin"
|
|
PostPropsMentionHighlightDisabled = "mentionHighlightDisabled"
|
|
PostPropsGroupHighlightDisabled = "disable_group_highlight"
|
|
PostPropsPreviewedPost = "previewed_post"
|
|
PostPropsForceNotification = "force_notification"
|
|
PostPropsChannelMentions = "channel_mentions"
|
|
PostPropsCurrentTeamId = "current_team_id"
|
|
PostPropsUnsafeLinks = "unsafe_links"
|
|
PostPropsAIGeneratedByUserID = "ai_generated_by"
|
|
PostPropsAIGeneratedByUsername = "ai_generated_by_username"
|
|
PostPropsExpireAt = "expire_at"
|
|
PostPropsReadDurationSeconds = "read_duration"
|
|
|
|
PostPriorityUrgent = "urgent"
|
|
|
|
DefaultExpirySeconds = 60 * 60 * 24 * 7 // 7 days
|
|
DefaultReadDurationSeconds = 10 * 60 // 10 minutes
|
|
|
|
PostContextKeyIsScheduledPost PostContextKey = "isScheduledPost"
|
|
)
|
|
|
|
type Post struct {
|
|
Id string `json:"id" xml:"Id"`
|
|
CreateAt int64 `json:"create_at" xml:"CreateAt"`
|
|
UpdateAt int64 `json:"update_at" xml:"UpdateAt"`
|
|
EditAt int64 `json:"edit_at" xml:"EditAt"`
|
|
DeleteAt int64 `json:"delete_at" xml:"DeleteAt"`
|
|
IsPinned bool `json:"is_pinned" xml:"IsPinned"`
|
|
UserId string `json:"user_id" xml:"UserId"`
|
|
ChannelId string `json:"channel_id" xml:"ChannelId"`
|
|
RootId string `json:"root_id" xml:"RootId"`
|
|
OriginalId string `json:"original_id" xml:"OriginalId"`
|
|
|
|
Message string `json:"message" xml:"Message"`
|
|
// MessageSource will contain the message as submitted by the user if Message has been modified
|
|
// by Mattermost for presentation (e.g if an image proxy is being used). It should be used to
|
|
// populate edit boxes if present.
|
|
MessageSource string `json:"message_source,omitempty" xml:"MessageSource,omitempty"`
|
|
|
|
Type string `json:"type" xml:"Type"`
|
|
propsMu sync.RWMutex `db:"-"` // Unexported mutex used to guard Post.Props.
|
|
Props StringInterface `json:"props" xml:"Props"` // Deprecated: use GetProps()
|
|
Hashtags string `json:"hashtags" xml:"Hashtags"`
|
|
Filenames StringArray `json:"-" xml:"-"` // Deprecated, do not use this field any more
|
|
FileIds StringArray `json:"file_ids" xml:"FileIds>Id"`
|
|
PendingPostId string `json:"pending_post_id" xml:"PendingPostId"`
|
|
HasReactions bool `json:"has_reactions,omitempty" xml:"HasReactions,omitempty"`
|
|
RemoteId *string `json:"remote_id,omitempty" xml:"RemoteId,omitempty"`
|
|
|
|
// Transient data populated before sending a post to the client
|
|
ReplyCount int64 `json:"reply_count" xml:"ReplyCount"`
|
|
LastReplyAt int64 `json:"last_reply_at" xml:"LastReplyAt"`
|
|
Participants []*User `json:"participants" xml:"Participants>User"`
|
|
IsFollowing *bool `json:"is_following,omitempty" xml:"IsFollowing,omitempty"` // for root posts in collapsed thread mode indicates if the current user is following this thread
|
|
Metadata *PostMetadata `json:"metadata,omitempty" xml:"-"`
|
|
}
|
|
|
|
func (o *Post) Auditable() map[string]any {
|
|
var metaData map[string]any
|
|
if o.Metadata != nil {
|
|
metaData = o.Metadata.Auditable()
|
|
}
|
|
|
|
return map[string]any{
|
|
"id": o.Id,
|
|
"create_at": o.CreateAt,
|
|
"update_at": o.UpdateAt,
|
|
"edit_at": o.EditAt,
|
|
"delete_at": o.DeleteAt,
|
|
"is_pinned": o.IsPinned,
|
|
"user_id": o.UserId,
|
|
"channel_id": o.ChannelId,
|
|
"root_id": o.RootId,
|
|
"original_id": o.OriginalId,
|
|
"type": o.Type,
|
|
"props": o.GetProps(),
|
|
"file_ids": o.FileIds,
|
|
"pending_post_id": o.PendingPostId,
|
|
"remote_id": o.RemoteId,
|
|
"reply_count": o.ReplyCount,
|
|
"last_reply_at": o.LastReplyAt,
|
|
"is_following": o.IsFollowing,
|
|
"metadata": metaData,
|
|
}
|
|
}
|
|
|
|
func (o *Post) LogClone() any {
|
|
return o.Auditable()
|
|
}
|
|
|
|
type PostEphemeral struct {
|
|
UserID string `json:"user_id"`
|
|
Post *Post `json:"post"`
|
|
}
|
|
|
|
type PostPatch struct {
|
|
IsPinned *bool `json:"is_pinned"`
|
|
Message *string `json:"message"`
|
|
Props *StringInterface `json:"props"`
|
|
FileIds *StringArray `json:"file_ids"`
|
|
HasReactions *bool `json:"has_reactions"`
|
|
}
|
|
|
|
func (o *PostPatch) IsEmpty() bool {
|
|
return o.IsPinned == nil && o.Message == nil && o.Props == nil && o.FileIds == nil && o.HasReactions == nil
|
|
}
|
|
|
|
type PostReminder struct {
|
|
TargetTime int64 `json:"target_time"`
|
|
// These fields are only used internally for interacting with DB.
|
|
PostId string `json:",omitempty"`
|
|
UserId string `json:",omitempty"`
|
|
}
|
|
|
|
type PostPriority struct {
|
|
Priority *string `json:"priority"`
|
|
RequestedAck *bool `json:"requested_ack"`
|
|
PersistentNotifications *bool `json:"persistent_notifications"`
|
|
// These fields are only used internally for interacting with DB.
|
|
PostId string `json:",omitempty"`
|
|
ChannelId string `json:",omitempty"`
|
|
}
|
|
|
|
type PostPersistentNotifications struct {
|
|
PostId string
|
|
CreateAt int64
|
|
LastSentAt int64
|
|
DeleteAt int64
|
|
SentCount int16
|
|
}
|
|
|
|
type GetPersistentNotificationsPostsParams struct {
|
|
MaxTime int64
|
|
MaxSentCount int16
|
|
PerPage int
|
|
}
|
|
|
|
type MoveThreadParams struct {
|
|
ChannelId string `json:"channel_id"`
|
|
}
|
|
|
|
type SearchParameter struct {
|
|
Terms *string `json:"terms"`
|
|
IsOrSearch *bool `json:"is_or_search"`
|
|
TimeZoneOffset *int `json:"time_zone_offset"`
|
|
Page *int `json:"page"`
|
|
PerPage *int `json:"per_page"`
|
|
IncludeDeletedChannels *bool `json:"include_deleted_channels"`
|
|
}
|
|
|
|
func (sp SearchParameter) Auditable() map[string]any {
|
|
return map[string]any{
|
|
"terms": sp.Terms,
|
|
"is_or_search": sp.IsOrSearch,
|
|
"time_zone_offset": sp.TimeZoneOffset,
|
|
"page": sp.Page,
|
|
"per_page": sp.PerPage,
|
|
"include_deleted_channels": sp.IncludeDeletedChannels,
|
|
}
|
|
}
|
|
|
|
func (sp SearchParameter) LogClone() any {
|
|
return sp.Auditable()
|
|
}
|
|
|
|
type AnalyticsPostCountsOptions struct {
|
|
TeamId string
|
|
BotsOnly bool
|
|
YesterdayOnly bool
|
|
}
|
|
|
|
func (o *PostPatch) WithRewrittenImageURLs(f func(string) string) *PostPatch {
|
|
pCopy := *o //nolint:revive
|
|
if pCopy.Message != nil {
|
|
*pCopy.Message = RewriteImageURLs(*o.Message, f)
|
|
}
|
|
return &pCopy
|
|
}
|
|
|
|
func (o *PostPatch) Auditable() map[string]any {
|
|
return map[string]any{
|
|
"is_pinned": o.IsPinned,
|
|
"props": o.Props,
|
|
"file_ids": o.FileIds,
|
|
"has_reactions": o.HasReactions,
|
|
}
|
|
}
|
|
|
|
type PostForExport struct {
|
|
Post
|
|
TeamName string
|
|
ChannelName string
|
|
Username string
|
|
ReplyCount int
|
|
FlaggedBy StringArray
|
|
}
|
|
|
|
type DirectPostForExport struct {
|
|
Post
|
|
User string
|
|
ChannelMembers *[]string
|
|
FlaggedBy StringArray
|
|
}
|
|
|
|
type ReplyForExport struct {
|
|
Post
|
|
Username string
|
|
FlaggedBy StringArray
|
|
}
|
|
|
|
type PostForIndexing struct {
|
|
Post
|
|
TeamId string `json:"team_id"`
|
|
ParentCreateAt *int64 `json:"parent_create_at"`
|
|
ChannelType string `json:"channel_type"`
|
|
}
|
|
|
|
type FileForIndexing struct {
|
|
FileInfo
|
|
ChannelId string `json:"channel_id"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// ShouldIndex tells if a file should be indexed or not.
|
|
// index files which are-
|
|
// a. not deleted
|
|
// b. have an associated post ID, if no post ID, then,
|
|
// b.i. the file should belong to the channel's bookmarks, as indicated by the "CreatorId" field.
|
|
//
|
|
// Files not passing this criteria will be deleted from ES index.
|
|
// We're deleting those files from ES index instead of simply skipping them while fetching a batch of files
|
|
// because existing ES indexes might have these files already indexed, so we need to remove them from index.
|
|
func (file *FileForIndexing) ShouldIndex() bool {
|
|
// NOTE - this function is used in server as well as Enterprise code.
|
|
// Make sure to update public package dependency in both server and Enterprise code when
|
|
// updating the logic here and to test both places.
|
|
return file != nil && file.DeleteAt == 0 && (file.PostId != "" || file.CreatorId == BookmarkFileOwner)
|
|
}
|
|
|
|
// ShallowCopy is an utility function to shallow copy a Post to the given
|
|
// destination without touching the internal RWMutex.
|
|
func (o *Post) ShallowCopy(dst *Post) error {
|
|
if dst == nil {
|
|
return errors.New("dst cannot be nil")
|
|
}
|
|
o.propsMu.RLock()
|
|
defer o.propsMu.RUnlock()
|
|
dst.propsMu.Lock()
|
|
defer dst.propsMu.Unlock()
|
|
dst.Id = o.Id
|
|
dst.CreateAt = o.CreateAt
|
|
dst.UpdateAt = o.UpdateAt
|
|
dst.EditAt = o.EditAt
|
|
dst.DeleteAt = o.DeleteAt
|
|
dst.IsPinned = o.IsPinned
|
|
dst.UserId = o.UserId
|
|
dst.ChannelId = o.ChannelId
|
|
dst.RootId = o.RootId
|
|
dst.OriginalId = o.OriginalId
|
|
dst.Message = o.Message
|
|
dst.MessageSource = o.MessageSource
|
|
dst.Type = o.Type
|
|
dst.Props = o.Props
|
|
dst.Hashtags = o.Hashtags
|
|
dst.Filenames = o.Filenames
|
|
dst.FileIds = o.FileIds
|
|
dst.PendingPostId = o.PendingPostId
|
|
dst.HasReactions = o.HasReactions
|
|
dst.ReplyCount = o.ReplyCount
|
|
dst.Participants = o.Participants
|
|
dst.LastReplyAt = o.LastReplyAt
|
|
dst.Metadata = o.Metadata
|
|
if o.IsFollowing != nil {
|
|
dst.IsFollowing = NewPointer(*o.IsFollowing)
|
|
}
|
|
dst.RemoteId = o.RemoteId
|
|
return nil
|
|
}
|
|
|
|
// Clone shallowly copies the post and returns the copy.
|
|
func (o *Post) Clone() *Post {
|
|
pCopy := &Post{} //nolint:revive
|
|
o.ShallowCopy(pCopy)
|
|
return pCopy
|
|
}
|
|
|
|
func (o *Post) ToJSON() (string, error) {
|
|
pCopy := o.Clone() //nolint:revive
|
|
pCopy.StripActionIntegrations()
|
|
b, err := json.Marshal(pCopy)
|
|
return string(b), err
|
|
}
|
|
|
|
func (o *Post) EncodeJSON(w io.Writer) error {
|
|
o.StripActionIntegrations()
|
|
return json.NewEncoder(w).Encode(o)
|
|
}
|
|
|
|
type CreatePostFlags struct {
|
|
TriggerWebhooks bool
|
|
SetOnline bool
|
|
ForceNotification bool
|
|
}
|
|
|
|
type GetPostsSinceOptions struct {
|
|
UserId string
|
|
ChannelId string
|
|
Time int64
|
|
SkipFetchThreads bool
|
|
CollapsedThreads bool
|
|
CollapsedThreadsExtended bool
|
|
SortAscending bool
|
|
}
|
|
|
|
type GetPostsSinceForSyncCursor struct {
|
|
LastPostUpdateAt int64
|
|
LastPostUpdateID string
|
|
LastPostCreateAt int64
|
|
LastPostCreateID string
|
|
}
|
|
|
|
func (c GetPostsSinceForSyncCursor) IsEmpty() bool {
|
|
return c.LastPostCreateAt == 0 && c.LastPostCreateID == "" && c.LastPostUpdateAt == 0 && c.LastPostUpdateID == ""
|
|
}
|
|
|
|
type GetPostsSinceForSyncOptions struct {
|
|
ChannelId string
|
|
ExcludeRemoteId string
|
|
IncludeDeleted bool
|
|
SinceCreateAt bool // determines whether the cursor will be based on CreateAt or UpdateAt
|
|
ExcludeChannelMetadataSystemPosts bool // if true, exclude channel metadata system posts (header, display name, purpose changes)
|
|
ExcludedPostTypes []string // post types to exclude from sync
|
|
}
|
|
|
|
type GetPostsOptions struct {
|
|
UserId string
|
|
ChannelId string
|
|
PostId string
|
|
Page int
|
|
PerPage int
|
|
SkipFetchThreads bool
|
|
CollapsedThreads bool
|
|
CollapsedThreadsExtended bool
|
|
FromPost string // PostId after which to send the items
|
|
FromCreateAt int64 // CreateAt after which to send the items
|
|
FromUpdateAt int64 // UpdateAt after which to send the items. This cannot be used with FromCreateAt.
|
|
Direction string // Only accepts up|down. Indicates the order in which to send the items.
|
|
UpdatesOnly bool // This flag is used to make the API work with the updateAt value.
|
|
IncludeDeleted bool
|
|
IncludePostPriority bool
|
|
}
|
|
|
|
type PostCountOptions struct {
|
|
// Only include posts on a specific team. "" for any team.
|
|
TeamId string
|
|
MustHaveFile bool
|
|
MustHaveHashtag bool
|
|
ExcludeDeleted bool
|
|
ExcludeSystemPosts bool
|
|
UsersPostsOnly bool
|
|
// AllowFromCache looks up cache only when ExcludeDeleted and UsersPostsOnly are true and rest are falsy.
|
|
AllowFromCache bool
|
|
|
|
// retrieves posts in the inclusive range: [SinceUpdateAt + LastPostId, UntilUpdateAt]
|
|
SincePostID string
|
|
SinceUpdateAt int64
|
|
UntilUpdateAt int64
|
|
}
|
|
|
|
func (o *Post) Etag() string {
|
|
return Etag(o.Id, o.UpdateAt)
|
|
}
|
|
|
|
func (o *Post) IsValid(maxPostSize int) *AppError {
|
|
if !IsValidId(o.Id) {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.id.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
if o.CreateAt == 0 {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if o.UpdateAt == 0 {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.update_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if !IsValidId(o.UserId) {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.user_id.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
if !IsValidId(o.ChannelId) {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
if !(IsValidId(o.RootId) || o.RootId == "") {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.root_id.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
if !(len(o.OriginalId) == 26 || o.OriginalId == "") {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.original_id.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
if utf8.RuneCountInString(o.Message) > maxPostSize {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.message_length.app_error",
|
|
map[string]any{"Length": utf8.RuneCountInString(o.Message), "MaxLength": maxPostSize}, "id="+o.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if utf8.RuneCountInString(o.Hashtags) > PostHashtagsMaxRunes {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.hashtags.app_error", nil, "id="+o.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
switch o.Type {
|
|
case
|
|
PostTypeDefault,
|
|
PostTypeSystemGeneric,
|
|
PostTypeJoinLeave,
|
|
PostTypeAutoResponder,
|
|
PostTypeAddRemove,
|
|
PostTypeJoinChannel,
|
|
PostTypeGuestJoinChannel,
|
|
PostTypeLeaveChannel,
|
|
PostTypeJoinTeam,
|
|
PostTypeLeaveTeam,
|
|
PostTypeAddToChannel,
|
|
PostTypeAddGuestToChannel,
|
|
PostTypeRemoveFromChannel,
|
|
PostTypeMoveChannel,
|
|
PostTypeAddToTeam,
|
|
PostTypeRemoveFromTeam,
|
|
PostTypeMessageAttachment,
|
|
PostTypeHeaderChange,
|
|
PostTypePurposeChange,
|
|
PostTypeDisplaynameChange,
|
|
PostTypeConvertChannel,
|
|
PostTypeChannelDeleted,
|
|
PostTypeChannelRestored,
|
|
PostTypeChangeChannelPrivacy,
|
|
PostTypeAddBotTeamsChannels,
|
|
PostTypeReminder,
|
|
PostTypeMe,
|
|
PostTypeWrangler,
|
|
PostTypeGMConvertedToChannel,
|
|
PostTypeAutotranslationChange,
|
|
PostTypeBurnOnRead,
|
|
PostTypeCard:
|
|
default:
|
|
if !strings.HasPrefix(o.Type, PostCustomTypePrefix) {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.type.app_error", nil, "id="+o.Type, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
if utf8.RuneCountInString(ArrayToJSON(o.Filenames)) > PostFilenamesMaxRunes {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.filenames.app_error", nil, "id="+o.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if utf8.RuneCountInString(ArrayToJSON(o.FileIds)) > PostFileidsMaxRunes {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.file_ids.app_error", nil, "id="+o.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if utf8.RuneCountInString(StringInterfaceToJSON(o.GetProps())) > PostPropsMaxRunes {
|
|
return NewAppError("Post.IsValid", "model.post.is_valid.props.app_error", nil, "id="+o.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (o *Post) SanitizeProps() {
|
|
if o == nil {
|
|
return
|
|
}
|
|
membersToSanitize := []string{
|
|
PropsAddChannelMember,
|
|
PostPropsForceNotification,
|
|
}
|
|
|
|
for _, member := range membersToSanitize {
|
|
if _, ok := o.GetProps()[member]; ok {
|
|
o.DelProp(member)
|
|
}
|
|
}
|
|
for _, p := range o.Participants {
|
|
p.Sanitize(map[string]bool{})
|
|
}
|
|
}
|
|
|
|
// Remove any input data from the post object that is not user controlled
|
|
func (o *Post) SanitizeInput() {
|
|
o.DeleteAt = 0
|
|
o.RemoteId = NewPointer("")
|
|
|
|
if o.Metadata != nil {
|
|
o.Metadata.Embeds = nil
|
|
}
|
|
}
|
|
|
|
func (o *Post) ContainsIntegrationsReservedProps() []string {
|
|
return ContainsIntegrationsReservedProps(o.GetProps())
|
|
}
|
|
|
|
func (o *PostPatch) ContainsIntegrationsReservedProps() []string {
|
|
if o == nil || o.Props == nil {
|
|
return nil
|
|
}
|
|
return ContainsIntegrationsReservedProps(*o.Props)
|
|
}
|
|
|
|
func ContainsIntegrationsReservedProps(props StringInterface) []string {
|
|
foundProps := []string{}
|
|
|
|
if props != nil {
|
|
reservedProps := []string{
|
|
PostPropsFromWebhook,
|
|
PostPropsOverrideUsername,
|
|
PostPropsWebhookDisplayName,
|
|
PostPropsOverrideIconURL,
|
|
PostPropsOverrideIconEmoji,
|
|
}
|
|
|
|
for _, key := range reservedProps {
|
|
if _, ok := props[key]; ok {
|
|
foundProps = append(foundProps, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
return foundProps
|
|
}
|
|
|
|
func (o *Post) PreSave() {
|
|
if o.Id == "" {
|
|
o.Id = NewId()
|
|
}
|
|
|
|
o.OriginalId = ""
|
|
|
|
if o.CreateAt == 0 {
|
|
o.CreateAt = GetMillis()
|
|
}
|
|
|
|
o.UpdateAt = o.CreateAt
|
|
o.PreCommit()
|
|
}
|
|
|
|
func (o *Post) PreCommit() {
|
|
if o.GetProps() == nil {
|
|
o.SetProps(make(map[string]any))
|
|
}
|
|
|
|
if o.Filenames == nil {
|
|
o.Filenames = []string{}
|
|
}
|
|
|
|
if o.FileIds == nil {
|
|
o.FileIds = []string{}
|
|
}
|
|
|
|
o.GenerateActionIds()
|
|
|
|
// There's a rare bug where the client sends up duplicate FileIds so protect against that
|
|
o.FileIds = RemoveDuplicateStrings(o.FileIds)
|
|
}
|
|
|
|
func (o *Post) MakeNonNil() {
|
|
if o.GetProps() == nil {
|
|
o.SetProps(make(map[string]any))
|
|
}
|
|
}
|
|
|
|
func (o *Post) DelProp(key string) {
|
|
o.propsMu.Lock()
|
|
defer o.propsMu.Unlock()
|
|
propsCopy := make(map[string]any, len(o.Props)-1)
|
|
maps.Copy(propsCopy, o.Props)
|
|
delete(propsCopy, key)
|
|
o.Props = propsCopy
|
|
}
|
|
|
|
func (o *Post) AddProp(key string, value any) {
|
|
o.propsMu.Lock()
|
|
defer o.propsMu.Unlock()
|
|
propsCopy := make(map[string]any, len(o.Props)+1)
|
|
maps.Copy(propsCopy, o.Props)
|
|
propsCopy[key] = value
|
|
o.Props = propsCopy
|
|
}
|
|
|
|
func (o *Post) GetProps() StringInterface {
|
|
o.propsMu.RLock()
|
|
defer o.propsMu.RUnlock()
|
|
return o.Props
|
|
}
|
|
|
|
func (o *Post) SetProps(props StringInterface) {
|
|
o.propsMu.Lock()
|
|
defer o.propsMu.Unlock()
|
|
o.Props = props
|
|
}
|
|
|
|
func (o *Post) GetProp(key string) any {
|
|
o.propsMu.RLock()
|
|
defer o.propsMu.RUnlock()
|
|
return o.Props[key]
|
|
}
|
|
|
|
// ValidateProps checks all known props for validity.
|
|
// Currently, it logs warnings for invalid props rather than returning an error.
|
|
// In a future version, this will be updated to return errors for invalid props.
|
|
func (o *Post) ValidateProps(logger mlog.LoggerIFace) {
|
|
if err := o.propsIsValid(); err != nil {
|
|
logger.Warn(
|
|
"Invalid post props. In a future version this will result in an error. Please update your integration to be compliant.",
|
|
mlog.String("post_id", o.Id),
|
|
mlog.Err(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (o *Post) propsIsValid() error {
|
|
var multiErr *multierror.Error
|
|
|
|
props := o.GetProps()
|
|
|
|
// Check basic props validity
|
|
if props == nil {
|
|
return nil
|
|
}
|
|
|
|
if props[PostPropsAddedUserId] != nil {
|
|
if addedUserID, ok := props[PostPropsAddedUserId].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("added_user_id prop must be a string"))
|
|
} else if !IsValidId(addedUserID) {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("added_user_id prop must be a valid user ID"))
|
|
}
|
|
}
|
|
if props[PostPropsDeleteBy] != nil {
|
|
if deleteByID, ok := props[PostPropsDeleteBy].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("delete_by prop must be a string"))
|
|
} else if !IsValidId(deleteByID) {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("delete_by prop must be a valid user ID"))
|
|
}
|
|
}
|
|
|
|
// Validate integration props
|
|
if props[PostPropsOverrideIconURL] != nil {
|
|
if iconURL, ok := props[PostPropsOverrideIconURL].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_url prop must be a string"))
|
|
} else if iconURL == "" || !IsValidHTTPURL(iconURL) {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_url prop must be a valid URL"))
|
|
}
|
|
}
|
|
if props[PostPropsOverrideIconEmoji] != nil {
|
|
if _, ok := props[PostPropsOverrideIconEmoji].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_emoji prop must be a string"))
|
|
}
|
|
}
|
|
if props[PostPropsOverrideUsername] != nil {
|
|
if _, ok := props[PostPropsOverrideUsername].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("override_username prop must be a string"))
|
|
}
|
|
}
|
|
if props[PostPropsFromWebhook] != nil {
|
|
if fromWebhook, ok := props[PostPropsFromWebhook].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("from_webhook prop must be a string"))
|
|
} else if fromWebhook != "true" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("from_webhook prop must be \"true\""))
|
|
}
|
|
}
|
|
if props[PostPropsFromBot] != nil {
|
|
if fromBot, ok := props[PostPropsFromBot].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("from_bot prop must be a string"))
|
|
} else if fromBot != "true" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("from_bot prop must be \"true\""))
|
|
}
|
|
}
|
|
if props[PostPropsFromOAuthApp] != nil {
|
|
if fromOAuthApp, ok := props[PostPropsFromOAuthApp].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("from_oauth_app prop must be a string"))
|
|
} else if fromOAuthApp != "true" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("from_oauth_app prop must be \"true\""))
|
|
}
|
|
}
|
|
if props[PostPropsFromPlugin] != nil {
|
|
if fromPlugin, ok := props[PostPropsFromPlugin].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("from_plugin prop must be a string"))
|
|
} else if fromPlugin != "true" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("from_plugin prop must be \"true\""))
|
|
}
|
|
}
|
|
if props[PostPropsUnsafeLinks] != nil {
|
|
if unsafeLinks, ok := props[PostPropsUnsafeLinks].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("unsafe_links prop must be a string"))
|
|
} else if unsafeLinks != "true" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("unsafe_links prop must be \"true\""))
|
|
}
|
|
}
|
|
if props[PostPropsWebhookDisplayName] != nil {
|
|
if _, ok := props[PostPropsWebhookDisplayName].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("webhook_display_name prop must be a string"))
|
|
}
|
|
}
|
|
|
|
if props[PostPropsMentionHighlightDisabled] != nil {
|
|
if _, ok := props[PostPropsMentionHighlightDisabled].(bool); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("mention_highlight_disabled prop must be a boolean"))
|
|
}
|
|
}
|
|
if props[PostPropsGroupHighlightDisabled] != nil {
|
|
if _, ok := props[PostPropsGroupHighlightDisabled].(bool); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("disable_group_highlight prop must be a boolean"))
|
|
}
|
|
}
|
|
|
|
if props[PostPropsPreviewedPost] != nil {
|
|
if previewedPostID, ok := props[PostPropsPreviewedPost].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("previewed_post prop must be a string"))
|
|
} else if !IsValidId(previewedPostID) {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("previewed_post prop must be a valid post ID"))
|
|
}
|
|
}
|
|
|
|
if props[PostPropsForceNotification] != nil {
|
|
if _, ok := props[PostPropsForceNotification].(bool); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("force_notification prop must be a boolean"))
|
|
}
|
|
}
|
|
|
|
if props[PostPropsAIGeneratedByUserID] != nil {
|
|
if aiGenUserID, ok := props[PostPropsAIGeneratedByUserID].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("ai_generated_by prop must be a string"))
|
|
} else if !IsValidId(aiGenUserID) {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("ai_generated_by prop must be a valid user ID"))
|
|
}
|
|
}
|
|
|
|
if props[PostPropsAIGeneratedByUsername] != nil {
|
|
if _, ok := props[PostPropsAIGeneratedByUsername].(string); !ok {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("ai_generated_by_username prop must be a string"))
|
|
}
|
|
}
|
|
|
|
for i, a := range o.Attachments() {
|
|
if err := a.IsValid(); err != nil {
|
|
multiErr = multierror.Append(multiErr, multierror.Prefix(err, fmt.Sprintf("message attachtment at index %d is invalid:", i)))
|
|
}
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
func (o *Post) IsSystemMessage() bool {
|
|
return len(o.Type) >= len(PostSystemMessagePrefix) && o.Type[:len(PostSystemMessagePrefix)] == PostSystemMessagePrefix
|
|
}
|
|
|
|
// IsRemote returns true if the post originated on a remote cluster.
|
|
func (o *Post) IsRemote() bool {
|
|
return o.RemoteId != nil && *o.RemoteId != ""
|
|
}
|
|
|
|
// GetRemoteID safely returns the remoteID or empty string if not remote.
|
|
func (o *Post) GetRemoteID() string {
|
|
if o.RemoteId != nil {
|
|
return *o.RemoteId
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (o *Post) IsJoinLeaveMessage() bool {
|
|
return o.Type == PostTypeJoinLeave ||
|
|
o.Type == PostTypeAddRemove ||
|
|
o.Type == PostTypeJoinChannel ||
|
|
o.Type == PostTypeLeaveChannel ||
|
|
o.Type == PostTypeJoinTeam ||
|
|
o.Type == PostTypeLeaveTeam ||
|
|
o.Type == PostTypeAddToChannel ||
|
|
o.Type == PostTypeRemoveFromChannel ||
|
|
o.Type == PostTypeAddToTeam ||
|
|
o.Type == PostTypeRemoveFromTeam
|
|
}
|
|
|
|
func (o *Post) Patch(patch *PostPatch) {
|
|
if patch.IsPinned != nil {
|
|
o.IsPinned = *patch.IsPinned
|
|
}
|
|
|
|
if patch.Message != nil {
|
|
o.Message = *patch.Message
|
|
}
|
|
|
|
if patch.Props != nil {
|
|
newProps := *patch.Props
|
|
o.SetProps(newProps)
|
|
}
|
|
|
|
if patch.FileIds != nil {
|
|
o.FileIds = *patch.FileIds
|
|
}
|
|
|
|
if patch.HasReactions != nil {
|
|
o.HasReactions = *patch.HasReactions
|
|
}
|
|
}
|
|
|
|
func (o *Post) ChannelMentions() []string {
|
|
return ChannelMentions(o.Message)
|
|
}
|
|
|
|
// ChannelMentionsAll returns all channel mentions from both the message and attachments.
|
|
// This is used by FillInPostProps to populate channel_mentions for rendering.
|
|
func (o *Post) ChannelMentionsAll() []string {
|
|
// Get mentions from message
|
|
messageMentions := ChannelMentions(o.Message)
|
|
|
|
// Get mentions from attachments
|
|
attachmentMentions := ChannelMentionsFromAttachments(o.Attachments())
|
|
|
|
// Combine and deduplicate
|
|
alreadyMentioned := make(map[string]bool)
|
|
var allMentions []string
|
|
|
|
for _, mention := range messageMentions {
|
|
if !alreadyMentioned[mention] {
|
|
allMentions = append(allMentions, mention)
|
|
alreadyMentioned[mention] = true
|
|
}
|
|
}
|
|
|
|
for _, mention := range attachmentMentions {
|
|
if !alreadyMentioned[mention] {
|
|
allMentions = append(allMentions, mention)
|
|
alreadyMentioned[mention] = true
|
|
}
|
|
}
|
|
|
|
return allMentions
|
|
}
|
|
|
|
// DisableMentionHighlights disables a posts mention highlighting and returns the first channel mention that was present in the message.
|
|
func (o *Post) DisableMentionHighlights() string {
|
|
mention, hasMentions := findAtChannelMention(o.Message)
|
|
if hasMentions {
|
|
o.AddProp(PostPropsMentionHighlightDisabled, true)
|
|
}
|
|
return mention
|
|
}
|
|
|
|
// DisableMentionHighlights disables mention highlighting for a post patch if required.
|
|
func (o *PostPatch) DisableMentionHighlights() {
|
|
if o.Message == nil {
|
|
return
|
|
}
|
|
if _, hasMentions := findAtChannelMention(*o.Message); hasMentions {
|
|
if o.Props == nil {
|
|
o.Props = &StringInterface{}
|
|
}
|
|
(*o.Props)[PostPropsMentionHighlightDisabled] = true
|
|
}
|
|
}
|
|
|
|
func findAtChannelMention(message string) (mention string, found bool) {
|
|
re := regexp.MustCompile(`(?i)\B@(channel|all|here)\b`)
|
|
matched := re.FindStringSubmatch(message)
|
|
if found = (len(matched) > 0); found {
|
|
mention = strings.ToLower(matched[0])
|
|
}
|
|
return
|
|
}
|
|
|
|
func (o *Post) Attachments() []*MessageAttachment {
|
|
if attachments, ok := o.GetProp(PostPropsAttachments).([]*MessageAttachment); ok {
|
|
return attachments
|
|
}
|
|
var ret []*MessageAttachment
|
|
if attachments, ok := o.GetProp(PostPropsAttachments).([]any); ok {
|
|
for _, attachment := range attachments {
|
|
if enc, err := json.Marshal(attachment); err == nil {
|
|
var decoded MessageAttachment
|
|
if json.Unmarshal(enc, &decoded) == nil {
|
|
// Ignoring nil actions
|
|
i := 0
|
|
for _, action := range decoded.Actions {
|
|
if action != nil {
|
|
decoded.Actions[i] = action
|
|
i++
|
|
}
|
|
}
|
|
decoded.Actions = decoded.Actions[:i]
|
|
|
|
// Ignoring nil fields
|
|
i = 0
|
|
for _, field := range decoded.Fields {
|
|
if field != nil {
|
|
decoded.Fields[i] = field
|
|
i++
|
|
}
|
|
}
|
|
decoded.Fields = decoded.Fields[:i]
|
|
ret = append(ret, &decoded)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func (o *Post) AttachmentsEqual(input *Post) bool {
|
|
attachments := o.Attachments()
|
|
inputAttachments := input.Attachments()
|
|
|
|
if len(attachments) != len(inputAttachments) {
|
|
return false
|
|
}
|
|
|
|
for i := range attachments {
|
|
if !attachments[i].Equals(inputAttachments[i]) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
var markdownDestinationEscaper = strings.NewReplacer(
|
|
`\`, `\\`,
|
|
`<`, `\<`,
|
|
`>`, `\>`,
|
|
`(`, `\(`,
|
|
`)`, `\)`,
|
|
)
|
|
|
|
// WithRewrittenImageURLs returns a new shallow copy of the post where the message has been
|
|
// rewritten via RewriteImageURLs.
|
|
func (o *Post) WithRewrittenImageURLs(f func(string) string) *Post {
|
|
pCopy := o.Clone()
|
|
pCopy.Message = RewriteImageURLs(o.Message, f)
|
|
if pCopy.MessageSource == "" && pCopy.Message != o.Message {
|
|
pCopy.MessageSource = o.Message
|
|
}
|
|
return pCopy
|
|
}
|
|
|
|
// RewriteImageURLs takes a message and returns a copy that has all of the image URLs replaced
|
|
// according to the function f. For each image URL, f will be invoked, and the resulting markdown
|
|
// will contain the URL returned by that invocation instead.
|
|
//
|
|
// Image URLs are destination URLs used in inline images or reference definitions that are used
|
|
// anywhere in the input markdown as an image.
|
|
func RewriteImageURLs(message string, f func(string) string) string {
|
|
if !strings.Contains(message, "![") {
|
|
return message
|
|
}
|
|
|
|
var ranges []markdown.Range
|
|
|
|
markdown.Inspect(message, func(blockOrInline any) bool {
|
|
switch v := blockOrInline.(type) {
|
|
case *markdown.ReferenceImage:
|
|
ranges = append(ranges, v.ReferenceDefinition.RawDestination)
|
|
case *markdown.InlineImage:
|
|
ranges = append(ranges, v.RawDestination)
|
|
default:
|
|
return true
|
|
}
|
|
return true
|
|
})
|
|
|
|
if ranges == nil {
|
|
return message
|
|
}
|
|
|
|
sort.Slice(ranges, func(i, j int) bool {
|
|
return ranges[i].Position < ranges[j].Position
|
|
})
|
|
|
|
copyRanges := make([]markdown.Range, 0, len(ranges))
|
|
urls := make([]string, 0, len(ranges))
|
|
resultLength := len(message)
|
|
|
|
start := 0
|
|
for i, r := range ranges {
|
|
switch {
|
|
case i == 0:
|
|
case r.Position != ranges[i-1].Position:
|
|
start = ranges[i-1].End
|
|
default:
|
|
continue
|
|
}
|
|
original := message[r.Position:r.End]
|
|
replacement := markdownDestinationEscaper.Replace(f(markdown.Unescape(original)))
|
|
resultLength += len(replacement) - len(original)
|
|
copyRanges = append(copyRanges, markdown.Range{Position: start, End: r.Position})
|
|
urls = append(urls, replacement)
|
|
}
|
|
|
|
result := make([]byte, resultLength)
|
|
|
|
offset := 0
|
|
for i, r := range copyRanges {
|
|
offset += copy(result[offset:], message[r.Position:r.End])
|
|
offset += copy(result[offset:], urls[i])
|
|
}
|
|
copy(result[offset:], message[ranges[len(ranges)-1].End:])
|
|
|
|
return string(result)
|
|
}
|
|
|
|
func (o *Post) IsFromOAuthBot() bool {
|
|
props := o.GetProps()
|
|
return props[PostPropsFromWebhook] == "true" && props[PostPropsOverrideUsername] != ""
|
|
}
|
|
|
|
func (o *Post) ToNilIfInvalid() *Post {
|
|
if o.Id == "" {
|
|
return nil
|
|
}
|
|
return o
|
|
}
|
|
|
|
func (o *Post) ForPlugin() *Post {
|
|
p := o.Clone()
|
|
p.Metadata = nil
|
|
if p.Type == fmt.Sprintf("%sup_notification", PostCustomTypePrefix) {
|
|
p.DelProp("requested_features")
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (o *Post) GetPreviewPost() *PreviewPost {
|
|
if o.Metadata == nil {
|
|
return nil
|
|
}
|
|
for _, embed := range o.Metadata.Embeds {
|
|
if embed != nil && embed.Type == PostEmbedPermalink {
|
|
if previewPost, ok := embed.Data.(*PreviewPost); ok {
|
|
return previewPost
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *Post) GetPreviewedPostProp() string {
|
|
if val, ok := o.GetProp(PostPropsPreviewedPost).(string); ok {
|
|
return val
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (o *Post) GetPriority() *PostPriority {
|
|
if o.Metadata == nil {
|
|
return nil
|
|
}
|
|
return o.Metadata.Priority
|
|
}
|
|
|
|
func (o *Post) GetPersistentNotification() *bool {
|
|
priority := o.GetPriority()
|
|
if priority == nil {
|
|
return nil
|
|
}
|
|
return priority.PersistentNotifications
|
|
}
|
|
|
|
func (o *Post) GetRequestedAck() *bool {
|
|
priority := o.GetPriority()
|
|
if priority == nil {
|
|
return nil
|
|
}
|
|
return priority.RequestedAck
|
|
}
|
|
|
|
func (o *Post) IsUrgent() bool {
|
|
postPriority := o.GetPriority()
|
|
if postPriority == nil {
|
|
return false
|
|
}
|
|
|
|
if postPriority.Priority == nil {
|
|
return false
|
|
}
|
|
|
|
return *postPriority.Priority == PostPriorityUrgent
|
|
}
|
|
|
|
func (o *Post) CleanPost() *Post {
|
|
o.Id = ""
|
|
o.CreateAt = 0
|
|
o.UpdateAt = 0
|
|
o.EditAt = 0
|
|
return o
|
|
}
|
|
|
|
type UpdatePostOptions struct {
|
|
SafeUpdate bool
|
|
IsRestorePost bool
|
|
}
|
|
|
|
func DefaultUpdatePostOptions() *UpdatePostOptions {
|
|
return &UpdatePostOptions{
|
|
SafeUpdate: false,
|
|
IsRestorePost: false,
|
|
}
|
|
}
|
|
|
|
type PreparePostForClientOpts struct {
|
|
IsNewPost bool
|
|
IsEditPost bool
|
|
IncludePriority bool
|
|
RetainContent bool
|
|
IncludeDeleted bool
|
|
}
|
|
|
|
// ReportPostOptions contains options for querying posts for reporting/compliance purposes
|
|
type ReportPostOptions struct {
|
|
ChannelId string `json:"channel_id"`
|
|
StartTime int64 `json:"start_time,omitempty"` // Optional: Start time for query range (unix timestamp in milliseconds)
|
|
TimeField string `json:"time_field,omitempty"` // "create_at" or "update_at" (default: "create_at")
|
|
SortDirection string `json:"sort_direction,omitempty"` // "asc" or "desc" (default: "asc")
|
|
PerPage int `json:"per_page,omitempty"` // Number of posts per page (default: 100, max: MaxReportingPerPage)
|
|
IncludeDeleted bool `json:"include_deleted,omitempty"` // Include deleted posts
|
|
ExcludeSystemPosts bool `json:"exclude_system_posts,omitempty"` // Exclude all system posts (any type starting with "system_")
|
|
IncludeMetadata bool `json:"include_metadata,omitempty"` // Include file info, reactions, etc.
|
|
}
|
|
type RewriteAction string
|
|
|
|
const (
|
|
RewriteActionCustom RewriteAction = "custom"
|
|
RewriteActionShorten RewriteAction = "shorten"
|
|
RewriteActionElaborate RewriteAction = "elaborate"
|
|
RewriteActionImproveWriting RewriteAction = "improve_writing"
|
|
RewriteActionFixSpelling RewriteAction = "fix_spelling"
|
|
RewriteActionSimplify RewriteAction = "simplify"
|
|
RewriteActionSummarize RewriteAction = "summarize"
|
|
)
|
|
|
|
type RewriteRequest struct {
|
|
AgentID string `json:"agent_id"`
|
|
Message string `json:"message"`
|
|
Action RewriteAction `json:"action"`
|
|
CustomPrompt string `json:"custom_prompt,omitempty"`
|
|
RootID string `json:"root_id,omitempty"`
|
|
}
|
|
|
|
type RewriteResponse struct {
|
|
RewrittenText string `json:"rewritten_text"`
|
|
}
|
|
|
|
const RewriteSystemPrompt = `You are a JSON API that rewrites text. Your response must be valid JSON only.
|
|
Return this exact format: {"rewritten_text":"content"}.
|
|
Do not use markdown, code blocks, or any formatting. Start with { and end with }.`
|
|
|
|
// ReportPostOptionsCursor contains cursor information for pagination.
|
|
// The cursor is an opaque base64-encoded string that encodes all pagination state.
|
|
// Clients should treat this as an opaque token and pass it back unchanged.
|
|
//
|
|
// Internal format (before base64 encoding):
|
|
//
|
|
// v1: "version:channel_id:time_field:include_deleted:exclude_system_posts:sort_direction:timestamp:post_id"
|
|
//
|
|
// Field order (general to specific):
|
|
// - version: Allows format evolution
|
|
// - channel_id: Which channel to query (filter)
|
|
// - time_field: Which timestamp column to use for ordering (filter/config)
|
|
// - include_deleted: Whether to include deleted posts (filter)
|
|
// - exclude_system_posts: Whether to exclude channel metadata system posts (filter)
|
|
// - sort_direction: Query direction ASC vs DESC (filter/config)
|
|
// - timestamp: The cursor position in time (pagination state)
|
|
// - post_id: Tie-breaker for posts with identical timestamps (pagination state)
|
|
//
|
|
// Version history:
|
|
// - v1: Initial format with all query-affecting parameters ordered general→specific, base64-encoded for opacity
|
|
// ReportPostOptionsCursor contains the pagination cursor for posts reporting.
|
|
//
|
|
// The cursor is opaque and self-contained:
|
|
// - It's base64-encoded and contains all query parameters (channel_id, time_field, sort_direction, etc.)
|
|
// - When a cursor is provided, query parameters in the request body are IGNORED
|
|
// - The cursor's embedded parameters take precedence over request body parameters
|
|
// - This allows clients to keep sending the same parameters on every page without errors
|
|
// - For the first page, omit the cursor field or set it to ""
|
|
type ReportPostOptionsCursor struct {
|
|
Cursor string `json:"cursor,omitempty"` // Optional: Opaque base64-encoded cursor string (omit or use "" for first request)
|
|
}
|
|
|
|
// ReportPostListResponse contains the response for cursor-based post reporting queries
|
|
type ReportPostListResponse struct {
|
|
Posts []*Post `json:"posts"`
|
|
NextCursor *ReportPostOptionsCursor `json:"next_cursor,omitempty"` // nil if no more pages
|
|
}
|
|
|
|
// ReportPostQueryParams contains the fully resolved query parameters for the store layer.
|
|
// This struct is used internally after cursor decoding and parameter resolution.
|
|
// The store layer receives these concrete parameters and executes the query.
|
|
type ReportPostQueryParams struct {
|
|
ChannelId string // Required: Channel to query
|
|
CursorTime int64 // Pagination cursor time position
|
|
CursorId string // Pagination cursor ID for tie-breaking
|
|
TimeField string // Resolved: "create_at" or "update_at"
|
|
SortDirection string // Resolved: "asc" or "desc"
|
|
IncludeDeleted bool // Resolved: include deleted posts
|
|
ExcludeSystemPosts bool // Resolved: exclude system posts
|
|
PerPage int // Number of posts per page (already validated)
|
|
}
|
|
|
|
// Validate validates the ReportPostQueryParams fields.
|
|
// This should be called after parameter resolution (from cursor or options) and before passing to the store layer.
|
|
// Note: PerPage is handled separately in the API layer (capped at 100-1000 range).
|
|
func (q *ReportPostQueryParams) Validate() *AppError {
|
|
// Validate ChannelId
|
|
if !IsValidId(q.ChannelId) {
|
|
return NewAppError("ReportPostQueryParams.Validate", "model.post.query_params.invalid_channel_id", nil, "channel_id must be a valid 26-character ID", 400)
|
|
}
|
|
|
|
// Validate TimeField
|
|
if q.TimeField != ReportingTimeFieldCreateAt && q.TimeField != ReportingTimeFieldUpdateAt {
|
|
return NewAppError("ReportPostQueryParams.Validate", "model.post.query_params.invalid_time_field", nil, fmt.Sprintf("time_field must be %q or %q", ReportingTimeFieldCreateAt, ReportingTimeFieldUpdateAt), 400)
|
|
}
|
|
|
|
// Validate SortDirection
|
|
if q.SortDirection != ReportingSortDirectionAsc && q.SortDirection != ReportingSortDirectionDesc {
|
|
return NewAppError("ReportPostQueryParams.Validate", "model.post.query_params.invalid_sort_direction", nil, fmt.Sprintf("sort_direction must be %q or %q", ReportingSortDirectionAsc, ReportingSortDirectionDesc), 400)
|
|
}
|
|
|
|
// Validate CursorId - can be empty (first page) or must be a valid ID format (subsequent pages)
|
|
if q.CursorId != "" && !IsValidId(q.CursorId) {
|
|
return NewAppError("ReportPostQueryParams.Validate", "model.post.query_params.invalid_cursor_id", nil, "cursor_id must be a valid 26-character ID", 400)
|
|
}
|
|
|
|
// CursorTime is validated by the fact it's an int64
|
|
// PerPage is handled in API layer before calling Validate()
|
|
return nil
|
|
}
|
|
|
|
// EncodeReportPostCursor creates an opaque cursor string from pagination state.
|
|
// The cursor encodes all query-affecting parameters to ensure consistency across pages.
|
|
// The cursor is base64-encoded to ensure it's truly opaque and URL-safe.
|
|
//
|
|
// Internal format: "version:channel_id:time_field:include_deleted:exclude_system_posts:sort_direction:timestamp:post_id"
|
|
// Example (before encoding): "1:abc123xyz:create_at:false:true:asc:1635724800000:post456def"
|
|
func EncodeReportPostCursor(channelId string, timeField string, includeDeleted bool, excludeSystemPosts bool, sortDirection string, timestamp int64, postId string) string {
|
|
plainText := fmt.Sprintf("1:%s:%s:%t:%t:%s:%d:%s",
|
|
channelId,
|
|
timeField,
|
|
includeDeleted,
|
|
excludeSystemPosts,
|
|
sortDirection,
|
|
timestamp,
|
|
postId)
|
|
return base64.URLEncoding.EncodeToString([]byte(plainText))
|
|
}
|
|
|
|
// DecodeReportPostCursorV1 parses an opaque cursor string into query parameters.
|
|
// Returns a partially populated ReportPostQueryParams (missing PerPage which comes from the request).
|
|
func DecodeReportPostCursorV1(cursor string) (*ReportPostQueryParams, *AppError) {
|
|
decoded, err := base64.URLEncoding.DecodeString(cursor)
|
|
if err != nil {
|
|
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_base64", nil, err.Error(), 400)
|
|
}
|
|
|
|
parts := strings.Split(string(decoded), ":")
|
|
if len(parts) != 8 {
|
|
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_format", nil, fmt.Sprintf("expected 8 parts, got %d", len(parts)), 400)
|
|
}
|
|
|
|
version, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_version", nil, fmt.Sprintf("version must be an integer: %s", err.Error()), 400)
|
|
}
|
|
if version != 1 {
|
|
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.unsupported_version", nil, fmt.Sprintf("version %d", version), 400)
|
|
}
|
|
|
|
includeDeleted, err := strconv.ParseBool(parts[3])
|
|
if err != nil {
|
|
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_include_deleted", nil, fmt.Sprintf("include_deleted must be a boolean: %s", err.Error()), 400)
|
|
}
|
|
|
|
excludeSystemPosts, err := strconv.ParseBool(parts[4])
|
|
if err != nil {
|
|
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_exclude_system_posts", nil, fmt.Sprintf("exclude_system_posts must be a boolean: %s", err.Error()), 400)
|
|
}
|
|
|
|
timestamp, err := strconv.ParseInt(parts[6], 10, 64)
|
|
if err != nil {
|
|
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_timestamp", nil, fmt.Sprintf("timestamp must be an integer: %s", err.Error()), 400)
|
|
}
|
|
|
|
return &ReportPostQueryParams{
|
|
ChannelId: parts[1],
|
|
CursorTime: timestamp,
|
|
CursorId: parts[7],
|
|
TimeField: parts[2],
|
|
SortDirection: parts[5],
|
|
IncludeDeleted: includeDeleted,
|
|
ExcludeSystemPosts: excludeSystemPosts,
|
|
}, nil
|
|
}
|