Merge pull request #3063 from trufae/deltachan

feat: add deltachat gateway
This commit is contained in:
Mauro 2026-07-02 23:45:24 +02:00 committed by GitHub
commit 883d43b37d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 3435 additions and 7 deletions

View file

@ -494,6 +494,7 @@ Talk to your PicoClaw through 19+ messaging platforms:
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) | | **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) | | **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) | | **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
| **Delta Chat** | Easy (account script or email/password) | JSON-RPC (email/E2EE) | [Guide](docs/channels/deltachat/README.md) |
| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) | | **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) |
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) | | **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) | | **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |

View file

@ -0,0 +1,148 @@
> Back to [README](../../../README.md)
# Delta Chat Channel
PicoClaw can run as a Delta Chat bot by launching a local
`deltachat-rpc-server` process and talking to it over JSON-RPC. The RPC server
handles the email account, IMAP/SMTP connection, message store, and encryption
keys.
## Install
Install the Delta Chat RPC server. If `deltachat-rpc-server` is on `PATH`,
PicoClaw can find it automatically; otherwise set `rpc_server_path` to the
exact binary path.
```bash
pip install deltachat-rpc-server
which deltachat-rpc-server
```
Prebuilt binaries are also available from the
[Delta Chat core releases](https://github.com/deltachat/deltachat-core-rust/releases).
## Configure
The easiest setup is to let PicoClaw create a chatmail account in Delta
Chat's local account store. Put a relay marker in `email` using an empty local
part, for example `@nine.testrun.org`:
```json
{
"channel_list": {
"deltachat": {
"enabled": true,
"type": "deltachat",
"allow_from": ["friend@example.org"],
"group_trigger": {
"mention_only": true
},
"settings": {
"email": "@nine.testrun.org",
"display_name": "PicoClaw Bot",
"avatar_image": "/home/me/bot-avatar.png"
}
}
}
}
```
On startup, PicoClaw creates the account through `deltachat-rpc-server`, then
stops with an error that contains the generated address. Replace the relay
marker with that full email address and run PicoClaw again:
```json
{
"email": "bot123@nine.testrun.org",
"display_name": "PicoClaw Bot",
"avatar_image": "/home/me/bot-avatar.png"
}
```
If `email` is missing, the startup error lists the built-in relay choices copied
from Parla. You can use one of those relay markers, or a custom chatmail relay
with the same `@server.name` form.
`password` is not needed for PicoClaw-created chatmail accounts. Omit it when
`email` points to an already configured account in `data_dir`; the JSON-RPC
server owns the mailbox password. The legacy password-based path remains only
for classic email accounts that PicoClaw must configure itself. In that mode,
`password` is a secure field; on first config load it is moved to
`~/.picoclaw/.security.yml`, and it can also be set with
`PICOCLAW_CHANNELS_DELTACHAT_PASSWORD`.
`display_name` and `avatar_image` are optional profile settings. When present,
PicoClaw applies them on every startup, so changing the avatar path in config is
enough to update the bot profile.
| Field | Required | Description |
|-------|----------|-------------|
| `email` | Yes | Full bot mailbox address, or first-run relay marker such as `@nine.testrun.org` |
| `rpc_server_path` | No | Path to `deltachat-rpc-server`; only needed when it is not on `PATH` |
| `password` | No | Legacy only; required when PicoClaw must configure/reconfigure a classic mailbox itself |
| `display_name` | No | Startup-applied profile name shown to contacts and used for group mention detection |
| `avatar_image` | No | Startup-applied profile avatar image path; `~` is expanded. Missing files are warned and ignored |
| `data_dir` | No | Account database directory. Default: `~/.picoclaw/deltachat/<channel-name>` |
| `invite_link` | No | Delta Chat invite link to join on startup |
| `allow_crosspost` | No | Default `false`. When `true`, senders allowed by `allow_from` may use `message` tool targets outside the current chat, or resolve recipients by email/contact/chat name |
| `imap_server`, `imap_port` | No | Manual IMAP override for password-based configuration |
| `smtp_server`, `smtp_port` | No | Manual SMTP override for password-based configuration |
Standard channel fields such as `allow_from`, `group_trigger`, and
`reasoning_channel_id` also apply.
## First Run
With `email` set to `@server`, PicoClaw creates the chatmail account, prints
the generated full email in the startup error, and exits. Update `email` to that
full address and run PicoClaw again. On later runs, PicoClaw selects the
configured account by `email`, applies optional profile settings, marks it as a
bot, and starts IO.
With a new `data_dir` plus legacy `password`, PicoClaw can still configure a
classic email account and validate the mailbox credentials; after that, the
account is reused from the local data directory.
Delta Chat requires peers to learn the bot's encryption key before messaging
it. On startup PicoClaw prints the bot invite link and QR code. Add the bot from
Delta Chat with that invite, not by typing the bare email address.
## Behavior
- Direct chats always respond after `allow_from` passes.
- Group chats follow `group_trigger`; without one, every group message is
handled.
- Messages from the bot itself, device chats, and info/system messages are
ignored.
- Accepted inbound messages are marked seen after the allow-list check.
- Incoming attachments (images, audio, video, documents) are registered with
the media store and handed to the agent, so it can view images or operate on
the files directly. If no media store is available, the path is appended
inline as `[attachment: /path]` instead.
- Outbound attachments are supported: when the agent emits media, each file is
sent as a Delta Chat message (with the caption as text). Delta Chat infers the
view type from the file, so images, GIFs, and videos render natively.
- Crosspost recipient lookup is disabled by default. The agent can always reply
to the current numeric chat ID, but sending to another numeric chat ID or
resolving an email/contact/chat name requires `allow_crosspost: true`
and the current sender must pass `allow_from`; `allow_from: ["*"]` allows this
for any sender.
- Voice is supported in both directions when voice providers are configured:
incoming voice notes are transcribed by the agent's ASR and the transcript is
passed to the model; the agent can reply with synthesized speech, which is
delivered as a native Delta Chat voice message (`send_tts`). This requires an
ASR and/or TTS provider under `voice` — it is not Delta Chat-specific config.
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| `deltachat-rpc-server not found on PATH` or `rpc_server_path ... not found` | Install the RPC server on PATH, or set `rpc_server_path` to an absolute path |
| `email is required` | Choose one listed chatmail server, set `email` to a first-run marker such as `@nine.testrun.org`, run `picoclaw g`, then replace it with the generated full email |
| `created chatmail account ...` | Replace the `@server` marker in `email` with the generated full email and run PicoClaw again |
| `account ... is not configured in data_dir` | Point `data_dir` at the existing JSON-RPC account store, or use `email="@server"` to create one |
| `configure (check email/password/server)` | Check credentials, app password requirements, or IMAP/SMTP overrides |
| Bot does not answer in a group | Check `group_trigger`; mention `display_name` or use a configured prefix |
| Bot ignores a sender | Add the sender email to `allow_from`, or use `["*"]` for open access |
| Sender cannot message the bot | Re-add the bot with the startup QR/invite so Delta Chat can establish encryption |
| Agent cannot send to an email/name/other chat ID | Enable `settings.allow_crosspost` and allow the controlling sender in `allow_from`; this capability is disabled by default for privacy |

View file

@ -100,10 +100,15 @@ func resolveMediaRefs(
localPath, meta, err := store.ResolveWithMeta(ref) localPath, meta, err := store.ResolveWithMeta(ref)
if err != nil { if err != nil {
logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ fields := map[string]any{
"ref": ref, "ref": ref,
"error": err.Error(), "error": err.Error(),
}) }
if idx < currentTurnStart {
logger.DebugCF("agent", "Skipped stale historical media ref", fields)
} else {
logger.WarnCF("agent", "Failed to resolve media ref", fields)
}
continue continue
} }

View file

@ -266,7 +266,7 @@ func (c *BaseChannel) HandleMessageWithContext(
media []string, media []string,
inboundCtx bus.InboundContext, inboundCtx bus.InboundContext,
senderOpts ...bus.SenderInfo, senderOpts ...bus.SenderInfo,
) { ) error {
// Use SenderInfo-based allow check when available, else fall back to string // Use SenderInfo-based allow check when available, else fall back to string
var sender bus.SenderInfo var sender bus.SenderInfo
if len(senderOpts) > 0 { if len(senderOpts) > 0 {
@ -275,11 +275,11 @@ func (c *BaseChannel) HandleMessageWithContext(
senderID := strings.TrimSpace(inboundCtx.SenderID) senderID := strings.TrimSpace(inboundCtx.SenderID)
if sender.CanonicalID != "" || sender.PlatformID != "" { if sender.CanonicalID != "" || sender.PlatformID != "" {
if !c.IsAllowedSender(sender) { if !c.IsAllowedSender(sender) {
return return nil
} }
} else { } else {
if !c.IsAllowed(senderID) { if !c.IsAllowed(senderID) {
return return nil
} }
} }
@ -350,7 +350,9 @@ func (c *BaseChannel) HandleMessageWithContext(
"chat_id": deliveryChatID, "chat_id": deliveryChatID,
"error": err.Error(), "error": err.Error(),
}) })
return err
} }
return nil
} }
// HandleInboundContext publishes a normalized inbound message using only the // HandleInboundContext publishes a normalized inbound message using only the
@ -361,8 +363,8 @@ func (c *BaseChannel) HandleInboundContext(
media []string, media []string,
inboundCtx bus.InboundContext, inboundCtx bus.InboundContext,
senderOpts ...bus.SenderInfo, senderOpts ...bus.SenderInfo,
) { ) error {
c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...) return c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
} }
func (c *BaseChannel) SetRunning(running bool) { 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

View file

@ -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 == '_'
}

View file

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

View file

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

View file

@ -599,6 +599,31 @@ type MatrixSettings struct {
CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"` CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"`
} }
// DeltaChatSettings configures the Delta Chat channel. Delta Chat is an
// email-based, end-to-end encrypted messenger; PicoClaw talks to a local
// `deltachat-rpc-server` process over JSON-RPC (stdio).
//
// Email is the only required setting. A full address selects an already
// configured account in DataDir; a first-run marker such as "@nine.testrun.org"
// creates a chatmail account and tells the user which full email to save.
// Mailbox credentials stay in the Delta Chat account store. DisplayName and
// AvatarImage are optional profile settings applied on startup. Password remains
// only for legacy PicoClaw-managed email configuration.
type DeltaChatSettings struct {
Email string `json:"email" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_EMAIL"`
Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_DELTACHAT_PASSWORD"`
DisplayName string `json:"display_name,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_DISPLAY_NAME"`
AvatarImage string `json:"avatar_image,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_AVATAR_IMAGE"`
DataDir string `json:"data_dir,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_DATA_DIR"`
RPCServerPath string `json:"rpc_server_path,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_RPC_SERVER_PATH"`
InviteLink string `json:"invite_link,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_INVITE_LINK"`
AllowCrosspost bool `json:"allow_crosspost,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_ALLOW_CROSSPOST"`
IMAPServer string `json:"imap_server,omitempty" yaml:"-"`
IMAPPort int `json:"imap_port,omitempty" yaml:"-"`
SMTPServer string `json:"smtp_server,omitempty" yaml:"-"`
SMTPPort int `json:"smtp_port,omitempty" yaml:"-"`
}
type LINESettings struct { type LINESettings struct {
ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`

View file

@ -27,6 +27,7 @@ const (
ChannelDingTalk = "dingtalk" ChannelDingTalk = "dingtalk"
ChannelSlack = "slack" ChannelSlack = "slack"
ChannelMatrix = "matrix" ChannelMatrix = "matrix"
ChannelDeltaChat = "deltachat"
ChannelLINE = "line" ChannelLINE = "line"
ChannelOneBot = "onebot" ChannelOneBot = "onebot"
ChannelQQ = "qq" ChannelQQ = "qq"
@ -669,6 +670,7 @@ var channelSettingsFactory = map[string]any{
ChannelDingTalk: (DingTalkSettings{}), ChannelDingTalk: (DingTalkSettings{}),
ChannelSlack: (SlackSettings{}), ChannelSlack: (SlackSettings{}),
ChannelMatrix: (MatrixSettings{}), ChannelMatrix: (MatrixSettings{}),
ChannelDeltaChat: (DeltaChatSettings{}),
ChannelLINE: (LINESettings{}), ChannelLINE: (LINESettings{}),
ChannelOneBot: (OneBotSettings{}), ChannelOneBot: (OneBotSettings{}),
ChannelQQ: (QQSettings{}), ChannelQQ: (QQSettings{}),

View file

@ -1019,6 +1019,38 @@ func TestDefaultConfig_ChannelStreamingDisabled(t *testing.T) {
} }
} }
func TestDefaultConfig_DeltaChatExample(t *testing.T) {
cfg := DefaultConfig()
deltachat := cfg.Channels.Get(ChannelDeltaChat)
if deltachat == nil {
t.Fatal("DefaultConfig() missing deltachat channel")
}
if deltachat.Enabled {
t.Fatal("DefaultConfig().deltachat should be disabled")
}
if !deltachat.GroupTrigger.MentionOnly {
t.Fatal("DefaultConfig().deltachat should use mention-only group trigger")
}
decoded, err := deltachat.GetDecoded()
if err != nil {
t.Fatalf("deltachat GetDecoded() error = %v", err)
}
settings, ok := decoded.(*DeltaChatSettings)
if !ok {
t.Fatalf("deltachat settings type = %T, want *DeltaChatSettings", decoded)
}
if settings.Email != "@nine.testrun.org" {
t.Fatalf("DefaultConfig().deltachat.settings.email = %q, want @nine.testrun.org", settings.Email)
}
if settings.Password.String() != "" {
t.Fatal("DefaultConfig().deltachat.settings.password should be empty")
}
if settings.DisplayName == "" {
t.Fatal("DefaultConfig().deltachat.settings.display_name should be populated")
}
}
func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) { func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) {
channels := ChannelsConfig{ channels := ChannelsConfig{
"pico1": &Channel{Enabled: true, Type: ChannelPico}, "pico1": &Channel{Enabled: true, Type: ChannelPico},

View file

@ -549,6 +549,13 @@ func defaultChannels() ChannelsConfig {
"join_on_invite": true, "join_on_invite": true,
}, },
}, },
"deltachat": map[string]any{
"group_trigger": map[string]any{"mention_only": true},
"settings": map[string]any{
"email": "@nine.testrun.org",
"display_name": "PicoClaw Bot",
},
},
"line": map[string]any{ "line": map[string]any{
"group_trigger": map[string]any{"mention_only": true}, "group_trigger": map[string]any{"mention_only": true},
"settings": map[string]any{ "settings": map[string]any{

View file

@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/deltachat"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu" _ "github.com/sipeed/picoclaw/pkg/channels/feishu"