package msglib import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "strings" "sync/atomic" "time" ) const ( Protocol = "isphere.msglib.v1" DefaultPassword = "123" EntityTypeContacts = "contacts" EntityTypeGroups = "groups" DefaultDisplayEntityLimit = 25 MaxDisplayEntityLimit = 100 DefaultListMessagesLimit = 20 MaxListMessagesLimit = 50 ) var requestCounter uint64 // Config describes the local sidecar process and copied MsgLib DB inputs. type Config struct { SidecarPath string SidecarArgs []string SQLiteDLLPath string DBPath string Password string Timeout time.Duration ExtraEnv []string } // ConfigFromEnv reads the C28 environment shape without wiring it into MCP tools. func ConfigFromEnv() (Config, error) { cfg := Config{ SidecarPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_SIDECAR_EXE")), SQLiteDLLPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_SQLITE_DLL")), DBPath: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_DB")), Password: strings.TrimSpace(os.Getenv("ISPHERE_MSGLIB_PASSWORD")), } if cfg.Password == "" { cfg.Password = DefaultPassword } if cfg.SidecarPath == "" { return cfg, errors.New("ISPHERE_MSGLIB_SIDECAR_EXE is not configured") } return cfg, nil } // NewClient returns a process-backed MsgLib sidecar client. func NewClient(cfg Config) *Client { if cfg.Password == "" { cfg.Password = DefaultPassword } if cfg.Timeout == 0 { cfg.Timeout = 10 * time.Second } return &Client{config: cfg} } type Client struct { config Config } type SelfCheckResult struct { SidecarName string `json:"sidecar_name"` SidecarVersion string `json:"sidecar_version"` Protocol string `json:"protocol"` Runtime string `json:"runtime"` ProcessBits int `json:"process_bits"` ReadOnly bool `json:"read_only"` MutatesDB bool `json:"mutates_db"` } type DisplaySource struct { Source string `json:"source"` Table string `json:"table"` Available bool `json:"available"` RequiredColumns []string `json:"required_columns"` PresentColumns []string `json:"present_columns"` } type MessageSource struct { Source string `json:"source"` Table string `json:"table"` Role string `json:"role"` Available bool `json:"available"` RequiredColumns []string `json:"required_columns"` PresentColumns []string `json:"present_columns"` RowCount string `json:"row_count"` } type ListMessagesOptions struct { ConversationID string ConversationType string Query string Since string Cursor string Limit int IncludeBody bool IncludeAttachmentMetadata bool } type ListMessagesResult struct { ReadOnly bool `json:"read_only"` MessageBodyValuesReturned bool `json:"message_body_values_returned"` RawRowsReturned bool `json:"raw_rows_returned"` FilePathsReturned bool `json:"file_paths_returned"` SourceTables []string `json:"source_tables"` Messages []ListMessage `json:"messages"` NextCursor string `json:"next_cursor"` } type ListMessage struct { MessageID string `json:"message_id"` ID string `json:"id"` ConversationID string `json:"conversation_id"` ConversationType string `json:"conversation_type"` SenderID string `json:"sender_id"` SenderName string `json:"sender_name"` ReceiverID string `json:"receiver_id"` Text string `json:"text"` ContentText string `json:"content_text"` ContentType string `json:"content_type"` Timestamp string `json:"timestamp"` CreatedAt string `json:"created_at"` Subject string `json:"subject"` Read bool `json:"read"` Source string `json:"source"` RawRef string `json:"raw_ref"` Attachments []ListMessageAttachment `json:"attachments"` } type ListMessageAttachment struct { FileID string `json:"file_id"` FileName string `json:"file_name"` SizeBytes int64 `json:"size_bytes"` DownloadRef string `json:"download_ref"` } type DisplayEntitiesOptions struct { EntityType string Query string Limit int } type DisplayEntity struct { EntityType string `json:"entity_type"` SourceTable string `json:"source_table"` JID string `json:"jid"` DisplayName string `json:"display_name"` Confidence float64 `json:"confidence"` MatchedColumns []string `json:"matched_columns"` } type SidecarError struct { Code string `json:"code"` Message string `json:"message"` } func (e *SidecarError) Error() string { if e == nil { return "msglib sidecar error" } if e.Code == "" { return "msglib sidecar error: " + e.Message } if e.Message == "" { return "msglib sidecar error: " + e.Code } return fmt.Sprintf("msglib sidecar error %s: %s", e.Code, e.Message) } func (c *Client) SelfCheck(ctx context.Context) (SelfCheckResult, error) { var out SelfCheckResult err := c.call(ctx, "self_check", map[string]any{}, &out) return out, err } func (c *Client) DisplaySources(ctx context.Context) ([]DisplaySource, error) { args := map[string]any{ "sqlite_dll_path": c.config.SQLiteDLLPath, "db_path": c.config.DBPath, "password": c.config.Password, } var out displaySourcesData if err := c.call(ctx, "display_sources", args, &out); err != nil { return nil, err } return out.DisplaySources, nil } func (c *Client) MessageSources(ctx context.Context) ([]MessageSource, error) { args := map[string]any{ "sqlite_dll_path": c.config.SQLiteDLLPath, "db_path": c.config.DBPath, "password": c.config.Password, } var out messageSourcesData if err := c.call(ctx, "message_sources", args, &out); err != nil { return nil, err } return out.MessageSources, nil } func (c *Client) ListMessages(ctx context.Context, opts ListMessagesOptions) (ListMessagesResult, error) { if strings.TrimSpace(opts.Cursor) != "" { return ListMessagesResult{}, errors.New("msglib list_messages cursor pagination is not available yet") } limit := opts.Limit if limit <= 0 { limit = DefaultListMessagesLimit } if limit > MaxListMessagesLimit { limit = MaxListMessagesLimit } args := map[string]any{ "sqlite_dll_path": c.config.SQLiteDLLPath, "db_path": c.config.DBPath, "password": c.config.Password, "limit": limit, "include_body": opts.IncludeBody, "include_attachment_metadata": opts.IncludeAttachmentMetadata, } if strings.TrimSpace(opts.ConversationID) != "" { args["conversation_id"] = strings.TrimSpace(opts.ConversationID) } if strings.TrimSpace(opts.ConversationType) != "" { args["conversation_type"] = strings.TrimSpace(opts.ConversationType) } if strings.TrimSpace(opts.Query) != "" { args["query"] = strings.TrimSpace(opts.Query) } if strings.TrimSpace(opts.Since) != "" { args["since"] = strings.TrimSpace(opts.Since) } var out ListMessagesResult if err := c.call(ctx, "list_messages", args, &out); err != nil { return ListMessagesResult{}, err } return out, nil } func (c *Client) DisplayEntities(ctx context.Context, opts DisplayEntitiesOptions) ([]DisplayEntity, error) { entityType := strings.TrimSpace(opts.EntityType) if entityType != EntityTypeContacts && entityType != EntityTypeGroups { return nil, fmt.Errorf("entity_type must be %q or %q", EntityTypeContacts, EntityTypeGroups) } limit := opts.Limit if limit <= 0 { limit = DefaultDisplayEntityLimit } if limit > MaxDisplayEntityLimit { limit = MaxDisplayEntityLimit } args := map[string]any{ "sqlite_dll_path": c.config.SQLiteDLLPath, "db_path": c.config.DBPath, "password": c.config.Password, "entity_type": entityType, "limit": limit, } if strings.TrimSpace(opts.Query) != "" { args["query"] = strings.TrimSpace(opts.Query) } var out displayEntitiesData if err := c.call(ctx, "display_entities", args, &out); err != nil { return nil, err } return out.Entities, nil } type displaySourcesData struct { DisplaySources []DisplaySource `json:"display_sources"` } type messageSourcesData struct { MessageSources []MessageSource `json:"message_sources"` } type displayEntitiesData struct { Entities []DisplayEntity `json:"entities"` } type requestEnvelope struct { Protocol string `json:"protocol"` RequestID string `json:"request_id"` Op string `json:"op"` Args map[string]any `json:"args,omitempty"` } type responseEnvelope struct { Protocol string `json:"protocol"` RequestID string `json:"request_id"` Op string `json:"op"` OK bool `json:"ok"` Data json.RawMessage `json:"data"` Error *SidecarError `json:"error"` Warnings []string `json:"warnings"` } func (c *Client) call(ctx context.Context, op string, args map[string]any, out any) error { if ctx == nil { ctx = context.Background() } if strings.TrimSpace(c.config.SidecarPath) == "" { return errors.New("msglib sidecar path is not configured") } requestID := nextRequestID() req := requestEnvelope{ Protocol: Protocol, RequestID: requestID, Op: op, Args: args, } payload, err := json.Marshal(req) if err != nil { return fmt.Errorf("marshal msglib sidecar request: %w", err) } payload = append(payload, '\n') callCtx := ctx var cancel context.CancelFunc if c.config.Timeout > 0 { callCtx, cancel = context.WithTimeout(ctx, c.config.Timeout) defer cancel() } cmd := exec.CommandContext(callCtx, c.config.SidecarPath, c.config.SidecarArgs...) cmd.Stdin = bytes.NewReader(payload) var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if len(c.config.ExtraEnv) > 0 { cmd.Env = append(os.Environ(), c.config.ExtraEnv...) } runErr := cmd.Run() if callCtx.Err() != nil { if errors.Is(callCtx.Err(), context.DeadlineExceeded) { return fmt.Errorf("msglib sidecar %s timed out: %w", op, context.DeadlineExceeded) } return fmt.Errorf("msglib sidecar %s context failed: %w", op, callCtx.Err()) } if runErr != nil { stderrText := strings.TrimSpace(stderr.String()) if stderrText == "" { return fmt.Errorf("msglib sidecar %s process failed: %w", op, runErr) } return fmt.Errorf("msglib sidecar %s process failed: %w: %s", op, runErr, stderrText) } body := bytes.TrimSpace(stdout.Bytes()) if len(body) == 0 { return fmt.Errorf("msglib sidecar %s returned empty stdout", op) } var resp responseEnvelope if err := json.Unmarshal(body, &resp); err != nil { return fmt.Errorf("decode msglib sidecar %s response: %w", op, err) } if resp.Protocol != Protocol { return fmt.Errorf("msglib sidecar %s response protocol = %q, want %q", op, resp.Protocol, Protocol) } if resp.RequestID != requestID { return fmt.Errorf("msglib sidecar %s response request_id = %q, want %q", op, resp.RequestID, requestID) } if resp.Op != op { return fmt.Errorf("msglib sidecar response op = %q, want %q", resp.Op, op) } if !resp.OK { if resp.Error != nil { return fmt.Errorf("msglib sidecar %s failed: %w", op, resp.Error) } return fmt.Errorf("msglib sidecar %s failed", op) } if out == nil || len(resp.Data) == 0 || bytes.Equal(resp.Data, []byte("null")) { return nil } if err := json.Unmarshal(resp.Data, out); err != nil { return fmt.Errorf("decode msglib sidecar %s data: %w", op, err) } return nil } func nextRequestID() string { n := atomic.AddUint64(&requestCounter, 1) return fmt.Sprintf("go-%d-%d", time.Now().UnixNano(), n) }