mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
fix(message): gate local media attachments
This commit is contained in:
@@ -37,14 +37,15 @@ type sentTarget struct {
|
||||
}
|
||||
|
||||
type MessageTool struct {
|
||||
sendCallback SendCallbackWithContext
|
||||
workspace string
|
||||
restrict bool
|
||||
maxFileSize int
|
||||
mediaStore media.MediaStore
|
||||
allowPaths []*regexp.Regexp
|
||||
mu sync.Mutex
|
||||
sentTargets map[string][]sentTarget
|
||||
sendCallback SendCallbackWithContext
|
||||
workspace string
|
||||
restrict bool
|
||||
maxFileSize int
|
||||
mediaStore media.MediaStore
|
||||
allowPaths []*regexp.Regexp
|
||||
localMediaEnabled bool
|
||||
mu sync.Mutex
|
||||
sentTargets map[string][]sentTarget
|
||||
}
|
||||
|
||||
func NewMessageTool() *MessageTool {
|
||||
@@ -58,57 +59,66 @@ func (t *MessageTool) Name() string {
|
||||
}
|
||||
|
||||
func (t *MessageTool) Description() string {
|
||||
if !t.localMediaEnabled {
|
||||
return "Send a text message to the user on a chat channel."
|
||||
}
|
||||
return "Send a message to the user on a chat channel. Supports text-only, media-only, or text with media attachments."
|
||||
}
|
||||
|
||||
func (t *MessageTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional message text. When media is present, this text is used as the caption/body for the media message.",
|
||||
},
|
||||
"media": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Optional local media attachments to send with the message.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the local file. Relative paths are resolved from workspace.",
|
||||
},
|
||||
"type": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional media type hint: image, audio, video, or file.",
|
||||
},
|
||||
"filename": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional display filename. Defaults to the basename of path.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
"channel": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: target channel (telegram, whatsapp, etc.)",
|
||||
},
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: target chat/user ID",
|
||||
},
|
||||
"reply_to_message_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: reply target message ID for channels that support threaded replies",
|
||||
},
|
||||
properties := map[string]any{
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional message text. When media is present, this text is used as the caption/body for the media message.",
|
||||
},
|
||||
"anyOf": []map[string]any{
|
||||
{"required": []string{"content"}},
|
||||
{"required": []string{"media"}},
|
||||
"channel": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: target channel (telegram, whatsapp, etc.)",
|
||||
},
|
||||
"chat_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: target chat/user ID",
|
||||
},
|
||||
"reply_to_message_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional: reply target message ID for channels that support threaded replies",
|
||||
},
|
||||
}
|
||||
params := map[string]any{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": []string{"content"},
|
||||
}
|
||||
if t.localMediaEnabled {
|
||||
properties["media"] = map[string]any{
|
||||
"type": "array",
|
||||
"description": "Optional local media attachments to send with the message. Requires tools.message.media_enabled.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the local file. Relative paths are resolved from workspace.",
|
||||
},
|
||||
"type": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional media type hint: image, audio, video, or file.",
|
||||
},
|
||||
"filename": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional display filename. Defaults to the basename of path.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
}
|
||||
delete(params, "required")
|
||||
params["anyOf"] = []map[string]any{
|
||||
{"required": []string{"content"}},
|
||||
{"required": []string{"media"}},
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (t *MessageTool) ConfigureLocalMedia(
|
||||
@@ -124,6 +134,7 @@ func (t *MessageTool) ConfigureLocalMedia(
|
||||
}
|
||||
t.maxFileSize = maxFileSize
|
||||
t.allowPaths = allowPaths
|
||||
t.localMediaEnabled = true
|
||||
}
|
||||
|
||||
func (t *MessageTool) SetMediaStore(store media.MediaStore) {
|
||||
@@ -173,6 +184,12 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||
if err != nil {
|
||||
return &ToolResult{ForLLM: err.Error(), IsError: true}
|
||||
}
|
||||
if len(mediaArgs) > 0 && !t.localMediaEnabled {
|
||||
return &ToolResult{
|
||||
ForLLM: "message media attachments are disabled; enable tools.message.media_enabled to send local media through message",
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
if content == "" && len(mediaArgs) == 0 {
|
||||
return &ToolResult{ForLLM: "content or media is required", IsError: true}
|
||||
}
|
||||
@@ -262,6 +279,9 @@ func (t *MessageTool) buildMediaParts(
|
||||
if len(mediaArgs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if !t.localMediaEnabled {
|
||||
return nil, fmt.Errorf("message media attachments are disabled")
|
||||
}
|
||||
if t.mediaStore == nil {
|
||||
return nil, fmt.Errorf("media store not configured")
|
||||
}
|
||||
|
||||
@@ -250,9 +250,9 @@ func TestMessageTool_Parameters(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check required properties
|
||||
anyOf, ok := params["anyOf"].([]map[string]any)
|
||||
if !ok || len(anyOf) != 2 {
|
||||
t.Fatal("Expected anyOf content/media requirement")
|
||||
required, ok := params["required"].([]string)
|
||||
if !ok || len(required) != 1 || required[0] != "content" {
|
||||
t.Fatal("Expected content-only required schema when local media is disabled")
|
||||
}
|
||||
|
||||
// Check content property
|
||||
@@ -264,12 +264,8 @@ func TestMessageTool_Parameters(t *testing.T) {
|
||||
t.Error("Expected content type to be 'string'")
|
||||
}
|
||||
|
||||
mediaProp, ok := props["media"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected 'media' property")
|
||||
}
|
||||
if mediaProp["type"] != "array" {
|
||||
t.Error("Expected media type to be 'array'")
|
||||
if _, hasMedia := props["media"]; hasMedia {
|
||||
t.Fatal("did not expect 'media' property when local media is disabled")
|
||||
}
|
||||
|
||||
// Check channel property (optional)
|
||||
@@ -300,6 +296,56 @@ func TestMessageTool_Parameters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Parameters_WithLocalMediaEnabled(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
tool.ConfigureLocalMedia(t.TempDir(), true, 1024*1024, nil)
|
||||
params := tool.Parameters()
|
||||
|
||||
props, ok := params["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected properties to be a map")
|
||||
}
|
||||
mediaProp, ok := props["media"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected 'media' property")
|
||||
}
|
||||
if mediaProp["type"] != "array" {
|
||||
t.Error("Expected media type to be 'array'")
|
||||
}
|
||||
anyOf, ok := params["anyOf"].([]map[string]any)
|
||||
if !ok || len(anyOf) != 2 {
|
||||
t.Fatal("Expected anyOf content/media requirement")
|
||||
}
|
||||
if _, ok := params["required"]; ok {
|
||||
t.Fatal("did not expect top-level required content when media is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_WithMediaDisabled(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
tool.SetSendCallback(func(
|
||||
ctx context.Context,
|
||||
channel, chatID, content, replyToMessageID string,
|
||||
mediaParts []bus.MediaPart,
|
||||
) error {
|
||||
t.Fatal("send callback should not run when message media is disabled")
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := WithToolContext(context.Background(), "telegram", "-1001")
|
||||
result := tool.Execute(ctx, map[string]any{
|
||||
"media": []any{
|
||||
map[string]any{"path": "photo.jpg"},
|
||||
},
|
||||
})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error when message media is disabled")
|
||||
}
|
||||
if result.ForLLM != "message media attachments are disabled; enable tools.message.media_enabled to send local media through message" {
|
||||
t.Fatalf("unexpected error: %q", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user