406 lines
12 KiB
Go
406 lines
12 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
|
|
|
|
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)
|
|
}
|