mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 18:15:47 +00:00
fix(message): strip embedded MEDIA: paths and add cross-tenant channel validation
Message tool sent raw MEDIA: paths to channels when LLMs embedded them in multi-line text instead of using standalone prefix. Also added tenant validation to prevent agents from sending to channels belonging to other tenants. - Extract embedded MEDIA: tokens from message text, resolve as media attachments, strip from visible content (keeps surrounding text) - Add ChannelTenantChecker callback to validate channel belongs to calling agent's tenant before any send path - Add ChannelTenantID() to channel Manager for tenant lookup - Fix sendMedia() missing ContentType on MediaAttachment - Fix .docx/.xlsx MIME types to proper OpenXML values
This commit is contained in:
1 parent
4c987aff69
commit
cfb587e171
5 files changed
+396
-8
No files matched your search
+4
-1
@@ -497,11 +497,14 @@ func runGateway() {
|
||||
// Channel manager
|
||||
channelMgr := channels.NewManager(msgBus)
|
||||
|
||||
// Wire channel sender on message tool (now that channelMgr exists)
|
||||
// Wire channel sender + tenant checker on message tool (now that channelMgr exists)
|
||||
if t, ok := toolsReg.Get("message"); ok {
|
||||
if cs, ok := t.(tools.ChannelSenderAware); ok {
|
||||
cs.SetChannelSender(channelMgr.SendToChannel)
|
||||
}
|
||||
if tc, ok := t.(tools.ChannelTenantCheckerAware); ok {
|
||||
tc.SetChannelTenantChecker(channelMgr.ChannelTenantID)
|
||||
}
|
||||
}
|
||||
// Wire group member lister on list_group_members tool
|
||||
if t, ok := toolsReg.Get("list_group_members"); ok {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
@@ -191,6 +193,22 @@ func (m *Manager) ChannelTypeForName(name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ChannelTenantID returns the tenant UUID for a channel instance.
|
||||
// Zero UUID means legacy/config-based channel (no tenant scope).
|
||||
// Returns (tenantID, exists).
|
||||
func (m *Manager) ChannelTenantID(channelName string) (uuid.UUID, bool) {
|
||||
m.mu.RLock()
|
||||
ch, ok := m.channels[channelName]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
if tc, ok := ch.(interface{ TenantID() uuid.UUID }); ok {
|
||||
return tc.TenantID(), true
|
||||
}
|
||||
return uuid.Nil, true // legacy channel without tenant scope
|
||||
}
|
||||
|
||||
// ListGroupMembers delegates to the channel's GroupMemberProvider if available.
|
||||
func (m *Manager) ListGroupMembers(ctx context.Context, channelName, chatID string) ([]GroupMember, error) {
|
||||
m.mu.RLock()
|
||||
|
||||
+147
-7
@@ -4,28 +4,38 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// embeddedMediaPattern matches "MEDIA:" followed by a non-whitespace path.
|
||||
// Duplicated from agent.mediaPathPattern to avoid tools→agent import cycle.
|
||||
var embeddedMediaPattern = regexp.MustCompile(`MEDIA:\S+`)
|
||||
|
||||
// MessageTool allows the agent to proactively send messages to channels.
|
||||
type MessageTool struct {
|
||||
workspace string
|
||||
restrict bool
|
||||
sender ChannelSender
|
||||
msgBus *bus.MessageBus
|
||||
workspace string
|
||||
restrict bool
|
||||
sender ChannelSender
|
||||
msgBus *bus.MessageBus
|
||||
tenantChecker ChannelTenantChecker
|
||||
}
|
||||
|
||||
func NewMessageTool(workspace string, restrict bool) *MessageTool {
|
||||
return &MessageTool{workspace: workspace, restrict: restrict}
|
||||
}
|
||||
|
||||
func (t *MessageTool) SetChannelSender(s ChannelSender) { t.sender = s }
|
||||
func (t *MessageTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b }
|
||||
func (t *MessageTool) SetChannelSender(s ChannelSender) { t.sender = s }
|
||||
func (t *MessageTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b }
|
||||
func (t *MessageTool) SetChannelTenantChecker(c ChannelTenantChecker) { t.tenantChecker = c }
|
||||
|
||||
func (t *MessageTool) Name() string { return "message" }
|
||||
func (t *MessageTool) Description() string {
|
||||
@@ -85,11 +95,35 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *Result
|
||||
return ErrorResult("target chat ID is required (no current chat in context)")
|
||||
}
|
||||
|
||||
// Tenant isolation: validate channel belongs to current tenant.
|
||||
if err := t.validateChannelTenant(ctx, channel, target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle MEDIA: prefix — send file as attachment instead of text.
|
||||
if filePath, ok := t.resolveMediaPath(ctx, message); ok {
|
||||
return t.sendMedia(ctx, channel, target, filePath)
|
||||
}
|
||||
|
||||
// Extract embedded MEDIA: paths from multi-line messages.
|
||||
// LLMs may include MEDIA: in conversational text rather than as a standalone prefix.
|
||||
message, embeddedMedia := t.extractEmbeddedMedia(ctx, message)
|
||||
|
||||
// If we found embedded media and bus is available, prefer bus path (supports media attachments).
|
||||
if len(embeddedMedia) > 0 && t.msgBus != nil {
|
||||
outMsg := bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: target,
|
||||
Content: message,
|
||||
Media: embeddedMedia,
|
||||
}
|
||||
if isGroupContext(ctx) {
|
||||
outMsg.Metadata = map[string]string{"group_id": target}
|
||||
}
|
||||
t.msgBus.PublishOutbound(outMsg)
|
||||
return SilentResult(fmt.Sprintf(`{"status":"sent","channel":"%s","target":"%s"}`, channel, target))
|
||||
}
|
||||
|
||||
// Prefer direct channel sender for immediate delivery.
|
||||
// For group chats, fall through to message bus which supports metadata.
|
||||
if t.sender != nil && !isGroupContext(ctx) {
|
||||
@@ -126,6 +160,33 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *Result
|
||||
return ErrorResult("no channel sender or message bus available")
|
||||
}
|
||||
|
||||
// validateChannelTenant checks the target channel belongs to the current tenant.
|
||||
// Returns an error Result if the send should be blocked, nil if allowed.
|
||||
func (t *MessageTool) validateChannelTenant(ctx context.Context, channel, target string) *Result {
|
||||
if t.tenantChecker == nil {
|
||||
return nil
|
||||
}
|
||||
chTenant, chExists := t.tenantChecker(channel)
|
||||
if !chExists {
|
||||
return ErrorResult(fmt.Sprintf("channel %q not found", channel))
|
||||
}
|
||||
// Allow: legacy/config-based channels (zero tenant) or master tenant context (system ops).
|
||||
if chTenant == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
ctxTenant := store.TenantIDFromContext(ctx)
|
||||
if ctxTenant == uuid.Nil {
|
||||
return nil // master tenant / system context
|
||||
}
|
||||
if chTenant != ctxTenant {
|
||||
slog.Warn("security.cross_tenant_send_blocked",
|
||||
"channel", channel, "target", target,
|
||||
"ctx_tenant", ctxTenant, "ch_tenant", chTenant)
|
||||
return ErrorResult("channel not accessible from this tenant")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMedia sends a file as a media attachment via the outbound message bus.
|
||||
func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath string) *Result {
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
@@ -144,7 +205,7 @@ func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath s
|
||||
t.msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: target,
|
||||
Media: []bus.MediaAttachment{{URL: filePath}},
|
||||
Media: []bus.MediaAttachment{{URL: filePath, ContentType: mimeFromPath(filePath)}},
|
||||
Metadata: meta,
|
||||
})
|
||||
out, _ := json.Marshal(map[string]string{
|
||||
@@ -156,6 +217,85 @@ func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath s
|
||||
return SilentResult(string(out))
|
||||
}
|
||||
|
||||
// extractEmbeddedMedia scans a multi-line message for embedded MEDIA: path references.
|
||||
// Returns cleaned text (MEDIA: lines removed) and resolved media attachments.
|
||||
// Prevents raw MEDIA: paths from leaking to channels when LLMs embed them
|
||||
// in conversational text instead of using a standalone MEDIA: prefix.
|
||||
func (t *MessageTool) extractEmbeddedMedia(ctx context.Context, message string) (string, []bus.MediaAttachment) {
|
||||
if !strings.Contains(message, "MEDIA:") {
|
||||
return message, nil
|
||||
}
|
||||
|
||||
lines := strings.Split(message, "\n")
|
||||
var cleaned []string
|
||||
var media []bus.MediaAttachment
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// Skip [[audio_as_voice]] tags (TTS voice messages).
|
||||
if strings.HasPrefix(trimmed, "[[audio_as_voice]]") {
|
||||
continue
|
||||
}
|
||||
// Find all MEDIA: tokens on this line.
|
||||
matches := embeddedMediaPattern.FindAllString(trimmed, -1)
|
||||
if len(matches) == 0 {
|
||||
cleaned = append(cleaned, line)
|
||||
continue
|
||||
}
|
||||
// Extract each MEDIA: path and resolve via security-checked path resolution.
|
||||
for _, raw := range matches {
|
||||
if resolved, ok := t.resolveMediaPath(ctx, raw); ok {
|
||||
media = append(media, bus.MediaAttachment{
|
||||
URL: resolved,
|
||||
ContentType: mimeFromPath(resolved),
|
||||
})
|
||||
}
|
||||
}
|
||||
// Strip MEDIA: tokens from line, keep surrounding text.
|
||||
remainder := strings.TrimSpace(embeddedMediaPattern.ReplaceAllString(line, ""))
|
||||
if remainder != "" {
|
||||
cleaned = append(cleaned, remainder)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Join(cleaned, "\n")), media
|
||||
}
|
||||
|
||||
// mimeFromPath returns a MIME type based on file extension.
|
||||
// Duplicated from agent.mimeFromExt to avoid tools→agent import cycle.
|
||||
func mimeFromPath(path string) string {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".ogg", ".opus":
|
||||
return "audio/ogg"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".wav":
|
||||
return "audio/wav"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".doc":
|
||||
return "application/msword"
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel"
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// isGroupContext returns true if the current context indicates a group conversation.
|
||||
func isGroupContext(ctx context.Context) bool {
|
||||
userID := store.UserIDFromContext(ctx)
|
||||
|
||||
@@ -5,6 +5,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// outsidePath returns an absolute path that is guaranteed to be outside the
|
||||
@@ -144,3 +148,214 @@ func TestIsInTempDir(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmbeddedMedia(t *testing.T) {
|
||||
tmpDir := os.TempDir()
|
||||
|
||||
workspace := t.TempDir()
|
||||
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
|
||||
|
||||
// Create test files in workspace.
|
||||
docsDir := filepath.Join(workspaceCanonical, "docs")
|
||||
os.MkdirAll(docsDir, 0o755)
|
||||
reportFile := filepath.Join(docsDir, "report.docx")
|
||||
os.WriteFile(reportFile, []byte("test"), 0o644)
|
||||
reportCanonical, _ := filepath.EvalSymlinks(reportFile)
|
||||
|
||||
tool := NewMessageTool(workspaceCanonical, true)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("no MEDIA: in message", func(t *testing.T) {
|
||||
msg := "Hello, here is your report!"
|
||||
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
|
||||
if cleaned != msg {
|
||||
t.Errorf("expected unchanged message, got %q", cleaned)
|
||||
}
|
||||
if len(media) != 0 {
|
||||
t.Errorf("expected no media, got %d", len(media))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("embedded MEDIA: in multi-line message", func(t *testing.T) {
|
||||
msg := "Here is the file:\nMEDIA:" + reportCanonical + "\nPlease download!"
|
||||
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
|
||||
|
||||
if cleaned != "Here is the file:\nPlease download!" {
|
||||
t.Errorf("unexpected cleaned text: %q", cleaned)
|
||||
}
|
||||
if len(media) != 1 {
|
||||
t.Fatalf("expected 1 media, got %d", len(media))
|
||||
}
|
||||
if media[0].URL != reportCanonical {
|
||||
t.Errorf("media URL = %q, want %q", media[0].URL, reportCanonical)
|
||||
}
|
||||
wantMime := "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
if media[0].ContentType != wantMime {
|
||||
t.Errorf("content type = %q, want %q", media[0].ContentType, wantMime)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MEDIA: mid-sentence keeps surrounding text", func(t *testing.T) {
|
||||
msg := "Here is your report MEDIA:" + reportCanonical + " please review"
|
||||
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
|
||||
|
||||
if cleaned != "Here is your report please review" {
|
||||
t.Errorf("surrounding text lost: %q", cleaned)
|
||||
}
|
||||
if len(media) != 1 {
|
||||
t.Fatalf("expected 1 media, got %d", len(media))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple MEDIA: on same line", func(t *testing.T) {
|
||||
img := filepath.Join(tmpDir, "photo.png")
|
||||
msg := "MEDIA:" + reportCanonical + " MEDIA:" + img
|
||||
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
|
||||
|
||||
if cleaned != "" {
|
||||
t.Errorf("expected empty cleaned text, got %q", cleaned)
|
||||
}
|
||||
if len(media) != 2 {
|
||||
t.Fatalf("expected 2 media from same line, got %d", len(media))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MEDIA: path outside workspace is stripped but no attachment", func(t *testing.T) {
|
||||
msg := "File:\nMEDIA:" + outsidePath(workspaceCanonical, "etc/passwd") + "\nDone"
|
||||
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
|
||||
|
||||
if cleaned != "File:\nDone" {
|
||||
t.Errorf("MEDIA: line not stripped: %q", cleaned)
|
||||
}
|
||||
if len(media) != 0 {
|
||||
t.Errorf("expected no media for outside-workspace path, got %d", len(media))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("message with only MEDIA: lines", func(t *testing.T) {
|
||||
msg := "MEDIA:" + reportCanonical
|
||||
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
|
||||
|
||||
if cleaned != "" {
|
||||
t.Errorf("expected empty cleaned text, got %q", cleaned)
|
||||
}
|
||||
if len(media) != 1 {
|
||||
t.Fatalf("expected 1 media, got %d", len(media))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audio_as_voice tag stripped", func(t *testing.T) {
|
||||
msg := "[[audio_as_voice]]\nMEDIA:" + filepath.Join(tmpDir, "voice.ogg") + "\nExtra text"
|
||||
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
|
||||
|
||||
if cleaned != "Extra text" {
|
||||
t.Errorf("unexpected cleaned text: %q", cleaned)
|
||||
}
|
||||
if len(media) != 1 {
|
||||
t.Fatalf("expected 1 media, got %d", len(media))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple MEDIA: paths", func(t *testing.T) {
|
||||
img := filepath.Join(tmpDir, "photo.png")
|
||||
msg := "Files:\nMEDIA:" + reportCanonical + "\nMEDIA:" + img + "\nEnjoy!"
|
||||
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
|
||||
|
||||
if cleaned != "Files:\nEnjoy!" {
|
||||
t.Errorf("unexpected cleaned text: %q", cleaned)
|
||||
}
|
||||
if len(media) != 2 {
|
||||
t.Fatalf("expected 2 media, got %d", len(media))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMimeFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/tmp/file.png", "image/png"},
|
||||
{"/tmp/file.jpg", "image/jpeg"},
|
||||
{"/tmp/file.mp4", "video/mp4"},
|
||||
{"/tmp/file.ogg", "audio/ogg"},
|
||||
{"/tmp/file.pdf", "application/pdf"},
|
||||
{"/tmp/file.doc", "application/msword"},
|
||||
{"/tmp/file.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
{"/tmp/file.xls", "application/vnd.ms-excel"},
|
||||
{"/tmp/file.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
|
||||
{"/tmp/file.unknown", "application/octet-stream"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(filepath.Base(tt.path), func(t *testing.T) {
|
||||
if got := mimeFromPath(tt.path); got != tt.want {
|
||||
t.Errorf("mimeFromPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChannelTenant(t *testing.T) {
|
||||
tenantA := uuid.MustParse("0193a5b0-7000-7000-8000-000000000001")
|
||||
tenantB := uuid.MustParse("019d1135-7087-7aa9-a2c7-cdaf7af851b1")
|
||||
|
||||
tool := NewMessageTool("", true)
|
||||
|
||||
t.Run("no checker configured allows all", func(t *testing.T) {
|
||||
ctx := store.WithTenantID(context.Background(), tenantA)
|
||||
if err := tool.validateChannelTenant(ctx, "telegram", "123"); err != nil {
|
||||
t.Errorf("expected nil, got error: %s", err.ForLLM)
|
||||
}
|
||||
})
|
||||
|
||||
// Wire a mock checker.
|
||||
channels := map[string]uuid.UUID{
|
||||
"telegram": tenantA,
|
||||
"tenant-b-tg": tenantB,
|
||||
}
|
||||
tool.SetChannelTenantChecker(func(name string) (uuid.UUID, bool) {
|
||||
tid, ok := channels[name]
|
||||
return tid, ok
|
||||
})
|
||||
|
||||
t.Run("same tenant allows", func(t *testing.T) {
|
||||
ctx := store.WithTenantID(context.Background(), tenantA)
|
||||
if err := tool.validateChannelTenant(ctx, "telegram", "123"); err != nil {
|
||||
t.Errorf("expected nil for same tenant, got: %s", err.ForLLM)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross tenant blocks", func(t *testing.T) {
|
||||
ctx := store.WithTenantID(context.Background(), tenantA)
|
||||
err := tool.validateChannelTenant(ctx, "tenant-b-tg", "456")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cross-tenant send, got nil")
|
||||
}
|
||||
if !err.IsError {
|
||||
t.Error("expected IsError=true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("channel not found blocks", func(t *testing.T) {
|
||||
ctx := store.WithTenantID(context.Background(), tenantA)
|
||||
err := tool.validateChannelTenant(ctx, "nonexistent", "789")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing channel, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil channel tenant allows (legacy)", func(t *testing.T) {
|
||||
channels["legacy-ch"] = uuid.Nil
|
||||
ctx := store.WithTenantID(context.Background(), tenantA)
|
||||
if err := tool.validateChannelTenant(ctx, "legacy-ch", "123"); err != nil {
|
||||
t.Errorf("expected nil for legacy channel, got: %s", err.ForLLM)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil context tenant allows (master/system)", func(t *testing.T) {
|
||||
ctx := context.Background() // no tenant in context
|
||||
if err := tool.validateChannelTenant(ctx, "tenant-b-tg", "456"); err != nil {
|
||||
t.Errorf("expected nil for master context, got: %s", err.ForLLM)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -99,6 +101,16 @@ type ChannelSenderAware interface {
|
||||
SetChannelSender(ChannelSender)
|
||||
}
|
||||
|
||||
// ChannelTenantChecker returns the tenant UUID for a channel instance.
|
||||
// Used by the message tool to prevent cross-tenant sends.
|
||||
// Returns (tenantID, exists). Zero tenantID means legacy/config-based channel.
|
||||
type ChannelTenantChecker func(channelName string) (tenantID uuid.UUID, exists bool)
|
||||
|
||||
// ChannelTenantCheckerAware tools can receive a channel tenant checker.
|
||||
type ChannelTenantCheckerAware interface {
|
||||
SetChannelTenantChecker(ChannelTenantChecker)
|
||||
}
|
||||
|
||||
// ChannelAware is optionally implemented by tools that only work on specific channel types.
|
||||
// Tools implementing this are filtered out when the current channel type doesn't match.
|
||||
type ChannelAware interface {
|
||||
|
||||
Reference in new issue
Block a user