main
   1package signal
   2
   3import (
   4	"crypto/aes"
   5	"crypto/cipher"
   6	"crypto/sha1"
   7	"database/sql"
   8	"encoding/base64"
   9	"encoding/hex"
  10	"encoding/json"
  11	"fmt"
  12	"os"
  13	"os/exec"
  14	"path/filepath"
  15	"runtime"
  16	"strconv"
  17	"strings"
  18	"sync"
  19	"time"
  20
  21	"github.com/xlgmokha/mcp/pkg/mcp"
  22	_ "github.com/mutecomm/go-sqlcipher/v4"
  23	"golang.org/x/crypto/pbkdf2"
  24)
  25
  26// SignalOperations provides Signal Desktop database operations
  27type SignalOperations struct {
  28	mu         sync.RWMutex
  29	dbPath     string
  30	configPath string
  31	db         *sql.DB
  32}
  33
  34type SignalConfig struct {
  35	Key          string `json:"key"`
  36	EncryptedKey string `json:"encryptedKey"`
  37}
  38
  39type Contact struct {
  40	ID          string `json:"id"`
  41	Name        string `json:"name"`
  42	ProfileName string `json:"profileName"`
  43	Phone       string `json:"phone"`
  44}
  45
  46type Message struct {
  47	ID             string                 `json:"id"`
  48	ConversationID string                 `json:"conversationId"`
  49	Type           string                 `json:"type"`
  50	Body           string                 `json:"body"`
  51	From           string                 `json:"from"`
  52	To             string                 `json:"to"`
  53	SentAt         time.Time              `json:"sentAt"`
  54	Attachments    []Attachment           `json:"attachments,omitempty"`
  55	RawJSON        map[string]interface{} `json:"rawJson,omitempty"`
  56}
  57
  58type Attachment struct {
  59	FileName    string `json:"filename"`
  60	Path        string `json:"path"`
  61	ContentType string `json:"contentType,omitempty"`
  62	Size        int64  `json:"size,omitempty"`
  63}
  64
  65// NewSignalOperations creates a new SignalOperations helper
  66func NewSignalOperations(signalPath string) (*SignalOperations, error) {
  67	signal := &SignalOperations{}
  68
  69	// Determine Signal database and config paths
  70	if signalPath != "" {
  71		signal.dbPath = filepath.Join(signalPath, "sql", "db.sqlite")
  72		signal.configPath = filepath.Join(signalPath, "config.json")
  73	} else {
  74		var err error
  75		signal.dbPath, signal.configPath, err = findSignalPaths()
  76		if err != nil {
  77			return nil, fmt.Errorf("failed to find Signal paths: %w", err)
  78		}
  79	}
  80
  81	return signal, nil
  82}
  83
  84// New creates a new Signal MCP server
  85func New(signalPath string) (*mcp.Server, error) {
  86	signal, err := NewSignalOperations(signalPath)
  87	if err != nil {
  88		return nil, err
  89	}
  90	
  91	builder := mcp.NewServerBuilder("signal-server", "1.0.0")
  92
  93	// Add signal_list_conversations tool
  94	builder.AddTool(mcp.NewTool("signal_list_conversations", "List recent Signal conversations with timestamps and participants", map[string]interface{}{
  95		"type": "object",
  96		"properties": map[string]interface{}{
  97			"limit": map[string]interface{}{
  98				"type":        "integer",
  99				"description": "Maximum number of conversations to return",
 100				"minimum":     1,
 101				"default":     20,
 102			},
 103		},
 104	}, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 105		return signal.handleListConversations(req)
 106	}))
 107
 108	// Add signal_search_messages tool
 109	builder.AddTool(mcp.NewTool("signal_search_messages", "Search Signal messages by content with filtering options", map[string]interface{}{
 110		"type": "object",
 111		"properties": map[string]interface{}{
 112			"query": map[string]interface{}{
 113				"type":        "string",
 114				"description": "Search query string",
 115			},
 116			"conversation_id": map[string]interface{}{
 117				"type":        "string",
 118				"description": "Optional conversation ID to limit search scope",
 119			},
 120			"limit": map[string]interface{}{
 121				"type":        "integer",
 122				"description": "Maximum number of results",
 123				"minimum":     1,
 124				"default":     50,
 125			},
 126		},
 127		"required": []string{"query"},
 128	}, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 129		return signal.handleSearchMessages(req)
 130	}))
 131
 132	// Add signal_get_conversation tool
 133	builder.AddTool(mcp.NewTool("signal_get_conversation", "Get detailed conversation history including all messages and metadata", map[string]interface{}{
 134		"type": "object",
 135		"properties": map[string]interface{}{
 136			"conversation_id": map[string]interface{}{
 137				"type":        "string",
 138				"description": "The conversation ID to retrieve",
 139			},
 140			"limit": map[string]interface{}{
 141				"type":        "integer",
 142				"description": "Maximum number of messages to return",
 143				"minimum":     1,
 144				"default":     100,
 145			},
 146		},
 147		"required": []string{"conversation_id"},
 148	}, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 149		return signal.handleGetConversation(req)
 150	}))
 151
 152	// Add signal_get_contact tool
 153	builder.AddTool(mcp.NewTool("signal_get_contact", "Get detailed contact information by ID, phone number, or name", map[string]interface{}{
 154		"type": "object",
 155		"properties": map[string]interface{}{
 156			"contact_id": map[string]interface{}{
 157				"type":        "string",
 158				"description": "Contact ID, phone number, or display name",
 159			},
 160		},
 161		"required": []string{"contact_id"},
 162	}, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 163		return signal.handleGetContact(req)
 164	}))
 165
 166	// Add signal_get_message tool
 167	builder.AddTool(mcp.NewTool("signal_get_message", "Get detailed information about a specific message including attachments and reactions", map[string]interface{}{
 168		"type": "object",
 169		"properties": map[string]interface{}{
 170			"message_id": map[string]interface{}{
 171				"type":        "string",
 172				"description": "The message ID to retrieve",
 173			},
 174		},
 175		"required": []string{"message_id"},
 176	}, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 177		return signal.handleGetMessage(req)
 178	}))
 179
 180	// Add signal_list_attachments tool
 181	builder.AddTool(mcp.NewTool("signal_list_attachments", "List message attachments with metadata and filtering options", map[string]interface{}{
 182		"type": "object",
 183		"properties": map[string]interface{}{
 184			"conversation_id": map[string]interface{}{
 185				"type":        "string",
 186				"description": "Optional conversation ID to filter attachments",
 187			},
 188			"media_type": map[string]interface{}{
 189				"type":        "string",
 190				"description": "Filter by media type (image, video, audio, file)",
 191			},
 192			"limit": map[string]interface{}{
 193				"type":        "integer",
 194				"description": "Maximum number of attachments to return",
 195				"minimum":     1,
 196				"default":     50,
 197			},
 198		},
 199	}, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 200		return signal.handleListAttachments(req)
 201	}))
 202
 203	// Add signal_get_stats tool
 204	builder.AddTool(mcp.NewTool("signal_get_stats", "Get Signal database statistics and connection information", map[string]interface{}{
 205		"type": "object",
 206	}, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 207		return signal.handleGetStats(req)
 208	}))
 209
 210	// Add prompts
 211	builder.AddPrompt(mcp.NewPrompt("signal-conversation", "Analyze Signal conversation history for insights", []mcp.PromptArgument{}, func(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
 212		return signal.handleConversationPrompt(req)
 213	}))
 214
 215	builder.AddPrompt(mcp.NewPrompt("signal-search", "Search Signal messages with context", []mcp.PromptArgument{}, func(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
 216		return signal.handleSearchPrompt(req)
 217	}))
 218
 219	return builder.Build(), nil
 220}
 221
 222
 223func findSignalPaths() (dbPath, configPath string, err error) {
 224	var basePath string
 225	
 226	switch runtime.GOOS {
 227	case "darwin":
 228		basePath = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Signal")
 229	case "linux":
 230		// Try Flatpak first
 231		flatpakPath := filepath.Join(os.Getenv("HOME"), ".var", "app", "org.signal.Signal", "config", "Signal")
 232		if _, err := os.Stat(flatpakPath); err == nil {
 233			basePath = flatpakPath
 234		} else {
 235			// Try snap
 236			snapPath := filepath.Join(os.Getenv("HOME"), "snap", "signal-desktop", "current", ".config", "Signal")
 237			if _, err := os.Stat(snapPath); err == nil {
 238				basePath = snapPath
 239			} else {
 240				// Default Linux path
 241				basePath = filepath.Join(os.Getenv("HOME"), ".config", "Signal")
 242			}
 243		}
 244	case "windows":
 245		basePath = filepath.Join(os.Getenv("APPDATA"), "Signal")
 246	default:
 247		return "", "", fmt.Errorf("unsupported platform: %s", runtime.GOOS)
 248	}
 249
 250	dbPath = filepath.Join(basePath, "sql", "db.sqlite")
 251	configPath = filepath.Join(basePath, "config.json")
 252
 253	if _, err := os.Stat(dbPath); os.IsNotExist(err) {
 254		return "", "", fmt.Errorf("Signal database not found at %s", dbPath)
 255	}
 256	if _, err := os.Stat(configPath); os.IsNotExist(err) {
 257		return "", "", fmt.Errorf("Signal config not found at %s", configPath)
 258	}
 259
 260	return dbPath, configPath, nil
 261}
 262
 263func (signal *SignalOperations) ensureConnection() error {
 264	// Skip connection since we'll use command-line sqlcipher
 265	return nil
 266}
 267
 268func (signal *SignalOperations) executeQuery(query string) ([]byte, error) {
 269	// Get decryption key - use the same method as working implementation
 270	key, err := signal.getDecryptedSignalKey()
 271	if err != nil {
 272		return nil, fmt.Errorf("failed to get Signal key: %w", err)
 273	}
 274
 275	// Create SQL script using the same parameters as working implementation
 276	sqlScript := fmt.Sprintf(`
 277PRAGMA KEY = "x'%s'";
 278PRAGMA cipher_page_size = 4096;
 279PRAGMA kdf_iter = 64000;
 280PRAGMA cipher_hmac_algorithm = HMAC_SHA512;
 281PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512;
 282
 283.mode json
 284%s;
 285`, key, query)
 286
 287	// Execute sqlcipher with the script
 288	cmd := exec.Command("sqlcipher", signal.dbPath)
 289	cmd.Stdin = strings.NewReader(sqlScript)
 290	output, err := cmd.CombinedOutput()
 291	if err != nil {
 292		return nil, fmt.Errorf("sqlcipher command failed: %w\nOutput: %s", err, string(output))
 293	}
 294
 295	// Remove the "ok" line that sqlcipher outputs and extract JSON
 296	outputStr := string(output)
 297	lines := strings.Split(outputStr, "\n")
 298	var jsonContent []string
 299	
 300	// Look for JSON array start
 301	inJson := false
 302	for _, line := range lines {
 303		line = strings.TrimSpace(line)
 304		if line == "ok" || line == "" {
 305			continue
 306		}
 307		if strings.HasPrefix(line, "[") {
 308			inJson = true
 309		}
 310		if inJson {
 311			jsonContent = append(jsonContent, line)
 312		}
 313	}
 314	
 315	if len(jsonContent) > 0 {
 316		return []byte(strings.Join(jsonContent, "")), nil
 317	}
 318	
 319	return []byte("[]"), nil
 320}
 321
 322func (signal *SignalOperations) getDecryptedSignalKey() (string, error) {
 323	// Read config.json
 324	configData, err := os.ReadFile(signal.configPath)
 325	if err != nil {
 326		return "", fmt.Errorf("failed to read config: %w", err)
 327	}
 328
 329	var config SignalConfig
 330	if err := json.Unmarshal(configData, &config); err != nil {
 331		return "", fmt.Errorf("failed to parse config: %w", err)
 332	}
 333
 334	if config.Key != "" {
 335		return config.Key, nil
 336	}
 337
 338	if config.EncryptedKey != "" {
 339		return signal.decryptSignalKey(config.EncryptedKey)
 340	}
 341
 342	return "", fmt.Errorf("no encryption key found in config")
 343}
 344
 345func (signal *SignalOperations) decryptSignalKey(encryptedKey string) (string, error) {
 346	switch runtime.GOOS {
 347	case "darwin":
 348		return signal.decryptKeyMacOS(encryptedKey)
 349	case "linux":
 350		return signal.decryptKeyLinux(encryptedKey)
 351	default:
 352		return "", fmt.Errorf("key decryption not supported on %s", runtime.GOOS)
 353	}
 354}
 355
 356func (signal *SignalOperations) decryptKeyMacOS(encryptedKey string) (string, error) {
 357	// Get password from macOS Keychain using security command
 358	cmd := exec.Command("security", "find-generic-password", "-ws", "Signal Safe Storage")
 359	output, err := cmd.Output()
 360	if err != nil {
 361		return "", fmt.Errorf("failed to get Signal password from macOS Keychain: %w. "+
 362			"Make sure Signal Desktop has been run at least once and you're logged in", err)
 363	}
 364	
 365	password := strings.TrimSpace(string(output))
 366	if password == "" {
 367		return "", fmt.Errorf("empty password retrieved from keychain")
 368	}
 369	
 370	// Decrypt the key using the password
 371	key, err := signal.decryptWithPassword(password, encryptedKey, "v10", 1003)
 372	if err != nil {
 373		return "", fmt.Errorf("failed to decrypt key: %w", err)
 374	}
 375	
 376	return key, nil
 377}
 378
 379func (signal *SignalOperations) decryptKeyLinux(encryptedKey string) (string, error) {
 380	// Try secret-tool first
 381	cmd := exec.Command("secret-tool", "lookup", "application", "Signal")
 382	if output, err := cmd.Output(); err == nil {
 383		password := strings.TrimSpace(string(output))
 384		if password != "" {
 385			return signal.decryptWithPassword(password, encryptedKey, "v11", 1)
 386		}
 387	}
 388
 389	// Could add KDE wallet support here
 390	return "", fmt.Errorf("failed to decrypt key on Linux")
 391}
 392
 393func (signal *SignalOperations) convertKeyToHex(key string) (string, error) {
 394	// Signal stores keys in base64 format in the keychain
 395	// SQLCipher expects hex format with x'...' syntax
 396	keyBytes, err := base64.StdEncoding.DecodeString(key)
 397	if err != nil {
 398		return "", fmt.Errorf("failed to decode base64 key: %w", err)
 399	}
 400	
 401	return hex.EncodeToString(keyBytes), nil
 402}
 403
 404func (signal *SignalOperations) decryptWithPassword(password, encryptedKey, prefix string, iterations int) (string, error) {
 405	// Decode hex key
 406	encryptedKeyBytes, err := hex.DecodeString(encryptedKey)
 407	if err != nil {
 408		return "", fmt.Errorf("failed to decode encrypted key: %w", err)
 409	}
 410	
 411	// Check prefix
 412	prefixBytes := []byte(prefix)
 413	if len(encryptedKeyBytes) < len(prefixBytes) || string(encryptedKeyBytes[:len(prefixBytes)]) != prefix {
 414		return "", fmt.Errorf("encrypted key has wrong prefix, expected %s", prefix)
 415	}
 416	
 417	// Remove prefix
 418	encryptedKeyBytes = encryptedKeyBytes[len(prefixBytes):]
 419	
 420	// Derive decryption key using PBKDF2
 421	salt := []byte("saltysalt")
 422	key := pbkdf2.Key([]byte(password), salt, iterations, 16, sha1.New)
 423	
 424	// Decrypt using AES-CBC
 425	if len(encryptedKeyBytes) < aes.BlockSize {
 426		return "", fmt.Errorf("encrypted key too short")
 427	}
 428	
 429	// Create cipher
 430	block, err := aes.NewCipher(key)
 431	if err != nil {
 432		return "", fmt.Errorf("failed to create AES cipher: %w", err)
 433	}
 434	
 435	// Use 16 space bytes as IV (as per signal-export implementation: b" " * 16)
 436	iv := make([]byte, aes.BlockSize)
 437	for i := range iv {
 438		iv[i] = ' ' // ASCII space character (32)
 439	}
 440	mode := cipher.NewCBCDecrypter(block, iv)
 441	
 442	// Decrypt
 443	decrypted := make([]byte, len(encryptedKeyBytes))
 444	mode.CryptBlocks(decrypted, encryptedKeyBytes)
 445	
 446	// Remove PKCS7 padding
 447	decrypted, err = signal.removePKCS7Padding(decrypted)
 448	if err != nil {
 449		return "", fmt.Errorf("failed to remove padding: %w", err)
 450	}
 451	
 452	// Convert to string - should be ASCII hex characters
 453	result := string(decrypted)
 454	
 455	return result, nil
 456}
 457
 458func (signal *SignalOperations) removePKCS7Padding(data []byte) ([]byte, error) {
 459	if len(data) == 0 {
 460		return nil, fmt.Errorf("empty data")
 461	}
 462	
 463	padLen := int(data[len(data)-1])
 464	if padLen > len(data) || padLen == 0 {
 465		return nil, fmt.Errorf("invalid padding")
 466	}
 467	
 468	// Check padding bytes
 469	for i := len(data) - padLen; i < len(data); i++ {
 470		if data[i] != byte(padLen) {
 471			return nil, fmt.Errorf("invalid padding")
 472		}
 473	}
 474	
 475	return data[:len(data)-padLen], nil
 476}
 477
 478func (signal *SignalOperations) handleListConversations(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 479	if err := signal.ensureConnection(); err != nil {
 480		return mcp.NewToolError(fmt.Sprintf("Database connection failed: %v", err)), nil
 481	}
 482
 483	query := `SELECT id, COALESCE(name, profileName, e164, id) as display_name, 
 484		       profileName, e164, type, active_at
 485		FROM conversations 
 486		WHERE type IS NOT NULL
 487		ORDER BY active_at DESC
 488		LIMIT 10`
 489
 490	output, err := signal.executeQuery(query)
 491	if err != nil {
 492		return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
 493	}
 494
 495	var conversations []map[string]interface{}
 496	if err := json.Unmarshal(output, &conversations); err != nil {
 497		return mcp.NewToolError(fmt.Sprintf("Failed to parse conversations: %v", err)), nil
 498	}
 499
 500	result := fmt.Sprintf("Recent conversations (%d):\n\n", len(conversations))
 501	for _, conv := range conversations {
 502		displayName := conv["display_name"]
 503		if displayName == nil {
 504			displayName = "Unknown"
 505		}
 506		activeAt := conv["active_at"]
 507		if activeAt != nil {
 508			if timestamp, ok := activeAt.(float64); ok {
 509				t := time.Unix(int64(timestamp/1000), 0)
 510				result += fmt.Sprintf("• %s (last active: %s)\n", displayName, t.Format("2006-01-02 15:04"))
 511			} else {
 512				result += fmt.Sprintf("• %s\n", displayName)
 513			}
 514		} else {
 515			result += fmt.Sprintf("• %s\n", displayName)
 516		}
 517	}
 518
 519	return mcp.NewToolResult(mcp.NewTextContent(result)), nil
 520}
 521
 522func (signal *SignalOperations) handleSearchMessages(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 523	args := req.Arguments
 524	searchTerm, ok := args["query"].(string)
 525	if !ok || searchTerm == "" {
 526		return mcp.NewToolError("query parameter is required"), nil
 527	}
 528
 529	limitStr, _ := args["limit"].(string)
 530	limit := 50
 531	if limitStr != "" {
 532		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
 533			limit = l
 534		}
 535	}
 536
 537	query := `SELECT m.id, m.conversationId, m.type, m.body, m.sourceServiceId, m.sent_at,
 538		       COALESCE(c.name, c.profileName, c.e164, c.id) as conversation_name
 539		FROM messages m
 540		JOIN conversations c ON m.conversationId = c.id
 541		WHERE m.body LIKE '%` + searchTerm + `%' 
 542		  AND m.type NOT IN ('keychange', 'profile-change')
 543		  AND m.type IS NOT NULL
 544		ORDER BY m.sent_at DESC
 545		LIMIT ` + strconv.Itoa(limit)
 546
 547	output, err := signal.executeQuery(query)
 548	if err != nil {
 549		return mcp.NewToolError(fmt.Sprintf("Search failed: %v", err)), nil
 550	}
 551
 552	var messages []map[string]interface{}
 553	if err := json.Unmarshal(output, &messages); err != nil {
 554		return mcp.NewToolError(fmt.Sprintf("Failed to parse search results: %v", err)), nil
 555	}
 556
 557	result := fmt.Sprintf("Found %d messages containing '%s':\n\n", len(messages), searchTerm)
 558	for _, msg := range messages {
 559		conversationName := msg["conversation_name"]
 560		if conversationName == nil {
 561			conversationName = "Unknown"
 562		}
 563		
 564		body := msg["body"]
 565		if body == nil {
 566			body = ""
 567		}
 568
 569		if sentAt, ok := msg["sent_at"].(float64); ok {
 570			t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
 571			result += fmt.Sprintf("**%s** (%s)\n%s\n---\n", 
 572				conversationName, t.Format("2006-01-02 15:04"), body)
 573		} else {
 574			result += fmt.Sprintf("**%s**\n%s\n---\n", conversationName, body)
 575		}
 576	}
 577
 578	return mcp.NewToolResult(mcp.NewTextContent(result)), nil
 579}
 580
 581func (signal *SignalOperations) handleGetConversation(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 582	args := req.Arguments
 583	conversationID, ok := args["conversation_id"].(string)
 584	if !ok || conversationID == "" {
 585		return mcp.NewToolError("conversation_id parameter is required"), nil
 586	}
 587
 588	limitStr, _ := args["limit"].(string)
 589	limit := 100
 590	if limitStr != "" {
 591		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
 592			limit = l
 593		}
 594	}
 595
 596	query := `SELECT m.id, m.type, m.body, m.sourceServiceId, m.sent_at, m.json
 597		FROM messages m
 598		WHERE m.conversationId = '` + conversationID + `'
 599		  AND m.type NOT IN ('keychange', 'profile-change')
 600		  AND m.type IS NOT NULL
 601		ORDER BY m.sent_at DESC
 602		LIMIT ` + strconv.Itoa(limit)
 603
 604	output, err := signal.executeQuery(query)
 605	if err != nil {
 606		return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
 607	}
 608
 609	var messages []map[string]interface{}
 610	if err := json.Unmarshal(output, &messages); err != nil {
 611		return mcp.NewToolError(fmt.Sprintf("Failed to parse conversation data: %v", err)), nil
 612	}
 613
 614	result := fmt.Sprintf("Conversation %s (%d messages):\n\n", conversationID, len(messages))
 615	for _, msg := range messages {
 616		attachInfo := ""
 617		
 618		// Check for attachments in JSON data
 619		if jsonData, ok := msg["json"].(string); ok && jsonData != "" {
 620			var rawJSON map[string]interface{}
 621			if err := json.Unmarshal([]byte(jsonData), &rawJSON); err == nil {
 622				if attachments, ok := rawJSON["attachments"].([]interface{}); ok && len(attachments) > 0 {
 623					attachInfo = fmt.Sprintf(" [%d attachments]", len(attachments))
 624				}
 625			}
 626		}
 627		
 628		body := msg["body"]
 629		if body == nil {
 630			body = ""
 631		}
 632
 633		if sentAt, ok := msg["sent_at"].(float64); ok {
 634			t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
 635			result += fmt.Sprintf("%s: %s%s\n", 
 636				t.Format("2006-01-02 15:04"), body, attachInfo)
 637		} else {
 638			result += fmt.Sprintf("%s%s\n", body, attachInfo)
 639		}
 640	}
 641
 642	return mcp.NewToolResult(mcp.NewTextContent(result)), nil
 643}
 644
 645func (signal *SignalOperations) handleGetContact(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 646	args := req.Arguments
 647	contactID, ok := args["contact_id"].(string)
 648	if !ok || contactID == "" {
 649		return mcp.NewToolError("contact_id parameter is required"), nil
 650	}
 651
 652	query := `SELECT id, COALESCE(name, profileName, e164, id) as display_name, 
 653		       profileName, e164, type, active_at
 654		FROM conversations 
 655		WHERE id = '` + contactID + `' OR e164 = '` + contactID + `' OR name = '` + contactID + `' OR profileName = '` + contactID + `'
 656		LIMIT 1`
 657
 658	output, err := signal.executeQuery(query)
 659	if err != nil {
 660		return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
 661	}
 662
 663	var contacts []map[string]interface{}
 664	if err := json.Unmarshal(output, &contacts); err != nil {
 665		return mcp.NewToolError(fmt.Sprintf("Failed to parse contact data: %v", err)), nil
 666	}
 667
 668	if len(contacts) == 0 {
 669		return mcp.NewToolError(fmt.Sprintf("Contact not found: %s", contactID)), nil
 670	}
 671
 672	contact := contacts[0]
 673	displayName := contact["display_name"]
 674	if displayName == nil {
 675		displayName = "Unknown"
 676	}
 677
 678	// Get message count with this contact
 679	messageCountQuery := `SELECT COUNT(*) as message_count FROM messages WHERE conversationId = '` + fmt.Sprintf("%v", contact["id"]) + `' AND type NOT IN ('keychange', 'profile-change') AND type IS NOT NULL`
 680	
 681	countOutput, err := signal.executeQuery(messageCountQuery)
 682	var messageCount int64 = 0
 683	if err == nil {
 684		var countResult []map[string]interface{}
 685		if err := json.Unmarshal(countOutput, &countResult); err == nil && len(countResult) > 0 {
 686			if count, ok := countResult[0]["message_count"].(float64); ok {
 687				messageCount = int64(count)
 688			}
 689		}
 690	}
 691
 692	result := fmt.Sprintf("Contact Details:\n\n")
 693	result += fmt.Sprintf("**Name:** %s\n", displayName)
 694	if contact["profileName"] != nil && contact["profileName"] != "" {
 695		result += fmt.Sprintf("**Profile Name:** %s\n", contact["profileName"])
 696	}
 697	if contact["e164"] != nil && contact["e164"] != "" {
 698		result += fmt.Sprintf("**Phone:** %s\n", contact["e164"])
 699	}
 700	result += fmt.Sprintf("**Contact ID:** %s\n", contact["id"])
 701	if contact["type"] != nil {
 702		result += fmt.Sprintf("**Type:** %s\n", contact["type"])
 703	}
 704	if contact["active_at"] != nil {
 705		if timestamp, ok := contact["active_at"].(float64); ok {
 706			t := time.Unix(int64(timestamp/1000), 0)
 707			result += fmt.Sprintf("**Last Active:** %s\n", t.Format("2006-01-02 15:04:05"))
 708		}
 709	}
 710	result += fmt.Sprintf("**Total Messages:** %d\n", messageCount)
 711
 712	return mcp.NewToolResult(mcp.NewTextContent(result)), nil
 713}
 714
 715func (signal *SignalOperations) handleGetMessage(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 716	args := req.Arguments
 717	messageID, ok := args["message_id"].(string)
 718	if !ok || messageID == "" {
 719		return mcp.NewToolError("message_id parameter is required"), nil
 720	}
 721
 722	query := `SELECT m.id, m.conversationId, m.type, m.body, m.sourceServiceId, m.sent_at, m.json,
 723		       COALESCE(c.name, c.profileName, c.e164, c.id) as conversation_name
 724		FROM messages m
 725		JOIN conversations c ON m.conversationId = c.id
 726		WHERE m.id = '` + messageID + `'
 727		LIMIT 1`
 728
 729	output, err := signal.executeQuery(query)
 730	if err != nil {
 731		return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
 732	}
 733
 734	var messages []map[string]interface{}
 735	if err := json.Unmarshal(output, &messages); err != nil {
 736		return mcp.NewToolError(fmt.Sprintf("Failed to parse message data: %v", err)), nil
 737	}
 738
 739	if len(messages) == 0 {
 740		return mcp.NewToolError(fmt.Sprintf("Message not found: %s", messageID)), nil
 741	}
 742
 743	msg := messages[0]
 744	
 745	result := fmt.Sprintf("Message Details:\n\n")
 746	result += fmt.Sprintf("**Message ID:** %s\n", msg["id"])
 747	result += fmt.Sprintf("**Conversation:** %s\n", msg["conversation_name"])
 748	result += fmt.Sprintf("**Type:** %s\n", msg["type"])
 749	
 750	if sentAt, ok := msg["sent_at"].(float64); ok {
 751		t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
 752		result += fmt.Sprintf("**Sent:** %s\n", t.Format("2006-01-02 15:04:05"))
 753	}
 754	
 755	if msg["body"] != nil && fmt.Sprintf("%v", msg["body"]) != "" {
 756		result += fmt.Sprintf("**Message:** %s\n", msg["body"])
 757	}
 758
 759	// Parse JSON data for additional details
 760	if jsonData, ok := msg["json"].(string); ok && jsonData != "" {
 761		var rawJSON map[string]interface{}
 762		if err := json.Unmarshal([]byte(jsonData), &rawJSON); err == nil {
 763			// Check for attachments
 764			if attachments, ok := rawJSON["attachments"].([]interface{}); ok && len(attachments) > 0 {
 765				result += fmt.Sprintf("**Attachments:** %d\n", len(attachments))
 766				for i, att := range attachments {
 767					if attMap, ok := att.(map[string]interface{}); ok {
 768						if fileName, ok := attMap["fileName"].(string); ok {
 769							result += fmt.Sprintf("  %d. %s", i+1, fileName)
 770							if contentType, ok := attMap["contentType"].(string); ok {
 771								result += fmt.Sprintf(" (%s)", contentType)
 772							}
 773							if size, ok := attMap["size"].(float64); ok {
 774								result += fmt.Sprintf(" - %.1f KB", size/1024)
 775							}
 776							result += "\n"
 777						}
 778					}
 779				}
 780			}
 781
 782			// Check for reactions
 783			if reactions, ok := rawJSON["reactions"].([]interface{}); ok && len(reactions) > 0 {
 784				result += "**Reactions:** "
 785				var reactionStrs []string
 786				for _, reaction := range reactions {
 787					if r, ok := reaction.(map[string]interface{}); ok {
 788						if emoji, exists := r["emoji"].(string); exists {
 789							reactionStrs = append(reactionStrs, emoji)
 790						}
 791					}
 792				}
 793				result += strings.Join(reactionStrs, " ") + "\n"
 794			}
 795
 796			// Check for quoted message
 797			if quote, ok := rawJSON["quote"].(map[string]interface{}); ok {
 798				if quotedText, exists := quote["text"].(string); exists && strings.TrimSpace(quotedText) != "" {
 799					result += fmt.Sprintf("**Quoted Message:** %s\n", strings.TrimSpace(quotedText))
 800				}
 801			}
 802
 803			// Check for sticker
 804			if sticker, ok := rawJSON["sticker"].(map[string]interface{}); ok && len(sticker) > 0 {
 805				result += "**Sticker:** Yes\n"
 806			}
 807		}
 808	}
 809
 810	return mcp.NewToolResult(mcp.NewTextContent(result)), nil
 811}
 812
 813func (signal *SignalOperations) handleListAttachments(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 814	args := req.Arguments
 815	
 816	// Optional conversation filter
 817	conversationID, _ := args["conversation_id"].(string)
 818	
 819	// Optional limit
 820	limitStr, _ := args["limit"].(string)
 821	limit := 20
 822	if limitStr != "" {
 823		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
 824			limit = l
 825		}
 826	}
 827
 828	query := `SELECT m.id as message_id, m.conversationId, m.sent_at, m.json,
 829		       COALESCE(c.name, c.profileName, c.e164, c.id) as conversation_name
 830		FROM messages m
 831		JOIN conversations c ON m.conversationId = c.id
 832		WHERE m.json IS NOT NULL 
 833		  AND m.json LIKE '%"attachments":%'
 834		  AND m.type NOT IN ('keychange', 'profile-change')
 835		  AND m.type IS NOT NULL`
 836
 837	if conversationID != "" {
 838		query += ` AND m.conversationId = '` + conversationID + `'`
 839	}
 840	
 841	query += ` ORDER BY m.sent_at DESC LIMIT ` + strconv.Itoa(limit)
 842
 843	output, err := signal.executeQuery(query)
 844	if err != nil {
 845		return mcp.NewToolError(fmt.Sprintf("Query failed: %v", err)), nil
 846	}
 847
 848	var messages []map[string]interface{}
 849	if err := json.Unmarshal(output, &messages); err != nil {
 850		return mcp.NewToolError(fmt.Sprintf("Failed to parse attachment data: %v", err)), nil
 851	}
 852
 853	var attachments []map[string]interface{}
 854	
 855	for _, msg := range messages {
 856		if jsonData, ok := msg["json"].(string); ok && jsonData != "" {
 857			var rawJSON map[string]interface{}
 858			if err := json.Unmarshal([]byte(jsonData), &rawJSON); err == nil {
 859				if attachmentList, ok := rawJSON["attachments"].([]interface{}); ok {
 860					for _, att := range attachmentList {
 861						if attMap, ok := att.(map[string]interface{}); ok {
 862							attachment := map[string]interface{}{
 863								"message_id":        msg["message_id"],
 864								"conversation_name": msg["conversation_name"],
 865								"conversation_id":   msg["conversationId"],
 866								"sent_at":          msg["sent_at"],
 867							}
 868							
 869							// Copy attachment properties
 870							if fileName, ok := attMap["fileName"].(string); ok {
 871								attachment["file_name"] = fileName
 872							}
 873							if contentType, ok := attMap["contentType"].(string); ok {
 874								attachment["content_type"] = contentType
 875							}
 876							if size, ok := attMap["size"].(float64); ok {
 877								attachment["size"] = size
 878							}
 879							if path, ok := attMap["path"].(string); ok {
 880								attachment["path"] = path
 881							}
 882							
 883							attachments = append(attachments, attachment)
 884						}
 885					}
 886				}
 887			}
 888		}
 889	}
 890
 891	result := fmt.Sprintf("Attachments (%d found):\n\n", len(attachments))
 892	
 893	if len(attachments) == 0 {
 894		result += "No attachments found."
 895		if conversationID != "" {
 896			result += fmt.Sprintf(" (in conversation: %s)", conversationID)
 897		}
 898		result += "\n"
 899	} else {
 900		for i, att := range attachments {
 901			result += fmt.Sprintf("**%d. %s**\n", i+1, att["file_name"])
 902			result += fmt.Sprintf("   Conversation: %s\n", att["conversation_name"])
 903			
 904			if sentAt, ok := att["sent_at"].(float64); ok {
 905				t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
 906				result += fmt.Sprintf("   Sent: %s\n", t.Format("2006-01-02 15:04"))
 907			}
 908			
 909			if contentType, ok := att["content_type"].(string); ok {
 910				result += fmt.Sprintf("   Type: %s\n", contentType)
 911			}
 912			
 913			if size, ok := att["size"].(float64); ok {
 914				if size < 1024 {
 915					result += fmt.Sprintf("   Size: %.0f bytes\n", size)
 916				} else if size < 1024*1024 {
 917					result += fmt.Sprintf("   Size: %.1f KB\n", size/1024)
 918				} else {
 919					result += fmt.Sprintf("   Size: %.1f MB\n", size/(1024*1024))
 920				}
 921			}
 922			
 923			if messageID, ok := att["message_id"].(string); ok {
 924				result += fmt.Sprintf("   Message ID: %s\n", messageID)
 925			}
 926			
 927			result += "\n"
 928		}
 929	}
 930
 931	return mcp.NewToolResult(mcp.NewTextContent(result)), nil
 932}
 933
 934func (signal *SignalOperations) handleGetStats(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
 935	// Get message count using command-line sqlcipher
 936	messageQuery := "SELECT COUNT(*) as count FROM messages WHERE type NOT IN ('keychange', 'profile-change') AND type IS NOT NULL"
 937	messageOutput, err := signal.executeQuery(messageQuery)
 938	if err != nil {
 939		return mcp.NewToolError(fmt.Sprintf("Failed to count messages: %v", err)), nil
 940	}
 941
 942	var messageResult []map[string]interface{}
 943	totalMessages := 0
 944	if err := json.Unmarshal(messageOutput, &messageResult); err == nil && len(messageResult) > 0 {
 945		if count, ok := messageResult[0]["count"].(float64); ok {
 946			totalMessages = int(count)
 947		}
 948	}
 949
 950	// Get conversation count using command-line sqlcipher  
 951	convQuery := "SELECT COUNT(*) as count FROM conversations WHERE type IS NOT NULL"
 952	convOutput, err := signal.executeQuery(convQuery)
 953	if err != nil {
 954		return mcp.NewToolError(fmt.Sprintf("Failed to count conversations: %v", err)), nil
 955	}
 956
 957	var convResult []map[string]interface{}
 958	totalConversations := 0
 959	if err := json.Unmarshal(convOutput, &convResult); err == nil && len(convResult) > 0 {
 960		if count, ok := convResult[0]["count"].(float64); ok {
 961			totalConversations = int(count)
 962		}
 963	}
 964
 965	result := fmt.Sprintf("Signal Database Statistics:\n- Total Messages: %d\n- Total Conversations: %d\n- Database Path: %s", 
 966		totalMessages, totalConversations, signal.dbPath)
 967
 968	return mcp.NewToolResult(mcp.NewTextContent(result)), nil
 969}
 970
 971func (signal *SignalOperations) handleConversationPrompt(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
 972	// Get conversation ID from arguments
 973	args := req.Arguments
 974	conversationID, ok := args["conversation_id"].(string)
 975	if !ok || conversationID == "" {
 976		return mcp.GetPromptResult{}, fmt.Errorf("conversation_id parameter is required")
 977	}
 978
 979	// Optional analysis type
 980	analysisType, _ := args["analysis_type"].(string)
 981	if analysisType == "" {
 982		analysisType = "general"
 983	}
 984
 985	// Get conversation details
 986	convQuery := `SELECT COALESCE(name, profileName, e164, id) as display_name, type, active_at
 987		FROM conversations WHERE id = '` + conversationID + `' LIMIT 1`
 988	
 989	convOutput, err := signal.executeQuery(convQuery)
 990	if err != nil {
 991		return mcp.GetPromptResult{}, fmt.Errorf("failed to get conversation details: %w", err)
 992	}
 993
 994	var conversations []map[string]interface{}
 995	if err := json.Unmarshal(convOutput, &conversations); err != nil || len(conversations) == 0 {
 996		return mcp.GetPromptResult{}, fmt.Errorf("conversation not found")
 997	}
 998	
 999	conv := conversations[0]
1000	displayName := conv["display_name"]
1001	if displayName == nil {
1002		displayName = "Unknown"
1003	}
1004
1005	// Get recent messages from the conversation
1006	msgQuery := `SELECT type, body, sourceServiceId, sent_at, json
1007		FROM messages 
1008		WHERE conversationId = '` + conversationID + `'
1009		  AND type NOT IN ('keychange', 'profile-change')
1010		  AND type IS NOT NULL
1011		ORDER BY sent_at DESC 
1012		LIMIT 50`
1013
1014	msgOutput, err := signal.executeQuery(msgQuery)
1015	if err != nil {
1016		return mcp.GetPromptResult{}, fmt.Errorf("failed to get messages: %w", err)
1017	}
1018
1019	var messages []map[string]interface{}
1020	if err := json.Unmarshal(msgOutput, &messages); err != nil {
1021		messages = []map[string]interface{}{}
1022	}
1023
1024	// Build conversation context
1025	var contextBuilder strings.Builder
1026	contextBuilder.WriteString(fmt.Sprintf("Signal Conversation Analysis\n"))
1027	contextBuilder.WriteString(fmt.Sprintf("==========================\n\n"))
1028	contextBuilder.WriteString(fmt.Sprintf("**Conversation:** %s\n", displayName))
1029	contextBuilder.WriteString(fmt.Sprintf("**Analysis Type:** %s\n", analysisType))
1030	contextBuilder.WriteString(fmt.Sprintf("**Total Messages:** %d\n\n", len(messages)))
1031
1032	if len(messages) > 0 {
1033		contextBuilder.WriteString("**Recent Messages:**\n")
1034		for i, msg := range messages {
1035			if i >= 10 { // Limit to 10 most recent for prompt context
1036				break
1037			}
1038			
1039			if sentAt, ok := msg["sent_at"].(float64); ok {
1040				t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
1041				contextBuilder.WriteString(fmt.Sprintf("- %s: %s\n", 
1042					t.Format("2006-01-02 15:04"), msg["body"]))
1043			} else {
1044				contextBuilder.WriteString(fmt.Sprintf("- %s\n", msg["body"]))
1045			}
1046		}
1047		contextBuilder.WriteString("\n")
1048	}
1049
1050	// Build analysis prompt based on type
1051	var prompt string
1052	switch analysisType {
1053	case "sentiment":
1054		prompt = "Analyze the sentiment and emotional tone of this Signal conversation. " +
1055			"Identify patterns in communication style, emotional themes, and relationship dynamics. " +
1056			"Provide insights into the overall mood and any significant emotional shifts."
1057	case "summary":
1058		prompt = "Provide a comprehensive summary of this Signal conversation. " +
1059			"Include key topics discussed, important decisions or agreements made, " +
1060			"and any action items or follow-ups mentioned."
1061	case "patterns":
1062		prompt = "Analyze communication patterns in this Signal conversation. " +
1063			"Look for frequency of messages, response times, common topics, " +
1064			"and any behavioral patterns that emerge from the interaction history."
1065	default:
1066		prompt = "Analyze this Signal conversation and provide insights about the communication. " +
1067			"Include a summary of key topics, relationship dynamics, communication patterns, " +
1068			"and any notable themes or trends in the conversation history."
1069	}
1070
1071	result := mcp.GetPromptResult{
1072		Description: fmt.Sprintf("Analyzing Signal conversation with %s", displayName),
1073		Messages: []mcp.PromptMessage{
1074			{
1075				Role: "user",
1076				Content: mcp.NewTextContent(contextBuilder.String() + "\n" + prompt),
1077			},
1078		},
1079	}
1080
1081	return result, nil
1082}
1083
1084func (signal *SignalOperations) handleSearchPrompt(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
1085	// Get search parameters from arguments
1086	args := req.Arguments
1087	searchQuery, ok := args["query"].(string)
1088	if !ok || searchQuery == "" {
1089		return mcp.GetPromptResult{}, fmt.Errorf("query parameter is required")
1090	}
1091
1092	// Optional search scope
1093	searchScope, _ := args["scope"].(string)
1094	if searchScope == "" {
1095		searchScope = "messages"
1096	}
1097
1098	// Optional conversation filter
1099	conversationID, _ := args["conversation_id"].(string)
1100	
1101	// Optional time range
1102	timeRange, _ := args["time_range"].(string)
1103	if timeRange == "" {
1104		timeRange = "all"
1105	}
1106
1107	// Build search query based on scope
1108	var searchResults string
1109	var err error
1110
1111	switch searchScope {
1112	case "conversations":
1113		searchResults, err = signal.searchConversations(searchQuery)
1114	case "contacts":
1115		searchResults, err = signal.searchContacts(searchQuery)
1116	default: // "messages" or anything else
1117		searchResults, err = signal.searchMessages(searchQuery, conversationID, timeRange)
1118	}
1119
1120	if err != nil {
1121		return mcp.GetPromptResult{}, fmt.Errorf("search failed: %w", err)
1122	}
1123
1124	// Build context for the search prompt
1125	var contextBuilder strings.Builder
1126	contextBuilder.WriteString(fmt.Sprintf("Signal Search Results\n"))
1127	contextBuilder.WriteString(fmt.Sprintf("=====================\n\n"))
1128	contextBuilder.WriteString(fmt.Sprintf("**Search Query:** %s\n", searchQuery))
1129	contextBuilder.WriteString(fmt.Sprintf("**Search Scope:** %s\n", searchScope))
1130	if conversationID != "" {
1131		contextBuilder.WriteString(fmt.Sprintf("**Conversation Filter:** %s\n", conversationID))
1132	}
1133	contextBuilder.WriteString(fmt.Sprintf("**Time Range:** %s\n\n", timeRange))
1134	contextBuilder.WriteString("**Results:**\n")
1135	contextBuilder.WriteString(searchResults)
1136	contextBuilder.WriteString("\n")
1137
1138	// Build analysis prompt
1139	prompt := "Analyze these Signal search results and provide insights. " +
1140		"Summarize the key findings, identify patterns or themes, " +
1141		"and highlight any important information or trends from the search results. " +
1142		"If there are specific messages or conversations of note, explain their significance."
1143
1144	result := mcp.GetPromptResult{
1145		Description: fmt.Sprintf("Searching Signal for: %s", searchQuery),
1146		Messages: []mcp.PromptMessage{
1147			{
1148				Role: "user",
1149				Content: mcp.NewTextContent(contextBuilder.String() + "\n" + prompt),
1150			},
1151		},
1152	}
1153
1154	return result, nil
1155}
1156
1157func (signal *SignalOperations) searchMessages(query, conversationID, timeRange string) (string, error) {
1158	sqlQuery := `SELECT m.id, m.conversationId, m.body, m.sent_at,
1159		       COALESCE(c.name, c.profileName, c.e164, c.id) as conversation_name
1160		FROM messages m
1161		JOIN conversations c ON m.conversationId = c.id
1162		WHERE m.body LIKE '%` + query + `%'
1163		  AND m.type NOT IN ('keychange', 'profile-change')
1164		  AND m.type IS NOT NULL`
1165
1166	if conversationID != "" {
1167		sqlQuery += ` AND m.conversationId = '` + conversationID + `'`
1168	}
1169
1170	// Add time range filter
1171	switch timeRange {
1172	case "today":
1173		sqlQuery += ` AND m.sent_at > ` + fmt.Sprintf("%d", time.Now().AddDate(0, 0, -1).Unix()*1000)
1174	case "week":
1175		sqlQuery += ` AND m.sent_at > ` + fmt.Sprintf("%d", time.Now().AddDate(0, 0, -7).Unix()*1000)
1176	case "month":
1177		sqlQuery += ` AND m.sent_at > ` + fmt.Sprintf("%d", time.Now().AddDate(0, -1, 0).Unix()*1000)
1178	}
1179
1180	sqlQuery += ` ORDER BY m.sent_at DESC LIMIT 20`
1181
1182	output, err := signal.executeQuery(sqlQuery)
1183	if err != nil {
1184		return "", err
1185	}
1186
1187	var messages []map[string]interface{}
1188	if err := json.Unmarshal(output, &messages); err != nil {
1189		return "No messages found.", nil
1190	}
1191
1192	if len(messages) == 0 {
1193		return "No messages found matching the search criteria.", nil
1194	}
1195
1196	var result strings.Builder
1197	result.WriteString(fmt.Sprintf("Found %d messages:\n\n", len(messages)))
1198	
1199	for i, msg := range messages {
1200		if sentAt, ok := msg["sent_at"].(float64); ok {
1201			t := time.Unix(int64(sentAt/1000), (int64(sentAt)%1000)*1000000)
1202			result.WriteString(fmt.Sprintf("%d. **%s** (%s)\n", i+1, 
1203				msg["conversation_name"], t.Format("2006-01-02 15:04")))
1204		} else {
1205			result.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, msg["conversation_name"]))
1206		}
1207		result.WriteString(fmt.Sprintf("   %s\n\n", msg["body"]))
1208	}
1209
1210	return result.String(), nil
1211}
1212
1213func (signal *SignalOperations) searchConversations(query string) (string, error) {
1214	sqlQuery := `SELECT id, COALESCE(name, profileName, e164, id) as display_name,
1215		       profileName, e164, type, active_at
1216		FROM conversations 
1217		WHERE (name LIKE '%` + query + `%' OR profileName LIKE '%` + query + `%' OR e164 LIKE '%` + query + `%')
1218		  AND type IS NOT NULL
1219		ORDER BY active_at DESC
1220		LIMIT 10`
1221
1222	output, err := signal.executeQuery(sqlQuery)
1223	if err != nil {
1224		return "", err
1225	}
1226
1227	var conversations []map[string]interface{}
1228	if err := json.Unmarshal(output, &conversations); err != nil {
1229		return "No conversations found.", nil
1230	}
1231
1232	if len(conversations) == 0 {
1233		return "No conversations found matching the search criteria.", nil
1234	}
1235
1236	var result strings.Builder
1237	result.WriteString(fmt.Sprintf("Found %d conversations:\n\n", len(conversations)))
1238	
1239	for i, conv := range conversations {
1240		displayName := conv["display_name"]
1241		if displayName == nil {
1242			displayName = "Unknown"
1243		}
1244		
1245		result.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, displayName))
1246		if conv["e164"] != nil && conv["e164"] != "" {
1247			result.WriteString(fmt.Sprintf("   Phone: %s\n", conv["e164"]))
1248		}
1249		if conv["type"] != nil {
1250			result.WriteString(fmt.Sprintf("   Type: %s\n", conv["type"]))
1251		}
1252		if activeAt, ok := conv["active_at"].(float64); ok {
1253			t := time.Unix(int64(activeAt/1000), 0)
1254			result.WriteString(fmt.Sprintf("   Last Active: %s\n", t.Format("2006-01-02 15:04")))
1255		}
1256		result.WriteString("\n")
1257	}
1258
1259	return result.String(), nil
1260}
1261
1262func (signal *SignalOperations) searchContacts(query string) (string, error) {
1263	// Same as searchConversations but with different framing
1264	return signal.searchConversations(query)
1265}