Files
isphere-ai-bridge/internal/msglib/sidecar_client.go
2026-07-10 03:02:16 +08:00

288 lines
7.7 KiB
Go

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
)
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 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) 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 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)
}