mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
WIP: Initial support for deltachat gateway
Features: - Support voice messages - Optional crossposting - Automatic account creation - Custom avatar image
This commit is contained in:
@@ -266,7 +266,7 @@ func (c *BaseChannel) HandleMessageWithContext(
|
||||
media []string,
|
||||
inboundCtx bus.InboundContext,
|
||||
senderOpts ...bus.SenderInfo,
|
||||
) {
|
||||
) error {
|
||||
// Use SenderInfo-based allow check when available, else fall back to string
|
||||
var sender bus.SenderInfo
|
||||
if len(senderOpts) > 0 {
|
||||
@@ -275,11 +275,11 @@ func (c *BaseChannel) HandleMessageWithContext(
|
||||
senderID := strings.TrimSpace(inboundCtx.SenderID)
|
||||
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
||||
if !c.IsAllowedSender(sender) {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
if !c.IsAllowed(senderID) {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,9 @@ func (c *BaseChannel) HandleMessageWithContext(
|
||||
"chat_id": deliveryChatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleInboundContext publishes a normalized inbound message using only the
|
||||
@@ -361,8 +363,8 @@ func (c *BaseChannel) HandleInboundContext(
|
||||
media []string,
|
||||
inboundCtx bus.InboundContext,
|
||||
senderOpts ...bus.SenderInfo,
|
||||
) {
|
||||
c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
|
||||
) error {
|
||||
return c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
|
||||
}
|
||||
|
||||
func (c *BaseChannel) SetRunning(running bool) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
||||
package deltachat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// listen is the inbound message loop. It blocks on wait_next_msgs and feeds
|
||||
// each new message into the PicoClaw inbound pipeline.
|
||||
func (c *DeltaChatChannel) listen() {
|
||||
logger.InfoCF("deltachat", "Listening for messages", map[string]any{
|
||||
"account_id": c.accountID,
|
||||
"email": c.selfAddr,
|
||||
})
|
||||
for c.IsRunning() && c.ctx.Err() == nil {
|
||||
raw, err := c.rpc.call(c.ctx, "wait_next_msgs", c.accountID)
|
||||
if err != nil {
|
||||
if c.ctx.Err() != nil || !c.IsRunning() {
|
||||
return
|
||||
}
|
||||
logger.ErrorCF("deltachat", "wait_next_msgs failed", map[string]any{"error": err.Error()})
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
var messageIDs []int64
|
||||
if err := json.Unmarshal(raw, &messageIDs); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(messageIDs) > 0 {
|
||||
logger.DebugCF("deltachat", "Received message batch", map[string]any{
|
||||
"count": len(messageIDs),
|
||||
})
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
c.handleMessage(messageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage fetches one message, applies inbound filtering, and publishes it.
|
||||
func (c *DeltaChatChannel) handleMessage(messageID int64) {
|
||||
msg, err := c.getMessage(messageID)
|
||||
if err != nil {
|
||||
logger.DebugCF("deltachat", "get_message failed", map[string]any{
|
||||
"message_id": messageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if msg.IsInfo || (strings.TrimSpace(msg.Text) == "" && msg.File == "") {
|
||||
return
|
||||
}
|
||||
|
||||
senderAddr := ""
|
||||
if msg.Sender != nil {
|
||||
senderAddr = msg.Sender.Address
|
||||
}
|
||||
if senderAddr != "" && strings.EqualFold(senderAddr, c.selfAddr) {
|
||||
logger.DebugCF("deltachat", "Drop: own message", map[string]any{"message_id": messageID})
|
||||
return
|
||||
}
|
||||
|
||||
chat, err := c.getFullChat(msg.ChatID)
|
||||
if err != nil {
|
||||
logger.DebugCF("deltachat", "get_full_chat_by_id failed", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Device messages are core-generated notices, not real conversations.
|
||||
if chat.IsDeviceChat {
|
||||
logger.DebugCF("deltachat", "Drop: device message", map[string]any{"chat_id": msg.ChatID})
|
||||
return
|
||||
}
|
||||
isGroup := chat.ChatType != chatTypeSingle
|
||||
|
||||
logger.DebugCF("deltachat", "Inbound message", map[string]any{
|
||||
"message_id": messageID,
|
||||
"chat_id": msg.ChatID,
|
||||
"from": senderAddr,
|
||||
"is_group": isGroup,
|
||||
"has_file": msg.File != "",
|
||||
"text_len": len(strings.TrimSpace(msg.Text)),
|
||||
})
|
||||
|
||||
senderName := senderAddr
|
||||
if msg.Sender != nil {
|
||||
if msg.Sender.DisplayName != "" {
|
||||
senderName = msg.Sender.DisplayName
|
||||
} else if msg.Sender.Name != "" {
|
||||
senderName = msg.Sender.Name
|
||||
}
|
||||
}
|
||||
if senderName == "" {
|
||||
senderName = "unknown"
|
||||
}
|
||||
|
||||
chatID := strconv.FormatInt(msg.ChatID, 10)
|
||||
messageIDStr := strconv.FormatInt(msg.ID, 10)
|
||||
|
||||
content := strings.TrimSpace(msg.Text)
|
||||
|
||||
// Register any attachment with the media store so the agent pipeline can
|
||||
// view images and operate on files. The ref is scoped to the same key the
|
||||
// BaseChannel derives for this message, so it is released with the turn.
|
||||
var mediaRefs []string
|
||||
if msg.File != "" {
|
||||
scope := channels.BuildMediaScope(c.Name(), chatID, messageIDStr)
|
||||
if ref := c.registerInboundFile(scope, msg); ref != "" {
|
||||
mediaRefs = append(mediaRefs, ref)
|
||||
} else {
|
||||
// Fallback when no media store is available: surface the path inline
|
||||
// so the attachment is not silently lost.
|
||||
annotation := fmt.Sprintf("[attachment: %s]", msg.File)
|
||||
if content == "" {
|
||||
content = annotation
|
||||
} else {
|
||||
content = content + "\n" + annotation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file with no caption still warrants a turn; give the agent a minimal
|
||||
// placeholder so the message survives the empty-content guard below. Audio
|
||||
// gets a "[voice]" tag specifically, so the agent's transcription step can
|
||||
// substitute the transcript in place rather than appending it.
|
||||
if content == "" && len(mediaRefs) > 0 {
|
||||
if utils.IsAudioFile(msg.FileName, msg.FileMime) {
|
||||
content = "[voice]"
|
||||
} else {
|
||||
content = "[media]"
|
||||
}
|
||||
}
|
||||
|
||||
sender := bus.SenderInfo{
|
||||
Platform: config.ChannelDeltaChat,
|
||||
PlatformID: senderAddr,
|
||||
CanonicalID: identity.BuildCanonicalID(config.ChannelDeltaChat, senderAddr),
|
||||
Username: senderAddr,
|
||||
DisplayName: senderName,
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("deltachat", "Drop: sender not in allow_from", map[string]any{
|
||||
"from": senderAddr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isMentioned := false
|
||||
if isGroup {
|
||||
botName := c.config.DisplayName
|
||||
if botName == "" {
|
||||
botName = c.selfAddr
|
||||
}
|
||||
isMentioned = mentionsBot(content, botName, c.selfAddr)
|
||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||
if !respond {
|
||||
logger.DebugCF("deltachat", "Drop: group trigger not satisfied", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"mentioned": isMentioned,
|
||||
})
|
||||
return
|
||||
}
|
||||
content = cleaned
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"platform": config.ChannelDeltaChat,
|
||||
"chat_name": chat.Name,
|
||||
}
|
||||
if msg.File != "" {
|
||||
metadata["file"] = msg.File
|
||||
metadata["file_name"] = msg.FileName
|
||||
metadata["file_mime"] = msg.FileMime
|
||||
}
|
||||
|
||||
inboundCtx := bus.InboundContext{
|
||||
Channel: config.ChannelDeltaChat,
|
||||
ChatID: chatID,
|
||||
SenderID: senderAddr,
|
||||
MessageID: messageIDStr,
|
||||
Mentioned: isMentioned,
|
||||
Raw: metadata,
|
||||
}
|
||||
if isGroup {
|
||||
inboundCtx.ChatType = "group"
|
||||
} else {
|
||||
inboundCtx.ChatType = "direct"
|
||||
}
|
||||
|
||||
logger.DebugCF("deltachat", "Dispatching to agent", map[string]any{
|
||||
"chat_id": chatID,
|
||||
"chat_type": inboundCtx.ChatType,
|
||||
"from": senderAddr,
|
||||
})
|
||||
if err := c.HandleInboundContext(c.ctx, chatID, content, mediaRefs, inboundCtx, sender); err != nil {
|
||||
logger.ErrorCF("deltachat", "Dispatch failed; leaving message unseen", map[string]any{
|
||||
"message_id": messageID,
|
||||
"chat_id": chatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, err := c.rpc.call(c.ctx, "markseen_msgs", c.accountID, []int64{messageID}); err != nil {
|
||||
logger.WarnCF("deltachat", "Failed to mark message seen", map[string]any{
|
||||
"message_id": messageID,
|
||||
"chat_id": chatID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// registerInboundFile records an inbound attachment with the media store under
|
||||
// the given scope and returns its media:// ref. Returns "" when there is no
|
||||
// media store or registration fails, letting the caller fall back to an inline
|
||||
// path annotation.
|
||||
//
|
||||
// Delta Chat stores attachments inside the account directory, next to the
|
||||
// credential database — a location tools are intentionally NOT allowed to read.
|
||||
// We therefore copy the single attachment out into the shared media temp dir
|
||||
// (which read_file/load_image are permitted to access) and register that copy,
|
||||
// so the agent can actually open the file. The copy is store-managed and deleted
|
||||
// when the turn's scope is released.
|
||||
func (c *DeltaChatChannel) registerInboundFile(scope string, msg *dcMessage) string {
|
||||
store := c.GetMediaStore()
|
||||
if store == nil {
|
||||
return ""
|
||||
}
|
||||
filename := msg.FileName
|
||||
if filename == "" {
|
||||
filename = filepath.Base(msg.File)
|
||||
}
|
||||
|
||||
localPath, err := copyToMediaTemp(msg.File, filename)
|
||||
if err != nil {
|
||||
logger.WarnCF("deltachat", "Failed to copy attachment into media dir", map[string]any{
|
||||
"file": msg.File,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
ContentType: msg.FileMime,
|
||||
Source: config.ChannelDeltaChat,
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
}, scope)
|
||||
if err != nil {
|
||||
logger.WarnCF("deltachat", "Failed to register attachment with media store", map[string]any{
|
||||
"file": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
_ = os.Remove(localPath)
|
||||
return ""
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// copyToMediaTemp copies srcPath into the shared media temp directory under a
|
||||
// unique name and returns the destination path. The media temp dir is the
|
||||
// location the read_file/load_image tools are permitted to read, so copying here
|
||||
// makes the attachment readable without exposing Delta Chat's account directory.
|
||||
func copyToMediaTemp(srcPath, filename string) (string, error) {
|
||||
if err := os.MkdirAll(media.TempDir(), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
safe := utils.SanitizeFilename(filename)
|
||||
if safe == "" {
|
||||
safe = filepath.Base(srcPath)
|
||||
}
|
||||
dstPath := filepath.Join(media.TempDir(), uuid.NewString()[:8]+"_"+safe)
|
||||
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
dst.Close()
|
||||
_ = os.Remove(dstPath)
|
||||
return "", err
|
||||
}
|
||||
if err := dst.Close(); err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
return "", err
|
||||
}
|
||||
return dstPath, nil
|
||||
}
|
||||
|
||||
func (c *DeltaChatChannel) getMessage(messageID int64) (*dcMessage, error) {
|
||||
raw, err := c.rpc.call(c.ctx, "get_message", c.accountID, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var msg dcMessage
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func (c *DeltaChatChannel) getFullChat(chatID int64) (*dcChat, error) {
|
||||
raw, err := c.rpc.call(c.ctx, "get_full_chat_by_id", c.accountID, chatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var chat dcChat
|
||||
if err := json.Unmarshal(raw, &chat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &chat, nil
|
||||
}
|
||||
|
||||
// mentionsBot reports whether the message references the bot by display name or
|
||||
// the local-part of its email address (a common addressing convention).
|
||||
func mentionsBot(content, displayName, email string) bool {
|
||||
if containsMentionToken(content, displayName) {
|
||||
return true
|
||||
}
|
||||
if local, _, ok := strings.Cut(email, "@"); ok && local != "" {
|
||||
if containsMentionToken(content, "@"+local) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsMentionToken(content, token string) bool {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
contentRunes := []rune(strings.ToLower(content))
|
||||
tokenRunes := []rune(strings.ToLower(token))
|
||||
if len(tokenRunes) == 0 || len(tokenRunes) > len(contentRunes) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(contentRunes)-len(tokenRunes); i++ {
|
||||
if !sameRunes(contentRunes[i:i+len(tokenRunes)], tokenRunes) {
|
||||
continue
|
||||
}
|
||||
before := i == 0 || !isMentionWordRune(contentRunes[i-1])
|
||||
afterIdx := i + len(tokenRunes)
|
||||
after := afterIdx >= len(contentRunes) || !isMentionWordRune(contentRunes[afterIdx])
|
||||
if before && after {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sameRunes(a, b []rune) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isMentionWordRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package deltachat
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory(
|
||||
config.ChannelDeltaChat,
|
||||
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
bc := cfg.Channels[channelName]
|
||||
if bc == nil || !bc.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
decoded, err := bc.GetDecoded()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, ok := decoded.(*config.DeltaChatSettings)
|
||||
if !ok {
|
||||
return nil, channels.ErrSendFailed
|
||||
}
|
||||
ch, err := NewDeltaChatChannel(bc, c, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channelName != config.ChannelDeltaChat {
|
||||
ch.SetName(channelName)
|
||||
}
|
||||
return ch, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package deltachat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// rpcRequest is a single JSON-RPC 2.0 request. Delta Chat uses positional
|
||||
// (array) parameters.
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID uint64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// rpcResponse is a single JSON-RPC 2.0 response.
|
||||
type rpcResponse struct {
|
||||
ID uint64 `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *rpcError `json:"error"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string {
|
||||
return fmt.Sprintf("deltachat rpc error %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// rpcClient drives a `deltachat-rpc-server` child process over stdio using
|
||||
// newline-delimited JSON-RPC 2.0. The server answers requests asynchronously
|
||||
// and out of order, so every call is correlated back to its caller by id.
|
||||
type rpcClient struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
|
||||
mu sync.Mutex
|
||||
nextID uint64
|
||||
pending map[uint64]chan rpcResponse
|
||||
closed bool
|
||||
}
|
||||
|
||||
// startRPC spawns the RPC server with DC_ACCOUNTS_PATH pointing at dataDir and
|
||||
// begins the background read loop.
|
||||
func startRPC(serverPath, dataDir string) (*rpcClient, error) {
|
||||
cmd := exec.Command(serverPath)
|
||||
cmd.Env = append(cmd.Environ(), "DC_ACCOUNTS_PATH="+dataDir)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deltachat rpc stdin: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deltachat rpc stdout: %w", err)
|
||||
}
|
||||
// Let the server's logs flow to our stderr for easy diagnostics.
|
||||
cmd.Stderr = logWriter{}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start deltachat-rpc-server (%s): %w", serverPath, err)
|
||||
}
|
||||
|
||||
c := &rpcClient{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
pending: make(map[uint64]chan rpcResponse),
|
||||
}
|
||||
go c.readLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// readLoop reads newline-delimited responses and dispatches them to waiters.
|
||||
func (c *rpcClient) readLoop() {
|
||||
reader := bufio.NewReader(c.stdout)
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
var resp rpcResponse
|
||||
if jsonErr := json.Unmarshal(line, &resp); jsonErr == nil && resp.ID != 0 {
|
||||
c.mu.Lock()
|
||||
ch := c.pending[resp.ID]
|
||||
delete(c.pending, resp.ID)
|
||||
c.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- resp
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
c.failAll(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failAll wakes every pending caller with an error (used on EOF / shutdown).
|
||||
func (c *rpcClient) failAll(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.closed = true
|
||||
for id, ch := range c.pending {
|
||||
ch <- rpcResponse{Error: &rpcError{Code: -1, Message: "rpc closed: " + err.Error()}}
|
||||
delete(c.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// call issues one request and blocks until the matching response arrives, the
|
||||
// context is canceled, or the server goes away.
|
||||
func (c *rpcClient) call(ctx context.Context, method string, params ...any) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("deltachat rpc: connection closed")
|
||||
}
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
ch := make(chan rpcResponse, 1)
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
req := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
c.clearPending(id)
|
||||
return nil, err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
c.mu.Lock()
|
||||
_, err = c.stdin.Write(data)
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
c.clearPending(id)
|
||||
return nil, fmt.Errorf("deltachat rpc write %s: %w", method, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.clearPending(id)
|
||||
return nil, ctx.Err()
|
||||
case resp := <-ch:
|
||||
if resp.Error != nil {
|
||||
return nil, resp.Error
|
||||
}
|
||||
return resp.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *rpcClient) clearPending(id uint64) {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// close terminates the RPC server process.
|
||||
func (c *rpcClient) close() {
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
if c.stdin != nil {
|
||||
_ = c.stdin.Close()
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
_ = c.cmd.Process.Kill()
|
||||
_ = c.cmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// logWriter forwards deltachat-rpc-server stderr lines into the logger.
|
||||
type logWriter struct{}
|
||||
|
||||
func (logWriter) Write(p []byte) (int, error) {
|
||||
logger.DebugC("deltachat", string(p))
|
||||
return len(p), nil
|
||||
}
|
||||
Reference in New Issue
Block a user