Files
team/server/public/model/post_interactive_blocks.go
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

797 lines
20 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"fmt"
"maps"
"strings"
"github.com/hanzoteam/server/server/public/shared/markdown"
)
func appendHumanReadableInteractiveStrings(o *Post, out []string) []string {
props := o.GetProps()
if props == nil {
return out
}
if raw, ok := props[PostPropsMmBlocks]; ok {
out = appendHumanStringsFromMmBlocks(raw, out)
}
if raw, ok := props[PostPropsBlockKitBlocks]; ok {
out = appendHumanStringsFromBlockKitTree(raw, out)
}
if raw, ok := props[PostPropsAdaptiveCards]; ok {
out = appendHumanStringsFromAdaptiveCardsTree(raw, out)
}
return out
}
func interactivePropJSONArray(raw any) ([]any, bool) {
switch v := raw.(type) {
case []any:
return v, true
default:
return nil, false
}
}
func interactivePropJSONArrayNonEmpty(raw any) bool {
arr, ok := interactivePropJSONArray(raw)
return ok && len(arr) > 0
}
func appendHumanStringsFromMmBlocks(raw any, out []string) []string {
blocks, ok := interactivePropJSONArray(raw)
if !ok {
return out
}
for _, b := range blocks {
m, ok := b.(map[string]any)
if !ok {
continue
}
out = appendHumanStringsFromMmBlockMap(m, out)
}
return out
}
func appendHumanStringsFromMmBlockMap(m map[string]any, out []string) []string {
typ, _ := m["type"].(string)
switch typ {
case "text":
if s, ok := m["text"].(string); ok {
out = appendNonWhitespaceOnlyMessage(out, s)
}
case "container":
out = appendHumanStringsFromMmBlocksArray(m["content"], out)
case "collapsible":
out = appendHumanStringsFromMmBlocksArray(m["header"], out)
out = appendHumanStringsFromMmBlocksArray(m["content"], out)
case "column_set":
if cols, ok := m["columns"].([]any); ok {
for _, col := range cols {
cm, ok := col.(map[string]any)
if !ok {
continue
}
colItems, ok := cm["items"].([]any)
if !ok {
continue
}
out = appendHumanStringsFromMmBlocksArray(colItems, out)
}
}
}
return out
}
func appendHumanStringsFromMmBlocksArray(raw any, out []string) []string {
arr, ok := interactivePropJSONArray(raw)
if !ok {
return out
}
for _, el := range arr {
m, ok := el.(map[string]any)
if !ok {
continue
}
out = appendHumanStringsFromMmBlockMap(m, out)
}
return out
}
func appendHumanStringsFromBlockKitTree(v any, out []string) []string {
blocks, ok := v.([]any)
if !ok {
return out
}
for _, block := range blocks {
blockMap, ok := block.(map[string]any)
if !ok {
continue
}
typ, _ := blockMap["type"].(string)
switch typ {
case "markdown":
if s, ok := blockMap["text"].(string); ok {
out = appendNonWhitespaceOnlyMessage(out, s)
}
case "section":
if textBlock, ok := blockMap["text"].(map[string]any); ok {
if s, ok := textBlock["text"].(string); ok {
out = appendNonWhitespaceOnlyMessage(out, s)
}
}
if fields, ok := blockMap["fields"].([]any); ok {
for _, field := range fields {
fieldMap, ok := field.(map[string]any)
if !ok {
continue
}
fieldText, ok := fieldMap["text"].(string)
if ok {
out = appendNonWhitespaceOnlyMessage(out, fieldText)
}
}
}
case "header":
if textBlock, ok := blockMap["text"].(map[string]any); ok {
if s, ok := textBlock["text"].(string); ok {
out = appendNonWhitespaceOnlyMessage(out, s)
}
}
}
}
return out
}
func appendHumanStringsFromAdaptiveCardsTree(v any, out []string) []string {
cards, ok := v.([]any)
if !ok {
return out
}
for _, card := range cards {
cardMap, ok := card.(map[string]any)
if !ok {
continue
}
body, ok := cardMap["body"].([]any)
if !ok {
continue
}
for _, item := range body {
out = appendHumanStringsFromAdaptiveCardsItem(item, out)
}
}
return out
}
func appendHumanStringsFromAdaptiveCardsItem(item any, out []string) []string {
itemMap, ok := item.(map[string]any)
if !ok {
return out
}
typ, _ := itemMap["type"].(string)
switch typ {
case "TextBlock":
if s, ok := itemMap["text"].(string); ok {
out = appendNonWhitespaceOnlyMessage(out, s)
}
case "Container":
if items, ok := itemMap["items"].([]any); ok {
for _, item := range items {
out = appendHumanStringsFromAdaptiveCardsItem(item, out)
}
}
case "ColumnSet":
if columns, ok := itemMap["columns"].([]any); ok {
for _, column := range columns {
columnMap, ok := column.(map[string]any)
if !ok {
continue
}
itemMap, ok := columnMap["items"].([]any)
if !ok {
continue
}
for _, item := range itemMap {
out = appendHumanStringsFromAdaptiveCardsItem(item, out)
}
}
}
}
return out
}
func appendMmBlockImageURLs(out []string, raw any) []string {
blocks, ok := interactivePropJSONArray(raw)
if !ok {
return out
}
for _, b := range blocks {
m, ok := b.(map[string]any)
if !ok {
continue
}
out = appendMmBlockMapImageURLs(out, m)
}
return out
}
func appendMmBlockMapImageURLs(out []string, m map[string]any) []string {
typ, _ := m["type"].(string)
switch typ {
case "image":
if u, ok := m["url"].(string); ok {
out = append(out, u)
}
case "container":
out = appendMmBlocksArrayImageURLs(out, m["content"])
case "collapsible":
out = appendMmBlocksArrayImageURLs(out, m["header"])
out = appendMmBlocksArrayImageURLs(out, m["content"])
case "column_set":
if cols, ok := m["columns"].([]any); ok {
for _, col := range cols {
cm, ok := col.(map[string]any)
if !ok {
continue
}
colItems, ok := cm["items"].([]any)
if !ok {
continue
}
for _, item := range colItems {
out = appendMmBlocksArrayImageURLs(out, item)
}
}
}
}
return out
}
func appendMmBlocksArrayImageURLs(out []string, raw any) []string {
arr, ok := interactivePropJSONArray(raw)
if !ok {
return out
}
for _, el := range arr {
m, ok := el.(map[string]any)
if !ok {
continue
}
out = appendMmBlockMapImageURLs(out, m)
}
return out
}
func appendBlockKitImageURLs(out []string, v any) []string {
blocks, ok := interactivePropJSONArray(v)
if !ok {
return out
}
for _, block := range blocks {
blockMap, ok := block.(map[string]any)
if !ok {
continue
}
typ, _ := blockMap["type"].(string)
switch typ {
case "section":
if accessory, ok := blockMap["accessory"].(map[string]any); ok {
accessoryType, _ := accessory["type"].(string)
if accessoryType != "image" {
continue
}
if u, ok := accessory["image_url"].(string); ok {
out = append(out, u)
}
}
case "image":
if u, ok := blockMap["image_url"].(string); ok {
out = append(out, u)
}
}
}
return out
}
func appendAdaptiveCardImageURLs(out []string, v any) []string {
cards, ok := interactivePropJSONArray(v)
if !ok {
return out
}
for _, card := range cards {
cardMap, ok := card.(map[string]any)
if !ok {
continue
}
body, ok := cardMap["body"].([]any)
if !ok {
continue
}
for _, item := range body {
out = appendAdaptiveCardImageURLsFromItem(out, item)
}
}
return out
}
func appendAdaptiveCardImageURLsFromItem(out []string, item any) []string {
itemMap, ok := item.(map[string]any)
if !ok {
return out
}
typ, _ := itemMap["type"].(string)
switch typ {
case "Container":
if items, ok := itemMap["items"].([]any); ok {
for _, item := range items {
out = appendAdaptiveCardImageURLsFromItem(out, item)
}
}
case "ColumnSet":
if columns, ok := itemMap["columns"].([]any); ok {
for _, column := range columns {
columnMap, ok := column.(map[string]any)
if !ok {
continue
}
items, ok := columnMap["items"].([]any)
if !ok {
continue
}
for _, item := range items {
out = appendAdaptiveCardImageURLsFromItem(out, item)
}
}
}
case "Image":
if u, ok := itemMap["url"].(string); ok {
out = append(out, u)
}
}
return out
}
func appendAttachmentsImageURLs(out []string, attachments []*MessageAttachment) []string {
for _, attachment := range attachments {
if attachment == nil {
continue
}
if attachment.ImageURL != "" {
out = append(out, attachment.ImageURL)
}
if attachment.ThumbURL != "" {
out = append(out, attachment.ThumbURL)
}
if attachment.AuthorIcon != "" {
out = append(out, attachment.AuthorIcon)
}
if attachment.FooterIcon != "" {
out = append(out, attachment.FooterIcon)
}
}
return out
}
const mmactionScheme = "mmaction://"
// appendMmactionIDsFromText appends action ids from mmaction:// markdown links only.
// Inline code, fenced code blocks, and other non-link text are ignored (same approach as mentions).
func appendMmactionIDsFromText(ids map[string]struct{}, text string) map[string]struct{} {
if ids == nil {
ids = make(map[string]struct{})
}
markdown.Inspect(text, func(blockOrInline any) bool {
switch v := blockOrInline.(type) {
case *markdown.InlineLink:
ids = appendMmactionIDFromURL(ids, v.Destination())
case *markdown.ReferenceLink:
if v.ReferenceDefinition != nil {
ids = appendMmactionIDFromURL(ids, v.ReferenceDefinition.Destination())
}
case *markdown.Autolink:
ids = appendMmactionIDFromURL(ids, v.Destination())
}
return true
})
return ids
}
func appendMmactionIDFromURL(ids map[string]struct{}, url string) map[string]struct{} {
if !strings.HasPrefix(url, mmactionScheme) {
return ids
}
if ids == nil {
ids = make(map[string]struct{})
}
withoutScheme := url[len(mmactionScheme):]
actionID := withoutScheme
if i := strings.IndexAny(withoutScheme, "/?#"); i >= 0 {
actionID = withoutScheme[:i]
}
if actionID != "" && mmBlocksActionIDRegex.MatchString(actionID) {
ids[actionID] = struct{}{}
}
return ids
}
func mergeActionIDs(into, from map[string]struct{}) {
maps.Copy(into, from)
}
func interactiveControlDisabled(m map[string]any) bool {
disabled, ok := m["disabled"].(bool)
return ok && disabled
}
func appendMmBlockActionIDsFromMap(ids map[string]struct{}, m map[string]any) map[string]struct{} {
typ, _ := m["type"].(string)
switch typ {
case "text":
if s, ok := m["text"].(string); ok {
ids = appendMmactionIDsFromText(ids, s)
}
case "button", "static_select":
if interactiveControlDisabled(m) {
break
}
if id, ok := m["action_id"].(string); ok && id != "" {
if ids == nil {
ids = make(map[string]struct{})
}
ids[id] = struct{}{}
}
case "container":
ids = appendMmBlockActionIDsFromArray(ids, m["content"])
case "collapsible":
ids = appendMmBlockActionIDsFromArray(ids, m["header"])
ids = appendMmBlockActionIDsFromArray(ids, m["content"])
case "column_set":
if cols, ok := m["columns"].([]any); ok {
for _, col := range cols {
cm, ok := col.(map[string]any)
if !ok {
continue
}
colTyp, _ := cm["type"].(string)
if colTyp != "column" {
continue
}
ids = appendMmBlockActionIDsFromArray(ids, cm["items"])
}
}
}
return ids
}
func appendMmBlockActionIDsFromArray(ids map[string]struct{}, raw any) map[string]struct{} {
arr, ok := interactivePropJSONArray(raw)
if !ok {
return ids
}
for _, el := range arr {
m, ok := el.(map[string]any)
if !ok {
continue
}
ids = appendMmBlockActionIDsFromMap(ids, m)
}
return ids
}
// CollectMmBlockActionIDs returns action_id values referenced by interactive mm_blocks controls.
func CollectMmBlockActionIDs(blocks []any) map[string]struct{} {
var ids map[string]struct{}
for _, b := range blocks {
m, ok := b.(map[string]any)
if !ok {
continue
}
ids = appendMmBlockActionIDsFromMap(ids, m)
}
return ids
}
func appendBlockKitTextMmaction(ids map[string]struct{}, raw any) map[string]struct{} {
if raw == nil {
return ids
}
switch v := raw.(type) {
case string:
return appendMmactionIDsFromText(ids, v)
case map[string]any:
if s, ok := v["text"].(string); ok {
return appendMmactionIDsFromText(ids, s)
}
}
return ids
}
func appendBlockKitAccessory(ids map[string]struct{}, accessory map[string]any) map[string]struct{} {
typ, _ := accessory["type"].(string)
switch typ {
case "button", "static_select":
if interactiveControlDisabled(accessory) {
return ids
}
if id, ok := accessory["action_id"].(string); ok && id != "" {
if ids == nil {
ids = make(map[string]struct{})
}
ids[id] = struct{}{}
}
}
return ids
}
func appendBlockKitActionElement(ids map[string]struct{}, el any) map[string]struct{} {
e, ok := el.(map[string]any)
if !ok {
return ids
}
typ, _ := e["type"].(string)
switch typ {
case "button", "static_select":
if interactiveControlDisabled(e) {
return ids
}
if id, ok := e["action_id"].(string); ok && id != "" {
if ids == nil {
ids = make(map[string]struct{})
}
ids[id] = struct{}{}
}
}
return ids
}
func appendBlockKitActionIDsFromBlock(ids map[string]struct{}, m map[string]any) map[string]struct{} {
typ, _ := m["type"].(string)
switch typ {
case "actions":
if elements, ok := m["elements"].([]any); ok {
for _, el := range elements {
ids = appendBlockKitActionElement(ids, el)
}
}
case "section":
ids = appendBlockKitTextMmaction(ids, m["text"])
if accessory, ok := m["accessory"].(map[string]any); ok {
ids = appendBlockKitAccessory(ids, accessory)
}
if fields, ok := m["fields"].([]any); ok {
for _, field := range fields {
ids = appendBlockKitTextMmaction(ids, field)
}
}
case "markdown":
ids = appendBlockKitTextMmaction(ids, m["text"])
case "header":
ids = appendBlockKitTextMmaction(ids, m["text"])
}
return ids
}
// CollectBlockKitActionIDs returns action_id values from Block Kit blocks (props.blocks).
func CollectBlockKitActionIDs(blocks []any) map[string]struct{} {
var ids map[string]struct{}
for _, b := range blocks {
m, ok := b.(map[string]any)
if !ok {
continue
}
ids = appendBlockKitActionIDsFromBlock(ids, m)
}
return ids
}
func appendAdaptiveCardActionElement(ids map[string]struct{}, action any) map[string]struct{} {
ac, ok := action.(map[string]any)
if !ok {
return ids
}
typ, _ := ac["type"].(string)
if typ == "Action.Submit" {
if id, ok := ac["id"].(string); ok && id != "" {
if ids == nil {
ids = make(map[string]struct{})
}
ids[id] = struct{}{}
}
}
return ids
}
func appendAdaptiveCardActionIDsFromItem(ids map[string]struct{}, item any) map[string]struct{} {
itemMap, ok := item.(map[string]any)
if !ok {
return ids
}
typ, _ := itemMap["type"].(string)
switch typ {
case "TextBlock":
if s, ok := itemMap["text"].(string); ok {
ids = appendMmactionIDsFromText(ids, s)
}
case "Container":
if items, ok := itemMap["items"].([]any); ok {
for _, nested := range items {
ids = appendAdaptiveCardActionIDsFromItem(ids, nested)
}
}
case "ColumnSet":
if columns, ok := itemMap["columns"].([]any); ok {
for _, column := range columns {
columnMap, ok := column.(map[string]any)
if !ok {
continue
}
if items, ok := columnMap["items"].([]any); ok {
for _, nested := range items {
ids = appendAdaptiveCardActionIDsFromItem(ids, nested)
}
}
}
}
case "ActionSet":
if actions, ok := itemMap["actions"].([]any); ok {
for _, action := range actions {
ids = appendAdaptiveCardActionElement(ids, action)
}
}
}
return ids
}
// CollectAdaptiveCardActionIDs returns action ids from Adaptive Cards (props.cards).
func CollectAdaptiveCardActionIDs(cards []any) map[string]struct{} {
var ids map[string]struct{}
for _, card := range cards {
cardMap, ok := card.(map[string]any)
if !ok {
continue
}
if body, ok := cardMap["body"].([]any); ok {
for _, item := range body {
ids = appendAdaptiveCardActionIDsFromItem(ids, item)
}
}
if actions, ok := cardMap["actions"].([]any); ok {
for _, action := range actions {
ids = appendAdaptiveCardActionElement(ids, action)
}
}
}
return ids
}
// CollectInteractiveActionIDs returns action ids referenced by interactive post props.
func CollectInteractiveActionIDs(props map[string]any) map[string]struct{} {
ids := make(map[string]struct{})
if props == nil {
return ids
}
if raw, ok := props[PostPropsMmBlocks]; ok {
if blocks, ok := interactivePropJSONArray(raw); ok {
mergeActionIDs(ids, CollectMmBlockActionIDs(blocks))
}
}
if raw, ok := props[PostPropsBlockKitBlocks]; ok {
if blocks, ok := interactivePropJSONArray(raw); ok {
mergeActionIDs(ids, CollectBlockKitActionIDs(blocks))
}
}
if raw, ok := props[PostPropsAdaptiveCards]; ok {
if cards, ok := interactivePropJSONArray(raw); ok {
mergeActionIDs(ids, CollectAdaptiveCardActionIDs(cards))
}
}
return ids
}
// CollectInteractiveActionIDsFromPost includes mmaction:// links in the post message.
func CollectInteractiveActionIDsFromPost(o *Post) map[string]struct{} {
ids := CollectInteractiveActionIDs(o.GetProps())
if o.Message != "" {
ids = appendMmactionIDsFromText(ids, o.Message)
}
return ids
}
// CollectMmactionIDsFromText returns action ids from mmaction:// links in a string.
func CollectMmactionIDsFromText(text string) map[string]struct{} {
return appendMmactionIDsFromText(nil, text)
}
// SubsetMmBlocksActions returns registry entries referenced by actionIDs.
func SubsetMmBlocksActions(allActions any, actionIDs map[string]struct{}) map[string]any {
if allActions == nil || len(actionIDs) == 0 {
return nil
}
top, ok := allActions.(map[string]any)
if !ok {
return nil
}
out := make(map[string]any, len(actionIDs))
for id := range actionIDs {
if entry, ok := top[id]; ok {
out[id] = entry
}
}
if len(out) == 0 {
return nil
}
return out
}
// RefreshInteractiveActionsOnPost sets mm_blocks_actions to the subset needed by this post's interactive content.
func RefreshInteractiveActionsOnPost(o *Post, allActions any) {
ids := CollectInteractiveActionIDsFromPost(o)
props := o.GetProps()
if props == nil {
props = make(map[string]any)
}
if len(ids) == 0 {
delete(props, PostPropsMmBlocksActions)
} else if subset := SubsetMmBlocksActions(allActions, ids); len(subset) > 0 {
props[PostPropsMmBlocksActions] = subset
} else {
delete(props, PostPropsMmBlocksActions)
}
o.SetProps(props)
}
// ApplyMmBlocksWithActionsToProps sets mm_blocks and refreshes mm_blocks_actions for the props payload.
// When props is nil, a new map is allocated and returned.
func ApplyMmBlocksWithActionsToProps(props map[string]any, blocks []any, allActions any) StringInterface {
if props == nil {
props = make(map[string]any)
}
props[PostPropsMmBlocks] = blocks
RefreshInteractiveActionsOnPost(&Post{Props: props}, allActions)
return props
}
// validateMmBlocksActionsPairing requires mm_blocks_actions to define exactly the actions
// referenced by mm_blocks, blocks, cards, and mmaction:// links in the post message.
func validateMmBlocksActionsPairing(o *Post, actions map[string]any) error {
referenced := CollectInteractiveActionIDsFromPost(o)
if len(referenced) == 0 {
if len(actions) > 0 {
return fmt.Errorf("mm_blocks_actions must only define actions referenced by interactive content")
}
return nil
}
for id := range referenced {
if _, ok := actions[id]; !ok {
return fmt.Errorf("mm_blocks_actions missing entry for action_id %q", id)
}
}
for key := range actions {
if _, ok := referenced[key]; !ok {
return fmt.Errorf("mm_blocks_actions entry %q is not referenced by interactive content", key)
}
}
return nil
}
// ValidateInteractiveActionsForWebhook checks interactive payloads and mm_blocks_actions are paired.
func ValidateInteractiveActionsForWebhook(o *Post) error {
return ValidateMmBlocksActions(o)
}
// ValidateMmBlocksActionsForWebhook validates mm_blocks-only webhook payloads (legacy helper).
func ValidateMmBlocksActionsForWebhook(blocks []any, actions any) error {
return ValidateInteractiveActionsForWebhook(&Post{
Props: map[string]any{
PostPropsMmBlocks: blocks,
PostPropsMmBlocksActions: actions,
},
})
}