diff --git a/README.md b/README.md index 94dda36..4d47528 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ The variables below cover both deployment modes. Credentials variables (`GREEN_A - `GREEN_API_TRANSPORT` — transport: `stdio`, `sse`, `http`, or `hybrid` - `GREEN_API_PORT` — HTTP port (defaults to `8090`) - `GREEN_API_BASE_URL` — public base URL (used for OAuth issuer/redirects, e.g. `https://mcp.example.com`) +- `GREEN_API_WIDGET_DOMAIN` — unique widget origin for ChatGPT Apps submission metadata (defaults to `GREEN_API_BASE_URL`, then `https://mcp.green-api.com`) - `GREEN_API_AUTH_MODE` — `config` or `proxy` - `GREEN_API_AUTH_CACHE_TTL` — proxy-auth credential cache TTL (seconds) - `GREEN_API_WEBHOOK_MODE` — `polling` or `receiver` diff --git a/internal/infrastructure/mcp/resources.go b/internal/infrastructure/mcp/resources.go index e1639b0..2e195f7 100644 --- a/internal/infrastructure/mcp/resources.go +++ b/internal/infrastructure/mcp/resources.go @@ -6,6 +6,8 @@ package mcp import ( "context" "fmt" + "os" + "strings" "github.com/green-api/green-api-mcp-gateway/internal/domain" "github.com/green-api/green-api-mcp-gateway/internal/infrastructure" @@ -24,14 +26,18 @@ import ( // - whatsapp://instance/{id}/settings — settings of an instance func registerResources(s *Server) { // ui://qr — MCP App widget for instance authorization via QR code + qrMeta := widgetResourceMeta("Interactive QR code widget for authorizing a GREEN-API instance", []string{}) + qrResource := mcp.NewResource("ui://qr", "QR Code Widget", + mcp.WithMIMEType("text/html;profile=mcp-app"), + mcp.WithResourceDescription("Interactive QR code widget for authorizing a GREEN-API instance"), + ) + qrResource.Meta = mcp.NewMetaFromMap(qrMeta) s.mcp.AddResource( - mcp.NewResource("ui://qr", "QR Code Widget", - mcp.WithMIMEType("text/html;profile=mcp-app"), - mcp.WithResourceDescription("Interactive QR code widget for authorizing a GREEN-API instance"), - ), + qrResource, mcpgo.ResourceHandlerFunc(func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { return []mcp.ResourceContents{ mcp.TextResourceContents{ + Meta: qrMeta, URI: "ui://qr", MIMEType: "text/html;profile=mcp-app", Text: infrastructure.QRAppHTML, @@ -41,14 +47,18 @@ func registerResources(s *Server) { ) // ui://contacts — MCP App widget for browsing the contacts list + contactsMeta := widgetResourceMeta("Interactive contacts list with search and contact details", []string{"https://pps.whatsapp.net"}) + contactsResource := mcp.NewResource("ui://contacts", "Contacts List Widget", + mcp.WithMIMEType("text/html;profile=mcp-app"), + mcp.WithResourceDescription("Interactive contacts list with search and contact details"), + ) + contactsResource.Meta = mcp.NewMetaFromMap(contactsMeta) s.mcp.AddResource( - mcp.NewResource("ui://contacts", "Contacts List Widget", - mcp.WithMIMEType("text/html;profile=mcp-app"), - mcp.WithResourceDescription("Interactive contacts list with search and contact details"), - ), + contactsResource, mcpgo.ResourceHandlerFunc(func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { return []mcp.ResourceContents{ mcp.TextResourceContents{ + Meta: contactsMeta, URI: "ui://contacts", MIMEType: "text/html;profile=mcp-app", Text: infrastructure.ContactsAppHTML, @@ -115,6 +125,42 @@ func registerResources(s *Server) { ) } +func widgetResourceMeta(description string, resourceDomains []string) map[string]any { + domain := strings.TrimRight(os.Getenv("GREEN_API_WIDGET_DOMAIN"), "/") + if domain == "" { + domain = strings.TrimRight(os.Getenv("GREEN_API_BASE_URL"), "/") + } + if domain == "" { + domain = "https://mcp.green-api.com" + } + + apiURL := strings.TrimRight(os.Getenv("GREEN_API_URL"), "/") + if apiURL == "" { + apiURL = "https://api.green-api.com" + } + connectDomains := []string{apiURL} + standardCSP := map[string]any{ + "connectDomains": connectDomains, + "resourceDomains": resourceDomains, + } + legacyCSP := map[string]any{ + "connect_domains": connectDomains, + "resource_domains": resourceDomains, + } + + return map[string]any{ + "ui": map[string]any{ + "prefersBorder": true, + "csp": standardCSP, + "domain": domain, + }, + "openai/widgetDescription": description, + "openai/widgetPrefersBorder": true, + "openai/widgetCSP": legacyCSP, + "openai/widgetDomain": domain, + } +} + // extractInstanceID parses the {id} variable from a resource URI template match. // The mcp-go library populates req.Params.Arguments with matched template variables. func extractInstanceID(req mcp.ReadResourceRequest) (uint64, error) { diff --git a/internal/infrastructure/mcp/server.go b/internal/infrastructure/mcp/server.go index 02d3204..57d4c11 100644 --- a/internal/infrastructure/mcp/server.go +++ b/internal/infrastructure/mcp/server.go @@ -88,6 +88,9 @@ func resolveToolIcon() *mcp.Icon { // addTool registers a tool and wraps its handler with rate limiting + metrics instrumentation. // It automatically injects the GREEN-API icon into the tool metadata when configured. func (s *Server) addTool(tool mcp.Tool, handler mcpgo.ToolHandlerFunc) { + applySubmissionReviewHints(&tool) + applyToolTitle(&tool) + applyWidgetToolMeta(&tool) if s.toolIcon != nil { tool.Icons = append(tool.Icons, *s.toolIcon) } @@ -126,6 +129,132 @@ func (s *Server) addTool(tool mcp.Tool, handler mcpgo.ToolHandlerFunc) { })) } +func applySubmissionReviewHints(tool *mcp.Tool) { + readOnlyTools := map[string]bool{ + "whatsapp_check_whatsapp": true, + "whatsapp_get_authorization_code": true, + "whatsapp_get_chat_history": true, + "whatsapp_get_contact_avatar": true, + "whatsapp_get_contact_info": true, + "whatsapp_get_contacts": true, + "whatsapp_get_group_data": true, + "whatsapp_get_instances": true, + "whatsapp_get_message": true, + "whatsapp_get_qr": true, + "whatsapp_get_settings": true, + "whatsapp_get_state": true, + "whatsapp_get_wa_settings": true, + "whatsapp_last_incoming_messages": true, + "whatsapp_last_outgoing_messages": true, + "whatsapp_receive_notification": true, + } + localOnlyTools := map[string]bool{ + "whatsapp_connect": true, + "whatsapp_disconnect": true, + } + destructiveTools := map[string]bool{ + "whatsapp_delete_instance": true, + "whatsapp_delete_message": true, + "whatsapp_delete_notification": true, + "whatsapp_edit_message": true, + "whatsapp_forward_messages": true, + "whatsapp_leave_group": true, + "whatsapp_logout": true, + "whatsapp_remove_group_admin": true, + "whatsapp_remove_group_participant": true, + "whatsapp_send_contact": true, + "whatsapp_send_file": true, + "whatsapp_send_file_by_upload": true, + "whatsapp_send_location": true, + "whatsapp_send_message": true, + "whatsapp_send_poll": true, + "whatsapp_set_settings": true, + } + + readOnly := readOnlyTools[tool.Name] + openWorld := !readOnly && !localOnlyTools[tool.Name] + destructive := destructiveTools[tool.Name] + + tool.Annotations.ReadOnlyHint = &readOnly + tool.Annotations.OpenWorldHint = &openWorld + tool.Annotations.DestructiveHint = &destructive + if readOnly { + idempotent := true + tool.Annotations.IdempotentHint = &idempotent + } +} + +var toolTitles = map[string]string{ + "whatsapp_connect": "Connect Instance", + "whatsapp_disconnect": "Disconnect Instance", + "whatsapp_send_message": "Send Message", + "whatsapp_send_file": "Send File by URL", + "whatsapp_upload_file": "Upload File", + "whatsapp_send_file_by_upload": "Send File", + "whatsapp_send_location": "Send Location", + "whatsapp_send_contact": "Send Contact", + "whatsapp_send_poll": "Send Poll", + "whatsapp_forward_messages": "Forward Messages", + "whatsapp_edit_message": "Edit Message", + "whatsapp_delete_message": "Delete Message", + "whatsapp_get_state": "Get Instance State", + "whatsapp_get_settings": "Get Instance Settings", + "whatsapp_set_settings": "Update Instance Settings", + "whatsapp_get_qr": "Get QR Code", + "whatsapp_check_whatsapp": "Check WhatsApp Number", + "whatsapp_get_contacts": "Get Contacts", + "whatsapp_get_contact_info": "Get Contact Info", + "whatsapp_receive_notification": "Receive Notification", + "whatsapp_create_group": "Create Group", + "whatsapp_get_group_data": "Get Group Data", + "whatsapp_add_group_participant": "Add Group Participant", + "whatsapp_remove_group_participant": "Remove Group Participant", + "whatsapp_reboot": "Reboot Instance", + "whatsapp_logout": "Logout Instance", + "whatsapp_get_authorization_code": "Get Authorization Code", + "whatsapp_get_wa_settings": "Get WhatsApp Account Settings", + "whatsapp_get_chat_history": "Get Chat History", + "whatsapp_get_message": "Get Message", + "whatsapp_last_incoming_messages": "Get Recent Incoming Messages", + "whatsapp_last_outgoing_messages": "Get Recent Outgoing Messages", + "whatsapp_read_chat": "Mark Chat as Read", + "whatsapp_delete_notification": "Delete Notification", + "whatsapp_set_group_admin": "Promote Group Admin", + "whatsapp_remove_group_admin": "Demote Group Admin", + "whatsapp_leave_group": "Leave Group", + "whatsapp_get_contact_avatar": "Get Contact Avatar", + "whatsapp_create_instance": "Create Partner Instance", + "whatsapp_delete_instance": "Delete Partner Instance", + "whatsapp_get_instances": "List Partner Instances", +} + +func applyToolTitle(tool *mcp.Tool) { + if title, ok := toolTitles[tool.Name]; ok && tool.Annotations.Title == "" { + tool.Annotations.Title = title + } +} + +func applyWidgetToolMeta(tool *mcp.Tool) { + resourceURIByTool := map[string]string{ + "whatsapp_get_contacts": "ui://contacts", + "whatsapp_get_qr": "ui://qr", + } + resourceURI, ok := resourceURIByTool[tool.Name] + if !ok { + return + } + + fields := map[string]any{} + if tool.Meta != nil { + for key, value := range tool.Meta.AdditionalFields { + fields[key] = value + } + } + fields["ui"] = map[string]any{"resourceUri": resourceURI} + fields["openai/outputTemplate"] = resourceURI + tool.Meta = mcp.NewMetaFromMap(fields) +} + // ServeStdio runs the MCP server over stdio transport (blocking). func (s *Server) ServeStdio(ctx context.Context) error { stdioSrv := mcpgo.NewStdioServer(s.mcp) diff --git a/internal/infrastructure/mcp/tools.go b/internal/infrastructure/mcp/tools.go index 8d475f8..6d5c343 100644 --- a/internal/infrastructure/mcp/tools.go +++ b/internal/infrastructure/mcp/tools.go @@ -624,15 +624,6 @@ func registerTools(s *Server) { ) // whatsapp_get_qr - qrTool := mcp.NewTool("whatsapp_get_qr", - mcp.WithDescription("Get QR code for instance authorization. Returns the QR image inline \u2014 scan it in your messenger app to link the instance."), - mcp.WithNumber("instance_id", mcp.Description("Instance ID (not required when authenticated via OAuth)")), - ) - qrTool.Meta = &mcp.Meta{ - AdditionalFields: map[string]any{ - "ui": map[string]any{"resourceUri": "ui://qr"}, - }, - } s.addTool( mcp.NewTool("whatsapp_get_qr", mcp.WithDescription("Get the QR code for instance authorization"), @@ -657,11 +648,13 @@ func registerTools(s *Server) { small = qr.Message // fallback to original } res := mcp.NewToolResultImage("Scan this QR code to authorize the instance", small, "image/png") - res.Meta = &mcp.Meta{AdditionalFields: map[string]any{"ui": map[string]any{"resourceUri": "ui://qr"}}} + res.Meta = qrToolResultMeta(small, "image/png") + res.StructuredContent = map[string]any{"type": "qrCode", "message": small, "mimeType": "image/png"} return res, nil } res := mcp.NewToolResultText(fmt.Sprintf(`{"type":%q,"message":%q}`, qr.Type, qr.Message)) res.Meta = &mcp.Meta{AdditionalFields: map[string]any{"ui": map[string]any{"resourceUri": "ui://qr"}}} + res.StructuredContent = map[string]any{"type": qr.Type, "message": qr.Message} return res, nil }), ) @@ -695,15 +688,6 @@ func registerTools(s *Server) { ) // whatsapp_get_contacts - contactsTool := mcp.NewTool("whatsapp_get_contacts", - mcp.WithDescription("Get the contact list"), - mcp.WithNumber("instance_id", mcp.Description("Instance ID (not required when authenticated via OAuth)")), - ) - contactsTool.Meta = &mcp.Meta{ - AdditionalFields: map[string]any{ - "ui": map[string]any{"resourceUri": "ui://contacts"}, - }, - } s.addTool( mcp.NewTool("whatsapp_get_contacts", mcp.WithDescription("Get the contact list"), @@ -1012,10 +996,10 @@ func registerTools(s *Server) { // whatsapp_get_chat_history s.addTool( mcp.NewTool("whatsapp_get_chat_history", - mcp.WithDescription("Get chat message history"), + mcp.WithDescription("Get chat message history. Use a small 'count' (default 50, max 100) to keep responses focused."), mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")), mcp.WithString("chat_id", mcp.Required(), mcp.Description("Chat ID (79001234567@c.us or 120363XXX@g.us)")), - mcp.WithNumber("count", mcp.Description("Number of messages (default 100)")), + mcp.WithNumber("count", mcp.Description("Number of messages (default 50, max 100)")), ), mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { instanceID, err := resolveInstanceID(ctx, req) @@ -1026,7 +1010,13 @@ func registerTools(s *Server) { if err != nil { return mcp.NewToolResultError(err.Error()), nil } - count := int(req.GetFloat("count", 100)) + count := int(req.GetFloat("count", 50)) + if count < 1 { + count = 50 + } + if count > 100 { + count = 100 + } body := domain.GetChatHistoryRequest{ChatID: chatID, Count: count} result, err := s.client.GetChatHistory(ctx, instanceID, body) if err != nil { @@ -1069,16 +1059,22 @@ func registerTools(s *Server) { // whatsapp_last_incoming_messages s.addTool( mcp.NewTool("whatsapp_last_incoming_messages", - mcp.WithDescription("Get recent incoming messages"), + mcp.WithDescription("Get recent incoming messages from the last N minutes (default 60, max 1440 = 24 hours)."), mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")), - mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 1440 = 24 hours)")), + mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 60, max 1440)")), ), mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { instanceID, err := resolveInstanceID(ctx, req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - minutes := int(req.GetFloat("minutes", 1440)) + minutes := int(req.GetFloat("minutes", 60)) + if minutes < 1 { + minutes = 60 + } + if minutes > 1440 { + minutes = 1440 + } result, err := s.client.LastIncomingMessages(ctx, instanceID, minutes) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -1090,16 +1086,22 @@ func registerTools(s *Server) { // whatsapp_last_outgoing_messages s.addTool( mcp.NewTool("whatsapp_last_outgoing_messages", - mcp.WithDescription("Get recent outgoing messages"), + mcp.WithDescription("Get recent outgoing messages from the last N minutes (default 60, max 1440 = 24 hours)."), mcp.WithNumber("instance_id", mcp.Required(), mcp.Description("WhatsApp instance ID")), - mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 1440 = 24 hours)")), + mcp.WithNumber("minutes", mcp.Description("Time window in minutes (default 60, max 1440)")), ), mcpgo.ToolHandlerFunc(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { instanceID, err := resolveInstanceID(ctx, req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - minutes := int(req.GetFloat("minutes", 1440)) + minutes := int(req.GetFloat("minutes", 60)) + if minutes < 1 { + minutes = 60 + } + if minutes > 1440 { + minutes = 1440 + } result, err := s.client.LastOutgoingMessages(ctx, instanceID, minutes) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -1353,6 +1355,17 @@ func marshalJSON(v any) ([]byte, error) { return json.Marshal(v) } +func qrToolResultMeta(base64, mimeType string) *mcp.Meta { + return &mcp.Meta{AdditionalFields: map[string]any{ + "ui": map[string]any{"resourceUri": "ui://qr"}, + "qr": map[string]any{ + "type": "qrCode", + "message": base64, + "mimeType": mimeType, + }, + }} +} + // resolveInstanceID returns the instance ID for a tool call. // Priority: explicit argument > authenticated context (OAuth/proxy mode). // This allows instance_id to be omitted when the client authenticated via OAuth. diff --git a/internal/infrastructure/mcp/tools_test.go b/internal/infrastructure/mcp/tools_test.go new file mode 100644 index 0000000..1d1d320 --- /dev/null +++ b/internal/infrastructure/mcp/tools_test.go @@ -0,0 +1,146 @@ +package mcp + +import ( + "testing" + + "github.com/green-api/green-api-mcp-gateway/internal/infrastructure" + mcpgo "github.com/mark3labs/mcp-go/server" +) + +func TestRegisteredToolsHaveSubmissionReviewHints(t *testing.T) { + server := NewServer(infrastructure.NewCredentialManager(), nil, nil, nil, "test") + tools := server.mcp.ListTools() + if len(tools) == 0 { + t.Fatal("expected registered tools") + } + + for name, entry := range tools { + annotations := entry.Tool.Annotations + if annotations.ReadOnlyHint == nil { + t.Errorf("%s missing readOnlyHint", name) + } + if annotations.OpenWorldHint == nil { + t.Errorf("%s missing openWorldHint", name) + } + if annotations.DestructiveHint == nil { + t.Errorf("%s missing destructiveHint", name) + } + } + + assertToolHints(t, tools, "whatsapp_get_contacts", true, false, false) + assertToolHints(t, tools, "whatsapp_send_message", false, true, true) + assertToolHints(t, tools, "whatsapp_delete_message", false, true, true) + assertToolHints(t, tools, "whatsapp_set_settings", false, true, true) + assertToolHints(t, tools, "whatsapp_disconnect", false, false, false) +} + +func TestWidgetToolsExposeResourceTemplateMeta(t *testing.T) { + server := NewServer(infrastructure.NewCredentialManager(), nil, nil, nil, "test") + tools := server.mcp.ListTools() + + assertToolResourceURI(t, tools, "whatsapp_get_contacts", "ui://contacts") + assertToolResourceURI(t, tools, "whatsapp_get_qr", "ui://qr") +} + +func assertToolHints(t *testing.T, tools map[string]*mcpgo.ServerTool, name string, readOnly, openWorld, destructive bool) { + t.Helper() + + entry, ok := tools[name] + if !ok { + t.Fatalf("tool %s is not registered", name) + } + annotations := entry.Tool.Annotations + if annotations.ReadOnlyHint == nil || *annotations.ReadOnlyHint != readOnly { + t.Errorf("%s readOnlyHint = %s, want %v", name, boolPtrString(annotations.ReadOnlyHint), readOnly) + } + if annotations.OpenWorldHint == nil || *annotations.OpenWorldHint != openWorld { + t.Errorf("%s openWorldHint = %s, want %v", name, boolPtrString(annotations.OpenWorldHint), openWorld) + } + if annotations.DestructiveHint == nil || *annotations.DestructiveHint != destructive { + t.Errorf("%s destructiveHint = %s, want %v", name, boolPtrString(annotations.DestructiveHint), destructive) + } +} + +func boolPtrString(value *bool) string { + if value == nil { + return "" + } + if *value { + return "true" + } + return "false" +} + +func assertToolResourceURI(t *testing.T, tools map[string]*mcpgo.ServerTool, name, want string) { + t.Helper() + + entry, ok := tools[name] + if !ok { + t.Fatalf("tool %s is not registered", name) + } + if entry.Tool.Meta == nil { + t.Fatalf("%s missing _meta", name) + } + ui, ok := entry.Tool.Meta.AdditionalFields["ui"].(map[string]any) + if !ok { + t.Fatalf("%s missing _meta.ui", name) + } + if got := ui["resourceUri"]; got != want { + t.Fatalf("%s _meta.ui.resourceUri = %v, want %q", name, got, want) + } + if got := entry.Tool.Meta.AdditionalFields["openai/outputTemplate"]; got != want { + t.Fatalf("%s _meta[openai/outputTemplate] = %v, want %q", name, got, want) + } +} + +func TestWidgetResourceMetaHasSubmissionCSPAndDomain(t *testing.T) { + t.Setenv("GREEN_API_WIDGET_DOMAIN", "https://widgets.green-api.example") + + meta := widgetResourceMeta("Contacts widget", []string{"https://pps.whatsapp.net"}) + + ui, ok := meta["ui"].(map[string]any) + if !ok { + t.Fatalf("ui metadata missing or wrong type: %#v", meta["ui"]) + } + if got := ui["domain"]; got != "https://widgets.green-api.example" { + t.Fatalf("ui.domain = %v, want %q", got, "https://widgets.green-api.example") + } + + csp, ok := ui["csp"].(map[string]any) + if !ok { + t.Fatalf("ui.csp missing or wrong type: %#v", ui["csp"]) + } + assertStringSlice(t, csp["connectDomains"], []string{"https://api.green-api.com"}) + assertStringSlice(t, csp["resourceDomains"], []string{"https://pps.whatsapp.net"}) + + legacyCSP, ok := meta["openai/widgetCSP"].(map[string]any) + if !ok { + t.Fatalf("openai/widgetCSP missing or wrong type: %#v", meta["openai/widgetCSP"]) + } + assertStringSlice(t, legacyCSP["connect_domains"], []string{"https://api.green-api.com"}) + assertStringSlice(t, legacyCSP["resource_domains"], []string{"https://pps.whatsapp.net"}) + + if got := meta["openai/widgetDomain"]; got != "https://widgets.green-api.example" { + t.Fatalf("openai/widgetDomain = %v, want %q", got, "https://widgets.green-api.example") + } + if got := meta["openai/widgetDescription"]; got != "Contacts widget" { + t.Fatalf("openai/widgetDescription = %v, want %q", got, "Contacts widget") + } +} + +func assertStringSlice(t *testing.T, got any, want []string) { + t.Helper() + + slice, ok := got.([]string) + if !ok { + t.Fatalf("value = %#v, want []string", got) + } + if len(slice) != len(want) { + t.Fatalf("len = %d, want %d for %#v", len(slice), len(want), slice) + } + for i := range want { + if slice[i] != want[i] { + t.Fatalf("slice[%d] = %q, want %q", i, slice[i], want[i]) + } + } +} diff --git a/internal/infrastructure/templates/qr_app.html b/internal/infrastructure/templates/qr_app.html index ec9ca17..7845f71 100644 --- a/internal/infrastructure/templates/qr_app.html +++ b/internal/infrastructure/templates/qr_app.html @@ -233,35 +233,70 @@ function handleNotification(method, params) { if (method === 'ui/notifications/tool-input') { hostConnected = true; - const args = params.arguments ?? {}; - if (args.instance_id != null) { - instanceId = args.instance_id; - document.getElementById('instance-badge').textContent = '#' + instanceId; - } + setInstanceId(extractInstanceId(params)); } if (method === 'ui/notifications/tool-result') { hostConnected = true; - const content = params.content ?? []; - - // Try image content first (legacy) - const img = content.find(c => c.type === 'image'); - if (img) { showQR(img.data, img.mimeType); return; } - - // Try JSON text: {"type":"qrCode","message":""} - const text = content.filter(c => c.type === 'text').map(c => c.text).join(''); - try { - const obj = JSON.parse(text); - if (obj.type === 'qrCode' && obj.message) { - showQR(obj.message, 'image/png'); - return; - } - // alreadyLogged, notAuthorized, etc. - if (obj.type === 'alreadyLogged') { showAuthorized(); return; } - setError(obj.message || obj.type); - } catch { - setError(text || 'Unknown error'); + handleToolResult(params); + } + } + + function setInstanceId(value) { + if (value == null || value === '') return; + instanceId = value; + document.getElementById('instance-badge').textContent = '#' + instanceId; + } + + function extractInstanceId(payload) { + return payload?.arguments?.instance_id + ?? payload?.input?.instance_id + ?? payload?.toolInput?.instance_id + ?? payload?.result?._meta?.instance_id + ?? payload?._meta?.instance_id + ?? null; + } + + function unwrapToolResult(payload) { + return payload?.result ?? payload?.toolResult ?? payload ?? {}; + } + + function handleToolResult(payload) { + hostConnected = true; + const result = unwrapToolResult(payload); + setInstanceId(extractInstanceId(payload) ?? extractInstanceId(result)); + + const metaQR = result?._meta?.qr ?? payload?._meta?.qr; + if (metaQR?.type === 'qrCode' && metaQR.message) { + showQR(metaQR.message, metaQR.mimeType || 'image/png'); + return true; + } + + const structured = result.structuredContent ?? payload.structuredContent ?? window.openai?.toolOutput; + if (structured?.type === 'qrCode' && structured.message) { + showQR(structured.message, structured.mimeType || 'image/png'); + return true; + } + if (structured?.type === 'alreadyLogged') { showAuthorized(); return true; } + + const content = result.content ?? payload.content ?? []; + const img = content.find?.(c => c.type === 'image'); + if (img) { showQR(img.data, img.mimeType || 'image/png'); return true; } + + const text = (content.filter?.(c => c.type === 'text') ?? []).map(c => c.text).join(''); + if (!text) return false; + try { + const obj = JSON.parse(text); + if (obj.type === 'qrCode' && obj.message) { + showQR(obj.message, obj.mimeType || 'image/png'); + return true; } + if (obj.type === 'alreadyLogged') { showAuthorized(); return true; } + setError(obj.message || obj.type); + return true; + } catch { + setError(text || 'Unknown error'); + return true; } } @@ -380,20 +415,7 @@ stopCountdown(); try { const res = await request('tools/call', { name: 'whatsapp_get_qr', arguments: { instance_id: instanceId } }); - const content = res?.content ?? []; - - // image content - const img = content.find(c => c.type === 'image'); - if (img) { showQR(img.data, img.mimeType); return; } - - // JSON text - const text = content.filter(c => c.type === 'text').map(c => c.text).join(''); - try { - const obj = JSON.parse(text); - if (obj.type === 'qrCode' && obj.message) { showQR(obj.message, 'image/png'); return; } - if (obj.type === 'alreadyLogged') { showAuthorized(); return; } - setError(obj.message || obj.type); - } catch { setError(text || 'Unknown error'); } + if (!handleToolResult(res)) setError('No QR data returned'); } catch (err) { const reason = err?.message || (typeof err === 'string' ? err : null); setError(reason); @@ -402,6 +424,12 @@ // ── Bootstrap ────────────────────────────────────────────── setLoading(); + setInstanceId(window.openai?.toolInput?.instance_id); + if (window.openai?.toolResponseMetadata?.qr) { + handleToolResult({ _meta: window.openai.toolResponseMetadata, structuredContent: window.openai.toolOutput }); + } else if (window.openai?.toolOutput) { + handleToolResult({ structuredContent: window.openai.toolOutput }); + } request('ui/initialize', { protocolVersion: '2026-01-26', clientInfo: { name: 'GREEN-API QR', version: '1.0.0' },