From 612e485d4ed1f3b413a7a386ad2d15dded987dd8 Mon Sep 17 00:00:00 2001 From: pancake Date: Mon, 8 Jun 2026 12:10:42 +0200 Subject: [PATCH] WIP: Initial support for deltachat gateway Features: - Support voice messages - Optional crossposting - Automatic account creation - Custom avatar image --- README.md | 1 + docs/channels/deltachat/README.md | 148 +++ pkg/agent/agent_media.go | 9 +- pkg/channels/base.go | 12 +- pkg/channels/deltachat/deltachat.go | 1330 ++++++++++++++++++++++ pkg/channels/deltachat/deltachat_test.go | 1254 ++++++++++++++++++++ pkg/channels/deltachat/handler.go | 398 +++++++ pkg/channels/deltachat/init.go | 35 + pkg/channels/deltachat/rpc.go | 188 +++ pkg/config/config.go | 25 + pkg/config/config_channel.go | 2 + pkg/config/config_test.go | 32 + pkg/config/defaults.go | 7 + pkg/gateway/gateway.go | 1 + 14 files changed, 3435 insertions(+), 7 deletions(-) create mode 100644 docs/channels/deltachat/README.md create mode 100644 pkg/channels/deltachat/deltachat.go create mode 100644 pkg/channels/deltachat/deltachat_test.go create mode 100644 pkg/channels/deltachat/handler.go create mode 100644 pkg/channels/deltachat/init.go create mode 100644 pkg/channels/deltachat/rpc.go diff --git a/README.md b/README.md index 3ba152c89..23e7cb5c4 100644 --- a/README.md +++ b/README.md @@ -494,6 +494,7 @@ Talk to your PicoClaw through 19+ messaging platforms: | **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/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) | +| **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) | | **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) | diff --git a/docs/channels/deltachat/README.md b/docs/channels/deltachat/README.md new file mode 100644 index 000000000..4b941d149 --- /dev/null +++ b/docs/channels/deltachat/README.md @@ -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/` | +| `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 | diff --git a/pkg/agent/agent_media.go b/pkg/agent/agent_media.go index 8915aca99..deee11cb7 100644 --- a/pkg/agent/agent_media.go +++ b/pkg/agent/agent_media.go @@ -100,10 +100,15 @@ func resolveMediaRefs( localPath, meta, err := store.ResolveWithMeta(ref) if err != nil { - logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{ + fields := map[string]any{ "ref": ref, "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 } diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 3585fb075..81d9ab72d 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -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) { diff --git a/pkg/channels/deltachat/deltachat.go b/pkg/channels/deltachat/deltachat.go new file mode 100644 index 000000000..6d925372f --- /dev/null +++ b/pkg/channels/deltachat/deltachat.go @@ -0,0 +1,1330 @@ +// Package deltachat implements a PicoClaw channel for Delta Chat, an +// email-based, end-to-end encrypted messenger. +// +// PicoClaw does not link the Delta Chat core directly. Instead it drives a +// local `deltachat-rpc-server` process (shipped with the `deltachat-rpc-server` +// pip package or the precompiled release binary) over newline-delimited +// JSON-RPC 2.0 on stdio. This keeps the Go binary free of CGO/native deps. +package deltachat + +import ( + "context" + "encoding/json" + "fmt" + "net/mail" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/mdp/qrterminal/v3" + + "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" +) + +// chatTypeSingle is Delta Chat's Chattype::Single β€” a 1:1 direct chat. +// The wire value is a string enum ("Single", "Group", "Mailinglist", +// "OutBroadcast", "InBroadcast"); anything other than Single is a group. +const chatTypeSingle = "Single" + +// configureTimeout bounds the (network-bound) account configuration step. +const configureTimeout = 90 * time.Second + +type chatmailRelay struct { + Domain string + Location string +} + +// Keep this list in sync with Parla's CHATMAIL_RELAYS in ../parla/src/relay_picker.vala. +var defaultChatmailRelays = []chatmailRelay{ + {"nine.testrun.org", "Default"}, + {"mehl.cloud", "German"}, + {"mailchat.pl", "Poland"}, + {"chatmail.woodpeckersnest.space", "Italy"}, + {"chatmail.culturanerd.it", "Italy"}, + {"chat.adminforge.de", "Falkenstein, Germany"}, + {"chika.aangat.lahat.computer", "Santa Clara, USA"}, + {"tarpit.fun", "Nuremberg, Germany"}, + {"d.gaufr.es", "Roubaix, France"}, + {"chtml.ca", "Quebec, Canada"}, + {"chatmail.au", "Melbourne, Australia"}, + {"e2ee.wang", "Johannesburg, South Africa"}, + {"chat.privittytech.com", "Bangalore, India"}, + {"e2ee.im", "Orastie, Romania"}, + {"chatmail.email", "Warsaw, Poland"}, + {"danneskjold.de", "Helsinki, Finland"}, + {"chat.in-the.eu", "Falkenstein, Germany"}, + {"chat.nuvon.app", "Prague, Czechia"}, + {"nibblehole.com", "Zug, Switzerland"}, + {"chat.zashm.org", "Lviv, Ukraine"}, + {"chat.sus.fr", "Iceland/Japan/Kenya/South Africa"}, + {"delta.thelab.uno", "Gravelines, France"}, + {"chat.vim.wtf", "Frankfurt, Germany"}, + {"uninterest.ing", "Elk Grove Village, USA"}, + {"sweetfern.net", "Ashburn, USA"}, + {"delta.disobey.net", "Roon, Netherlands"}, +} + +var managedAccountConfigKeys = []string{ + "addr", + "mail_server", + "mail_port", + "send_server", + "send_port", +} + +// dcAccount is one entry from get_all_accounts. +type dcAccount struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Addr string `json:"addr"` +} + +// dcContact is the subset of Delta Chat's ContactObject we consume. +// NOTE: Delta Chat serializes object fields in camelCase on the wire (method +// names and config keys are snake_case, but struct fields are camelCase). +type dcContact struct { + ID int64 `json:"id"` + Address string `json:"address"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + NameAndAddr string `json:"nameAndAddr"` +} + +// dcMessage is the subset of Delta Chat's MessageObject we consume. +type dcMessage struct { + ID int64 `json:"id"` + ChatID int64 `json:"chatId"` + FromID int64 `json:"fromId"` + Text string `json:"text"` + File string `json:"file"` + FileName string `json:"fileName"` + FileMime string `json:"fileMime"` + Timestamp int64 `json:"timestamp"` + IsInfo bool `json:"isInfo"` + Sender *dcContact `json:"sender"` +} + +// dcChat is the subset of Delta Chat's FullChat we consume. +type dcChat struct { + ID int64 `json:"id"` + Name string `json:"name"` + ChatType string `json:"chatType"` + IsDeviceChat bool `json:"isDeviceChat"` + CanSend bool `json:"canSend"` +} + +// dcMessageData mirrors the fields of Delta Chat's MessageData (camelCase on the +// wire) that PicoClaw sets when calling send_msg. Viewtype is normally left empty +// so Delta Chat infers it from the file (image/gif/video/file…); it is set only +// for voice replies, which must be Viewtype::Voice to render as a voice bubble +// rather than a generic audio attachment. +type dcMessageData struct { + Text string `json:"text,omitempty"` + File string `json:"file,omitempty"` + Filename string `json:"filename,omitempty"` + Viewtype string `json:"viewtype,omitempty"` +} + +// Ensure DeltaChatChannel satisfies the optional capability interfaces so the +// Manager routes media to it and the gateway advertises voice support. +var ( + _ channels.MediaSender = (*DeltaChatChannel)(nil) + _ channels.VoiceCapabilityProvider = (*DeltaChatChannel)(nil) +) + +// DeltaChatChannel implements channels.Channel on top of deltachat-rpc-server. +type DeltaChatChannel struct { + *channels.BaseChannel + bc *config.Channel + config *config.DeltaChatSettings + + serverPath string + dataDir string + + rpc *rpcClient + accountID int64 + selfAddr string + + ctx context.Context + cancel context.CancelFunc +} + +func parseDeltaChatEmailSetting(value string) (string, bool, error) { + email := strings.TrimSpace(value) + if email == "" { + return "", false, fmt.Errorf( + "deltachat: email is required.\nNext step: choose one of the chatmail servers below and set channel_list.deltachat.settings.email to %q, or use the same @server form with another chatmail relay. Run `picoclaw g` again; PicoClaw will create the account, print the generated full email address, and stop so you can save that address in the config.\nAvailable chatmail servers:\n%s", + "@"+defaultChatmailRelays[0].Domain, + formatChatmailRelayList(), + ) + } + if !strings.HasPrefix(email, "@") { + return email, false, nil + } + domain := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(email, "@"))) + if domain == "" { + return "", false, fmt.Errorf("deltachat: email %q is missing a chatmail server. Use %q or one of:\n%s", + email, "@"+defaultChatmailRelays[0].Domain, formatChatmailRelayList()) + } + if strings.Contains(domain, "@") || strings.ContainsAny(domain, "/\\ \t\r\n") { + return "", false, fmt.Errorf("deltachat: invalid chatmail server marker %q; use settings.email like %q", + email, "@"+defaultChatmailRelays[0].Domain) + } + return domain, true, nil +} + +func formatChatmailRelayList() string { + var b strings.Builder + for _, relay := range defaultChatmailRelays { + fmt.Fprintf(&b, " @%-34s %s\n", relay.Domain, relay.Location) + } + return strings.TrimRight(b.String(), "\n") +} + +func buildChatmailAccountQR(domain string) string { + return fmt.Sprintf("DCACCOUNT:https://%s/new", domain) +} + +// NewDeltaChatChannel validates config and resolves the RPC server + data dir. +func NewDeltaChatChannel( + bc *config.Channel, + cfg *config.DeltaChatSettings, + messageBus *bus.MessageBus, +) (*DeltaChatChannel, error) { + if _, _, err := parseDeltaChatEmailSetting(cfg.Email); err != nil { + return nil, err + } + + serverPath, err := resolveServerPath(cfg.RPCServerPath) + if err != nil { + return nil, err + } + dataDir := resolveDataDir(cfg.DataDir, bc.Name()) + + base := channels.NewBaseChannel(config.ChannelDeltaChat, cfg, messageBus, bc.AllowFrom, + channels.WithMaxMessageLength(0), // email has no practical length limit + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), + ) + + ch := &DeltaChatChannel{ + BaseChannel: base, + bc: bc, + config: cfg, + serverPath: serverPath, + dataDir: dataDir, + } + base.SetOwner(ch) + return ch, nil +} + +// Start spawns the RPC server, ensures the account is configured, and begins +// listening for messages. +func (c *DeltaChatChannel) Start(ctx context.Context) error { + logger.InfoC("deltachat", "Starting Delta Chat channel") + c.ctx, c.cancel = context.WithCancel(ctx) + + if err := os.MkdirAll(c.dataDir, 0o700); err != nil { + return fmt.Errorf("deltachat: create data dir %s: %w", c.dataDir, err) + } + + rpc, err := startRPC(c.serverPath, c.dataDir) + if err != nil { + return err + } + c.rpc = rpc + + if err := c.waitReady(c.ctx); err != nil { + c.rpc.close() + return err + } + + if err := c.ensureAccount(c.ctx); err != nil { + c.rpc.close() + return err + } + + if err := c.joinInviteLink(c.ctx); err != nil { + logger.WarnCF("deltachat", "Failed to join invite link", map[string]any{"error": err.Error()}) + } + + c.SetRunning(true) + go c.listen() + + logger.InfoCF("deltachat", "Delta Chat channel started", map[string]any{ + "email": c.selfAddr, + "account_id": c.accountID, + }) + + // Print the bot's invite link + QR so users can add it. Delta Chat / chatmail + // require end-to-end encryption, so peers must obtain the bot's key via this + // invite (adding the bare email address will not work). + c.printInviteLink(c.ctx) + + return nil +} + +// printInviteLink fetches the account-level secure-join invite link and prints +// it (with a scannable QR) to the terminal and log. +func (c *DeltaChatChannel) printInviteLink(ctx context.Context) { + raw, err := c.rpc.call(ctx, "get_chat_securejoin_qr_code", c.accountID, nil) + if err != nil { + logger.WarnCF("deltachat", "Could not generate invite link", map[string]any{"error": err.Error()}) + return + } + var link string + if err := json.Unmarshal(raw, &link); err != nil || link == "" { + return + } + + logger.InfoCF("deltachat", "Invite link", map[string]any{"link": link}) + fmt.Printf("\nπŸ“¨ Delta Chat invite for %s β€” scan with Delta Chat (βž• β†’ Scan/Paste QR) to message the bot:\n %s\n\n", + c.config.Email, link) + qrterminal.GenerateWithConfig(link, qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + HalfBlocks: true, + }) + fmt.Println() +} + +// Stop stops IO and terminates the RPC server. +func (c *DeltaChatChannel) Stop(ctx context.Context) error { + logger.InfoC("deltachat", "Stopping Delta Chat channel") + c.SetRunning(false) + if c.cancel != nil { + c.cancel() + } + if c.rpc != nil && c.accountID > 0 { + stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + _, _ = c.rpc.call(stopCtx, "stop_io", c.accountID) + cancel() + } + if c.rpc != nil { + c.rpc.close() + } + logger.InfoC("deltachat", "Delta Chat channel stopped") + return nil +} + +// Send delivers an outbound message to a Delta Chat chat. ChatID can be the +// numeric Delta Chat chat id, an email address, or a known contact/chat name. +func (c *DeltaChatChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + if strings.TrimSpace(msg.Content) == "" { + return nil, nil + } + + chatID, err := c.resolveOutboundChatID(ctx, msg.ChatID, msg.Context, msg.Scope) + if err != nil { + return nil, err + } + + // misc_send_msg(account_id, chat_id, text, file, name, location, quoted_message_id) + raw, err := c.rpc.call(ctx, "misc_send_msg", c.accountID, chatID, msg.Content, nil, nil, nil, nil) + if err != nil { + return nil, fmt.Errorf("deltachat send: %w", err) + } + + // Result is [message_id, message_object]; we only need the id. + var result []json.RawMessage + if err := json.Unmarshal(raw, &result); err == nil && len(result) > 0 { + var messageID int64 + if err := json.Unmarshal(result[0], &messageID); err == nil { + return []string{strconv.FormatInt(messageID, 10)}, nil + } + } + return nil, nil +} + +// SendMedia implements channels.MediaSender. Each part is resolved to a local +// file and delivered as its own Delta Chat message, with the part caption as the +// message text. Delta Chat copies the file into its blob store and infers the +// view type from the file itself. +func (c *DeltaChatChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + chatID, err := c.resolveOutboundChatID(ctx, msg.ChatID, msg.Context, msg.Scope) + if err != nil { + return nil, err + } + + store := c.GetMediaStore() + if store == nil { + return nil, fmt.Errorf("deltachat: no media store available: %w", channels.ErrSendFailed) + } + + var messageIDs []string + for _, part := range msg.Parts { + localPath, meta, err := store.ResolveWithMeta(part.Ref) + if err != nil { + logger.ErrorCF("deltachat", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + // Delta Chat needs a path it can open from its own working directory; + // absolutize defensively in case the store ever yields a relative one. + if abs, absErr := filepath.Abs(localPath); absErr == nil { + localPath = abs + } + + data := dcMessageData{ + Text: part.Caption, + File: localPath, + Filename: part.Filename, + Viewtype: deltaChatViewtype(part, meta), + } + raw, err := c.rpc.call(ctx, "send_msg", c.accountID, chatID, data) + if err != nil { + logger.ErrorCF("deltachat", "Failed to send media", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + return messageIDs, fmt.Errorf("deltachat send media: %w", channels.ErrTemporary) + } + + // send_msg returns the new message id as a bare integer. + var messageID int64 + if err := json.Unmarshal(raw, &messageID); err == nil && messageID > 0 { + messageIDs = append(messageIDs, strconv.FormatInt(messageID, 10)) + } + } + + return messageIDs, nil +} + +func (c *DeltaChatChannel) resolveOutboundChatID( + ctx context.Context, + target string, + outboundCtx bus.InboundContext, + outboundScope *bus.OutboundScope, +) (int64, error) { + target = strings.TrimSpace(target) + if target == "" { + return 0, fmt.Errorf("deltachat: empty chat target: %w", channels.ErrSendFailed) + } + + if chatID, ok := parsePositiveInt64(target); ok { + if err := c.requireOutboundNumericChatAllowed(outboundCtx, outboundScope, chatID); err != nil { + return 0, err + } + return chatID, nil + } + if chatID, ok := parsePrefixedChatID(target); ok { + if err := c.requireOutboundNumericChatAllowed(outboundCtx, outboundScope, chatID); err != nil { + return 0, err + } + return chatID, nil + } + + if err := c.requireOutboundRecipientResolution(outboundCtx, outboundScope); err != nil { + return 0, err + } + + if address, ok := emailAddressFromTarget(target); ok { + chatID, err := c.resolveEmailChatID(ctx, address) + if err != nil { + return 0, fmt.Errorf("deltachat: resolve %q: %w", target, err) + } + return chatID, nil + } + + chatID, err := c.resolveAliasChatID(ctx, target) + if err != nil { + return 0, fmt.Errorf("deltachat: resolve %q: %w", target, err) + } + if chatID <= 0 { + return 0, fmt.Errorf("deltachat: unknown chat target %q: %w", target, channels.ErrSendFailed) + } + return chatID, nil +} + +func (c *DeltaChatChannel) requireOutboundNumericChatAllowed( + outboundCtx bus.InboundContext, + outboundScope *bus.OutboundScope, + chatID int64, +) error { + callerChatID := outboundCallerChatID(outboundCtx, outboundScope) + if callerChatID == "" || callerChatID == strconv.FormatInt(chatID, 10) { + return nil + } + return c.requireOutboundRecipientResolution(outboundCtx, outboundScope) +} + +func outboundCallerChatID(outboundCtx bus.InboundContext, outboundScope *bus.OutboundScope) string { + if chatID := deltaChatChatIDFromScope(outboundScope); chatID != "" { + return chatID + } + return strings.TrimSpace(outboundCtx.ChatID) +} + +func deltaChatChatIDFromScope(scope *bus.OutboundScope) string { + if scope == nil || (scope.Channel != "" && !strings.EqualFold(scope.Channel, config.ChannelDeltaChat)) { + return "" + } + chat := strings.TrimSpace(scope.Values["chat"]) + if chat == "" { + return "" + } + if _, value, ok := strings.Cut(chat, ":"); ok { + chat = value + } + if value, _, ok := strings.Cut(chat, "/"); ok { + chat = value + } + return strings.TrimSpace(chat) +} + +func (c *DeltaChatChannel) requireOutboundRecipientResolution( + outboundCtx bus.InboundContext, + outboundScope *bus.OutboundScope, +) error { + senderID := outboundSenderID(outboundCtx, outboundScope) + if c.config != nil && c.config.AllowCrosspost && c.canCrosspost(senderID) { + return nil + } + return fmt.Errorf( + "deltachat: crosspost recipient resolution is disabled or caller %q is not allowed by allow_from; enable settings.allow_crosspost and allow the sender in allow_from: %w", + senderID, + channels.ErrSendFailed, + ) +} + +func outboundSenderID(outboundCtx bus.InboundContext, outboundScope *bus.OutboundScope) string { + if senderID := strings.TrimSpace(outboundCtx.SenderID); senderID != "" { + return senderID + } + if outboundScope == nil || + (outboundScope.Channel != "" && !strings.EqualFold(outboundScope.Channel, config.ChannelDeltaChat)) { + return "" + } + return strings.TrimSpace(outboundScope.Values["sender"]) +} + +func (c *DeltaChatChannel) canCrosspost(senderID string) bool { + if c.bc == nil { + return false + } + senderID = strings.TrimSpace(senderID) + localPart, _, _ := strings.Cut(senderID, "@") + sender := bus.SenderInfo{ + Platform: config.ChannelDeltaChat, + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID(config.ChannelDeltaChat, senderID), + Username: localPart, + } + for _, allowed := range c.bc.AllowFrom { + entry := strings.TrimSpace(allowed) + if entry == "*" { + return true + } + if entry != "" && senderID != "" && + (identity.MatchAllowed(sender, entry) || strings.EqualFold(entry, senderID)) { + return true + } + } + return false +} + +func parsePositiveInt64(value string) (int64, bool) { + id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} + +func parsePrefixedChatID(value string) (int64, bool) { + prefix, id, ok := strings.Cut(strings.TrimSpace(value), ":") + if !ok { + return 0, false + } + switch strings.ToLower(strings.TrimSpace(prefix)) { + case "chat", "chatid", "chat_id", config.ChannelDeltaChat: + return parsePositiveInt64(id) + default: + return 0, false + } +} + +func emailAddressFromTarget(target string) (string, bool) { + target = strings.TrimSpace(target) + if target == "" { + return "", false + } + if strings.HasPrefix(strings.ToLower(target), "mailto:") { + target = strings.TrimSpace(target[len("mailto:"):]) + if before, _, ok := strings.Cut(target, "?"); ok { + target = before + } + } + if !strings.Contains(target, "@") { + return "", false + } + if parsed, err := mail.ParseAddress(target); err == nil && parsed.Address != "" { + return strings.ToLower(parsed.Address), true + } + if strings.Count(target, "@") == 1 && !strings.ContainsAny(target, " \t\r\n<>") { + return strings.ToLower(target), true + } + return "", false +} + +func (c *DeltaChatChannel) resolveEmailChatID(ctx context.Context, address string) (int64, error) { + contactID, err := c.lookupContactIDByAddress(ctx, address) + if err != nil { + return 0, err + } + if contactID <= 0 { + contactID, err = c.createContact(ctx, address, "") + if err != nil { + return 0, err + } + } + return c.chatIDForContact(ctx, contactID) +} + +func (c *DeltaChatChannel) lookupContactIDByAddress(ctx context.Context, address string) (int64, error) { + raw, err := c.rpc.call(ctx, "lookup_contact_id_by_addr", c.accountID, address) + if err != nil { + return 0, fmt.Errorf("lookup contact by address: %w", err) + } + return decodeOptionalInt64(raw, "lookup contact by address") +} + +func (c *DeltaChatChannel) createContact(ctx context.Context, address, name string) (int64, error) { + var displayName any + if strings.TrimSpace(name) != "" { + displayName = strings.TrimSpace(name) + } + raw, err := c.rpc.call(ctx, "create_contact", c.accountID, address, displayName) + if err != nil { + return 0, fmt.Errorf("create contact: %w", err) + } + var contactID int64 + if err := json.Unmarshal(raw, &contactID); err != nil { + return 0, fmt.Errorf("create contact decode: %w", err) + } + if contactID <= 0 { + return 0, fmt.Errorf("create contact returned empty id: %w", channels.ErrSendFailed) + } + return contactID, nil +} + +func (c *DeltaChatChannel) chatIDForContact(ctx context.Context, contactID int64) (int64, error) { + raw, err := c.rpc.call(ctx, "get_chat_id_by_contact_id", c.accountID, contactID) + if err != nil { + return 0, fmt.Errorf("get chat by contact: %w", err) + } + chatID, err := decodeOptionalInt64(raw, "get chat by contact") + if err != nil { + return 0, err + } + if chatID > 0 { + return chatID, nil + } + + raw, err = c.rpc.call(ctx, "create_chat_by_contact_id", c.accountID, contactID) + if err != nil { + return 0, fmt.Errorf("create chat by contact: %w", err) + } + if err := json.Unmarshal(raw, &chatID); err != nil { + return 0, fmt.Errorf("create chat by contact decode: %w", err) + } + if chatID <= 0 { + return 0, fmt.Errorf("create chat by contact returned empty id: %w", channels.ErrSendFailed) + } + return chatID, nil +} + +func decodeOptionalInt64(raw json.RawMessage, label string) (int64, error) { + var id *int64 + if err := json.Unmarshal(raw, &id); err != nil { + return 0, fmt.Errorf("%s decode: %w", label, err) + } + if id == nil { + return 0, nil + } + return *id, nil +} + +func (c *DeltaChatChannel) resolveAliasChatID(ctx context.Context, target string) (int64, error) { + for _, query := range aliasQueries(target) { + contacts, err := c.findMatchingContacts(ctx, query) + if err != nil { + return 0, err + } + if len(contacts) == 1 { + return c.chatIDForContact(ctx, contacts[0].ID) + } + if len(contacts) > 1 { + return 0, ambiguousRecipientError(target, contactRecipientLabels(contacts)) + } + + chats, err := c.findMatchingChats(ctx, query) + if err != nil { + return 0, err + } + if len(chats) == 1 { + return chats[0].ID, nil + } + if len(chats) > 1 { + return 0, ambiguousRecipientError(target, chatRecipientLabels(chats)) + } + } + return 0, nil +} + +func aliasQueries(target string) []string { + target = strings.TrimSpace(target) + if target == "" { + return nil + } + queries := []string{target} + if unwrapped := strings.Trim(target, "<>"); unwrapped != "" && unwrapped != target { + queries = append(queries, unwrapped) + } + if unprefixed := strings.TrimPrefix(target, "@"); unprefixed != "" && unprefixed != target { + queries = append(queries, unprefixed) + } + return uniqueStrings(queries) +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + key := strings.ToLower(value) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out +} + +func (c *DeltaChatChannel) findMatchingContacts(ctx context.Context, query string) ([]dcContact, error) { + raw, err := c.rpc.call(ctx, "get_contacts", c.accountID, 0, query) + if err != nil { + return nil, fmt.Errorf("search contacts: %w", err) + } + var contacts []dcContact + if err := json.Unmarshal(raw, &contacts); err != nil { + return nil, fmt.Errorf("search contacts decode: %w", err) + } + contacts = uniqueContacts(contacts) + if exact := exactContactMatches(query, contacts); len(exact) > 0 { + return exact, nil + } + if len(contacts) == 1 { + return contacts, nil + } + return nil, nil +} + +func uniqueContacts(contacts []dcContact) []dcContact { + seen := make(map[int64]struct{}, len(contacts)) + out := make([]dcContact, 0, len(contacts)) + for _, contact := range contacts { + if contact.ID <= 0 { + continue + } + if _, ok := seen[contact.ID]; ok { + continue + } + seen[contact.ID] = struct{}{} + out = append(out, contact) + } + return out +} + +func exactContactMatches(query string, contacts []dcContact) []dcContact { + var matches []dcContact + for _, contact := range contacts { + if contactMatchesAlias(contact, query) { + matches = append(matches, contact) + } + } + return matches +} + +func contactMatchesAlias(contact dcContact, query string) bool { + query = strings.TrimSpace(query) + if query == "" { + return false + } + aliases := []string{ + contact.DisplayName, + contact.Name, + contact.Address, + contact.NameAndAddr, + } + if local, _, ok := strings.Cut(contact.Address, "@"); ok { + aliases = append(aliases, local, "@"+local) + } + for _, alias := range aliases { + if strings.EqualFold(strings.TrimSpace(alias), query) { + return true + } + } + return false +} + +func (c *DeltaChatChannel) findMatchingChats(ctx context.Context, query string) ([]dcChat, error) { + raw, err := c.rpc.call(ctx, "get_chatlist_entries", c.accountID, 0, query, nil) + if err != nil { + return nil, fmt.Errorf("search chats: %w", err) + } + var chatIDs []int64 + if err := json.Unmarshal(raw, &chatIDs); err != nil { + return nil, fmt.Errorf("search chats decode: %w", err) + } + var chats []dcChat + for _, chatID := range uniqueInt64s(chatIDs) { + if chatID <= 0 { + continue + } + chat, err := c.getFullChatByContext(ctx, chatID) + if err != nil { + return nil, err + } + if chat.IsDeviceChat { + continue + } + chats = append(chats, *chat) + } + if exact := exactChatMatches(query, chats); len(exact) > 0 { + return exact, nil + } + if len(chats) == 1 { + return chats, nil + } + return nil, nil +} + +func uniqueInt64s(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func exactChatMatches(query string, chats []dcChat) []dcChat { + var matches []dcChat + for _, chat := range chats { + if strings.EqualFold(strings.TrimSpace(chat.Name), strings.TrimSpace(query)) { + matches = append(matches, chat) + } + } + return matches +} + +func (c *DeltaChatChannel) getFullChatByContext(ctx context.Context, chatID int64) (*dcChat, error) { + raw, err := c.rpc.call(ctx, "get_full_chat_by_id", c.accountID, chatID) + if err != nil { + return nil, fmt.Errorf("get chat %d: %w", chatID, err) + } + var chat dcChat + if err := json.Unmarshal(raw, &chat); err != nil { + return nil, fmt.Errorf("get chat %d decode: %w", chatID, err) + } + return &chat, nil +} + +func ambiguousRecipientError(target string, labels []string) error { + return fmt.Errorf( + "ambiguous recipient %q matches %s: %w", + target, + strings.Join(labels, ", "), + channels.ErrSendFailed, + ) +} + +func contactRecipientLabels(contacts []dcContact) []string { + labels := make([]string, 0, len(contacts)) + for _, contact := range contacts { + name := strings.TrimSpace(contact.DisplayName) + if name == "" { + name = strings.TrimSpace(contact.Name) + } + if name != "" && contact.Address != "" { + labels = append(labels, fmt.Sprintf("%s <%s>", name, contact.Address)) + } else if contact.Address != "" { + labels = append(labels, contact.Address) + } else { + labels = append(labels, strconv.FormatInt(contact.ID, 10)) + } + } + return labels +} + +func chatRecipientLabels(chats []dcChat) []string { + labels := make([]string, 0, len(chats)) + for _, chat := range chats { + name := strings.TrimSpace(chat.Name) + if name == "" { + name = strconv.FormatInt(chat.ID, 10) + } + labels = append(labels, fmt.Sprintf("%s (chat %d)", name, chat.ID)) + } + return labels +} + +// deltaChatViewtype returns the explicit Delta Chat view type for an outbound +// media part, or "" to let Delta Chat infer it from the file. Only voice replies +// are forced (to Viewtype::Voice) so they render as playable voice bubbles; +// images, GIFs, and video keep Delta Chat's native auto-detection. A part is +// treated as voice when it is audio and either came from the send_tts tool or +// carries a "voice" filename hint (matching the convention other channels use). +func deltaChatViewtype(part bus.MediaPart, meta media.MediaMeta) string { + isAudio := part.Type == "audio" || + strings.HasPrefix(strings.ToLower(part.ContentType), "audio/") || + strings.HasPrefix(strings.ToLower(meta.ContentType), "audio/") + if !isAudio { + return "" + } + + name := strings.ToLower(part.Filename) + if name == "" { + name = strings.ToLower(meta.Filename) + } + if meta.Source == "tool:send_tts" || strings.Contains(name, "voice") { + return "Voice" + } + return "" +} + +// VoiceCapabilities implements channels.VoiceCapabilityProvider. Delta Chat can +// receive voice notes (which the agent's ASR transcribes) and deliver +// synthesized speech as voice messages, so it advertises both ASR and TTS. The +// gateway still gates actual availability on configured ASR/TTS providers. +func (c *DeltaChatChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} + +// StartTyping implements channels.TypingCapable. Delta Chat has no typing +// indicator over email, so this is a no-op that satisfies the interface and +// lets the Manager skip the placeholder dance gracefully. +func (c *DeltaChatChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + return func() {}, nil +} + +// waitReady polls get_system_info until the RPC server responds. +func (c *DeltaChatChannel) waitReady(ctx context.Context) error { + for attempt := 0; attempt < 40; attempt++ { + if ctx.Err() != nil { + return ctx.Err() + } + callCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + _, err := c.rpc.call(callCtx, "get_system_info") + cancel() + if err == nil { + return nil + } + time.Sleep(250 * time.Millisecond) + } + return fmt.Errorf("deltachat: rpc server did not become ready") +} + +// ensureAccount finds or creates the configured account and starts its IO. +func (c *DeltaChatChannel) ensureAccount(ctx context.Context) error { + server, bootstrap, err := parseDeltaChatEmailSetting(c.config.Email) + if err != nil { + return err + } + if bootstrap { + return c.createChatmailBootstrapAccount(ctx, server) + } + + c.selfAddr = strings.ToLower(c.config.Email) + + accounts, err := c.listAccounts(ctx) + if err != nil { + return err + } + + var accountID int64 + for _, acc := range accounts { + if acc.Kind == "Configured" && strings.EqualFold(acc.Addr, c.config.Email) { + accountID = acc.ID + break + } + } + + if accountID == 0 { + if c.config.Password.String() == "" { + return c.passwordRequiredError("account not found") + } + + raw, callErr := c.rpc.call(ctx, "add_account") + if callErr != nil { + return fmt.Errorf("deltachat add_account: %w", callErr) + } + if decErr := json.Unmarshal(raw, &accountID); decErr != nil { + return fmt.Errorf("deltachat add_account decode: %w", decErr) + } + } + + configured, err := c.isConfigured(ctx, accountID) + if err != nil { + return err + } + if !configured { + if err := c.configureAccount(ctx, accountID); err != nil { + return err + } + } else if c.config.Password.String() != "" { + changed, err := c.accountConfigChanged(ctx, accountID) + if err != nil { + logger.WarnCF("deltachat", "Could not read account config; reconfiguring", map[string]any{ + "email": c.config.Email, + "error": err.Error(), + }) + changed = true + } + if changed { + if err := c.configureAccount(ctx, accountID); err != nil { + return err + } + } + } + + if _, err := c.rpc.call(ctx, "select_account", accountID); err != nil { + return fmt.Errorf("deltachat select_account: %w", err) + } + if err := c.applyProfileConfig(ctx, accountID); err != nil { + return err + } + // Mark this account as a bot so the core delivers all messages to us. + if _, err := c.rpc.call(ctx, "batch_set_config", accountID, map[string]string{"bot": "1"}); err != nil { + return fmt.Errorf("deltachat set bot config: %w", err) + } + if _, err := c.rpc.call(ctx, "start_io", accountID); err != nil { + return fmt.Errorf("deltachat start_io: %w", err) + } + + c.accountID = accountID + return nil +} + +func (c *DeltaChatChannel) createChatmailBootstrapAccount(ctx context.Context, server string) error { + raw, err := c.rpc.call(ctx, "add_account") + if err != nil { + return fmt.Errorf("deltachat add_account: %w", err) + } + var accountID int64 + if decodeErr := json.Unmarshal(raw, &accountID); decodeErr != nil { + return fmt.Errorf("deltachat add_account decode: %w", decodeErr) + } + + created := false + defer func() { + if !created { + c.cleanupPendingAccount(context.Background(), accountID) + } + }() + + confCtx, cancel := context.WithTimeout(ctx, configureTimeout) + defer cancel() + if _, callErr := c.rpc.call( + confCtx, + "add_transport_from_qr", + accountID, + buildChatmailAccountQR(server), + ); callErr != nil { + return fmt.Errorf("deltachat create chatmail account on %s: %w", server, callErr) + } + created = true + + if profileErr := c.applyProfileConfig(ctx, accountID); profileErr != nil { + logger.WarnCF( + "deltachat", + "Could not apply profile config to new account", + map[string]any{"error": profileErr.Error()}, + ) + } + + addr, err := c.getAccountConfigString(ctx, accountID, "addr") + if err != nil { + return fmt.Errorf("deltachat created account on %s, but could not read generated email: %w", server, err) + } + addr = strings.TrimSpace(addr) + if addr == "" { + return fmt.Errorf("deltachat created account on %s, but generated email is empty", server) + } + + return fmt.Errorf( + "deltachat: created chatmail account %q on %s. Update channel_list.deltachat.settings.email from %q to %q, remove the @server bootstrap marker, then run PicoClaw again to use the account", + addr, + server, + c.config.Email, + addr, + ) +} + +func (c *DeltaChatChannel) cleanupPendingAccount(ctx context.Context, accountID int64) { + if accountID <= 0 || c.rpc == nil { + return + } + stopCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + _, _ = c.rpc.call(stopCtx, "stop_ongoing_process", accountID) + cancel() + + removeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + _, _ = c.rpc.call(removeCtx, "remove_account", accountID) + cancel() +} + +func (c *DeltaChatChannel) getAccountConfigString(ctx context.Context, accountID int64, key string) (string, error) { + raw, err := c.rpc.call(ctx, "get_config", accountID, key) + if err != nil { + return "", fmt.Errorf("get config %s: %w", key, err) + } + var value *string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("decode config %s: %w", key, err) + } + if value == nil { + return "", nil + } + return *value, nil +} + +func (c *DeltaChatChannel) passwordRequiredError(reason string) error { + return fmt.Errorf("deltachat: account %s is not configured in data_dir %s (%s)", + c.config.Email, c.dataDir, reason) +} + +func (c *DeltaChatChannel) applyProfileConfig(ctx context.Context, accountID int64) error { + cfgMap := map[string]*string{} + if name := strings.TrimSpace(c.config.DisplayName); name != "" { + cfgMap["displayname"] = accountConfigString(name) + } + if avatar := strings.TrimSpace(c.config.AvatarImage); avatar != "" { + avatar = expandHome(avatar) + if !fileExists(avatar) { + logger.WarnCF("deltachat", "avatar_image not found; leaving current avatar unchanged", map[string]any{ + "avatar_image": avatar, + }) + } else { + cfgMap["selfavatar"] = accountConfigString(avatar) + } + } + if len(cfgMap) == 0 { + return nil + } + if _, err := c.rpc.call(ctx, "batch_set_config", accountID, cfgMap); err != nil { + return fmt.Errorf("deltachat set profile config: %w", err) + } + return nil +} + +// configureAccount writes the managed account settings and runs the (network-bound) +// provider auto-configuration. +func (c *DeltaChatChannel) configureAccount(ctx context.Context, accountID int64) error { + if c.config.Password.String() == "" { + return c.passwordRequiredError("account is not configured") + } + + cfgMap := accountConfigMap(c.config) + if _, err := c.rpc.call(ctx, "batch_set_config", accountID, cfgMap); err != nil { + return fmt.Errorf("deltachat set account config: %w", err) + } + + logger.InfoCF("deltachat", "Configuring account (validating credentials)", map[string]any{ + "email": c.config.Email, + }) + confCtx, cancel := context.WithTimeout(ctx, configureTimeout) + defer cancel() + if _, err := c.rpc.call(confCtx, "configure", accountID); err != nil { + return fmt.Errorf("deltachat configure (check email/password/server): %w", err) + } + return nil +} + +func (c *DeltaChatChannel) accountConfigChanged(ctx context.Context, accountID int64) (bool, error) { + want := accountConfigMap(c.config) + for _, key := range managedAccountConfigKeys { + raw, err := c.rpc.call(ctx, "get_config", accountID, key) + if err != nil { + return false, fmt.Errorf("deltachat get config %s: %w", key, err) + } + var got *string + if err := json.Unmarshal(raw, &got); err != nil { + return false, fmt.Errorf("deltachat get config %s decode: %w", key, err) + } + if !accountConfigValueEqual(got, want[key]) { + logger.InfoCF("deltachat", "Account config changed; reconfiguring", map[string]any{ + "email": c.config.Email, + "key": key, + }) + return true, nil + } + } + return false, nil +} + +func accountConfigValueEqual(got, want *string) bool { + if want == nil { + return got == nil || *got == "" + } + if got == nil { + return *want == "" + } + return *got == *want +} + +func accountConfigMap(cfg *config.DeltaChatSettings) map[string]*string { + cfgMap := map[string]*string{ + "addr": accountConfigString(cfg.Email), + "mail_server": accountConfigOptionalString(cfg.IMAPServer), + "mail_port": accountConfigOptionalInt(cfg.IMAPPort), + "send_server": accountConfigOptionalString(cfg.SMTPServer), + "send_port": accountConfigOptionalInt(cfg.SMTPPort), + } + if password := cfg.Password.String(); password != "" { + cfgMap["mail_pw"] = accountConfigString(password) + } + return cfgMap +} + +func accountConfigString(value string) *string { + return &value +} + +func accountConfigOptionalString(value string) *string { + if value == "" { + return nil + } + return accountConfigString(value) +} + +func accountConfigOptionalInt(value int) *string { + if value <= 0 { + return nil + } + return accountConfigString(strconv.Itoa(value)) +} + +func (c *DeltaChatChannel) listAccounts(ctx context.Context) ([]dcAccount, error) { + raw, err := c.rpc.call(ctx, "get_all_accounts") + if err != nil { + return nil, fmt.Errorf("deltachat get_all_accounts: %w", err) + } + var accounts []dcAccount + if err := json.Unmarshal(raw, &accounts); err != nil { + return nil, fmt.Errorf("deltachat get_all_accounts decode: %w", err) + } + return accounts, nil +} + +func (c *DeltaChatChannel) isConfigured(ctx context.Context, accountID int64) (bool, error) { + raw, err := c.rpc.call(ctx, "is_configured", accountID) + if err != nil { + return false, fmt.Errorf("deltachat is_configured: %w", err) + } + var ok bool + if err := json.Unmarshal(raw, &ok); err != nil { + return false, fmt.Errorf("deltachat is_configured decode: %w", err) + } + return ok, nil +} + +// joinInviteLink optionally joins a chat via a configured invite/QR link. +func (c *DeltaChatChannel) joinInviteLink(ctx context.Context) error { + link := strings.TrimSpace(c.config.InviteLink) + if link == "" { + return nil + } + chatRaw, err := c.rpc.call(ctx, "secure_join", c.accountID, link) + if err != nil { + return err + } + var chatID int64 + if err := json.Unmarshal(chatRaw, &chatID); err == nil && chatID > 0 { + _, _ = c.rpc.call(ctx, "accept_chat", c.accountID, chatID) + logger.InfoCF("deltachat", "Joined invite chat", map[string]any{"chat_id": chatID}) + } + return nil +} + +// resolveServerPath validates the configured deltachat-rpc-server path, or +// falls back to deltachat-rpc-server on PATH. +func resolveServerPath(configured string) (string, error) { + if configured == "" { + p, err := exec.LookPath("deltachat-rpc-server") + if err != nil { + return "", fmt.Errorf("deltachat: deltachat-rpc-server not found on PATH " + + "(set rpc_server_path to the binary path if it is installed elsewhere)") + } + return p, nil + } + p := expandHome(configured) + if !fileExists(p) { + return "", fmt.Errorf("deltachat: rpc_server_path %q not found", p) + } + return p, nil +} + +// resolveDataDir picks where the account database lives. +func resolveDataDir(configured, channelName string) string { + if configured != "" { + return expandHome(configured) + } + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + name := channelName + if name == "" { + name = config.ChannelDeltaChat + } + return filepath.Join(home, ".picoclaw", "deltachat", name) +} + +func expandHome(path string) string { + if path == "" || path[0] != '~' { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if len(path) == 1 { + return home + } + if path[1] == '/' { + return filepath.Join(home, path[2:]) + } + return path +} + +func fileExists(p string) bool { + info, err := os.Stat(p) + return err == nil && !info.IsDir() +} diff --git a/pkg/channels/deltachat/deltachat_test.go b/pkg/channels/deltachat/deltachat_test.go new file mode 100644 index 000000000..bc46d40ff --- /dev/null +++ b/pkg/channels/deltachat/deltachat_test.go @@ -0,0 +1,1254 @@ +package deltachat + +import ( + "bufio" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +func TestNewDeltaChatChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + + // A fake rpc server so resolveServerPath succeeds regardless of host setup. + fakeServer := filepath.Join(t.TempDir(), "deltachat-rpc-server") + if err := os.WriteFile(fakeServer, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + t.Run("missing email", func(t *testing.T) { + bc := &config.Channel{Type: config.ChannelDeltaChat, Enabled: true} + cfg := &config.DeltaChatSettings{Password: *config.NewSecureString("pw"), RPCServerPath: fakeServer} + _, err := NewDeltaChatChannel(bc, cfg, msgBus) + if err == nil { + t.Fatal("expected error for missing email") + } + if !strings.Contains(err.Error(), "@nine.testrun.org") { + t.Fatalf("error = %v, want bootstrap server guidance", err) + } + if !strings.Contains(err.Error(), "Next step:") || !strings.Contains(err.Error(), "picoclaw g") { + t.Fatalf("error = %v, want next-step guidance", err) + } + }) + + t.Run("bootstrap server marker", func(t *testing.T) { + bc := &config.Channel{Type: config.ChannelDeltaChat, Enabled: true} + cfg := &config.DeltaChatSettings{Email: "@mehl.cloud", RPCServerPath: fakeServer} + if _, err := NewDeltaChatChannel(bc, cfg, msgBus); err != nil { + t.Fatalf("unexpected error for bootstrap marker: %v", err) + } + }) + + t.Run("password optional for existing account reference", func(t *testing.T) { + bc := &config.Channel{Type: config.ChannelDeltaChat, Enabled: true} + cfg := &config.DeltaChatSettings{Email: "bot@example.org", RPCServerPath: fakeServer} + if _, err := NewDeltaChatChannel(bc, cfg, msgBus); err != nil { + t.Fatalf("unexpected error without password: %v", err) + } + }) + + t.Run("missing rpc server", func(t *testing.T) { + bc := &config.Channel{Type: config.ChannelDeltaChat, Enabled: true} + cfg := &config.DeltaChatSettings{ + Email: "bot@example.org", + Password: *config.NewSecureString("pw"), + RPCServerPath: filepath.Join(t.TempDir(), "does-not-exist"), + } + if _, err := NewDeltaChatChannel(bc, cfg, msgBus); err == nil { + t.Error("expected error for missing rpc server path") + } + }) + + t.Run("valid config", func(t *testing.T) { + bc := &config.Channel{Type: config.ChannelDeltaChat, Enabled: true} + cfg := &config.DeltaChatSettings{ + Email: "bot@example.org", + Password: *config.NewSecureString("pw"), + RPCServerPath: fakeServer, + DataDir: t.TempDir(), + } + ch, err := NewDeltaChatChannel(bc, cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != config.ChannelDeltaChat { + t.Errorf("Name() = %q, want %q", ch.Name(), config.ChannelDeltaChat) + } + if ch.IsRunning() { + t.Error("new channel should not be running") + } + }) +} + +func TestResolveServerPathUsesPATH(t *testing.T) { + dir := t.TempDir() + fakeServer := filepath.Join(dir, "deltachat-rpc-server") + if err := os.WriteFile(fakeServer, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + + got, err := resolveServerPath("") + if err != nil { + t.Fatalf("resolveServerPath: %v", err) + } + if got != fakeServer { + t.Fatalf("resolveServerPath() = %q, want %q", got, fakeServer) + } +} + +func TestMentionsBot(t *testing.T) { + tests := []struct { + name string + content string + displayName string + email string + want bool + }{ + {"display name", "hey PicoBot can you help", "PicoBot", "bot@example.org", true}, + {"case insensitive name", "hey picobot", "PicoBot", "bot@example.org", true}, + {"short display name exact", "hey bot can you help", "bot", "bot@example.org", true}, + {"short display name with punctuation", "AI, summarize this", "ai", "bot@example.org", true}, + {"multi word display name", "hey PicoClaw Bot, can you help", "PicoClaw Bot", "bot@example.org", true}, + {"email local part", "@bot please summarize", "", "bot@example.org", true}, + {"email local part with punctuation", "please summarize, @bot.", "", "bot@example.org", true}, + {"no mention", "just chatting here", "PicoBot", "bot@example.org", false}, + {"local part without @", "the robot is cool", "", "bot@example.org", false}, + {"short display name inside word", "the robot is cool", "bot", "bot@example.org", false}, + {"short display name inside mail", "please email me later", "ai", "bot@example.org", false}, + {"display name with prefix word", "hey SuperPicoClaw Bot", "PicoClaw Bot", "bot@example.org", false}, + {"email local part inside handle", "hello @botanic", "", "bot@example.org", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mentionsBot(tt.content, tt.displayName, tt.email); got != tt.want { + t.Errorf("mentionsBot(%q, %q, %q) = %v, want %v", tt.content, tt.displayName, tt.email, got, tt.want) + } + }) + } +} + +func TestExpandHome(t *testing.T) { + home, _ := os.UserHomeDir() + tests := []struct { + in string + want string + }{ + {"", ""}, + {"/abs/path", "/abs/path"}, + {"~", home}, + {"~/sub", filepath.Join(home, "sub")}, + {"relative", "relative"}, + } + for _, tt := range tests { + if got := expandHome(tt.in); got != tt.want { + t.Errorf("expandHome(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestResolveDataDir(t *testing.T) { + if got := resolveDataDir("/explicit/dir", "x"); got != "/explicit/dir" { + t.Errorf("explicit data dir = %q, want /explicit/dir", got) + } + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".picoclaw", "deltachat", "mychan") + if got := resolveDataDir("", "mychan"); got != want { + t.Errorf("default data dir = %q, want %q", got, want) + } +} + +func TestHandleMessageMarksSeenOnlyAfterDispatch(t *testing.T) { + tests := []struct { + name string + chatType string + mentionOnly bool + closeBus bool + wantSeen bool + }{ + {name: "successful dispatch", chatType: chatTypeSingle, wantSeen: true}, + {name: "ignored group trigger", chatType: "Group", mentionOnly: true}, + {name: "failed local publish", chatType: chatTypeSingle, closeBus: true}, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + messageID := int64(42 + i) + chat := dcChat{ID: 99, Name: "chat", ChatType: tt.chatType} + msgBus := bus.NewMessageBus() + if tt.closeBus { + msgBus.Close() + } else { + defer msgBus.Close() + } + + ch := newTestChannelWithBus(t, msgBus, func(bc *config.Channel) { + bc.GroupTrigger.MentionOnly = tt.mentionOnly + }) + ch.ctx = context.Background() + ch.accountID = 7 + ch.selfAddr = "bot@example.org" + + markSeen := make(chan struct{}, 1) + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "get_message": + return rpcResult(req, dcMessage{ + ID: messageID, + ChatID: chat.ID, + Text: "hello", + Sender: &dcContact{Address: "alice@example.org", DisplayName: "Alice"}, + }) + case "get_full_chat_by_id": + return rpcResult(req, chat) + case "markseen_msgs": + markSeen <- struct{}{} + return rpcResult(req, nil) + default: + return rpcUnexpectedMethod(req) + } + }) + defer cleanup() + ch.rpc = rpc + + ch.handleMessage(messageID) + + gotSeen := false + select { + case <-markSeen: + gotSeen = true + default: + } + if gotSeen != tt.wantSeen { + t.Fatalf("markseen called = %v, want %v", gotSeen, tt.wantSeen) + } + }) + } +} + +func TestDeltaChatSettingsDecode(t *testing.T) { + raw := []byte(`{ + "enabled": true, + "type": "deltachat", + "allow_from": ["alice@example.org"], + "settings": { + "email": "bot@example.org", + "display_name": "PicoBot", + "avatar_image": "/tmp/picobot.png", + "allow_crosspost": true, + "imap_port": 993 + } + }`) + var bc config.Channel + if err := json.Unmarshal(raw, &bc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + bc.Type = config.ChannelDeltaChat + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("decode: %v", err) + } + cfg, ok := decoded.(*config.DeltaChatSettings) + if !ok { + t.Fatalf("decoded type = %T, want *config.DeltaChatSettings", decoded) + } + if cfg.Email != "bot@example.org" { + t.Errorf("email = %q, want bot@example.org", cfg.Email) + } + if cfg.DisplayName != "PicoBot" { + t.Errorf("display_name = %q, want PicoBot", cfg.DisplayName) + } + if cfg.AvatarImage != "/tmp/picobot.png" { + t.Errorf("avatar_image = %q, want /tmp/picobot.png", cfg.AvatarImage) + } + if cfg.IMAPPort != 993 { + t.Errorf("imap_port = %d, want 993", cfg.IMAPPort) + } + if !cfg.AllowCrosspost { + t.Error("allow_crosspost = false, want true") + } +} + +func TestEnsureAccountReconfiguresConfiguredAccountWhenSettingsChange(t *testing.T) { + ch := newTestChannel(t) + ch.config.DisplayName = "New Bot" + ch.config.IMAPServer = "imap.example.org" + ch.config.IMAPPort = 993 + ch.config.SMTPServer = "smtp.example.org" + ch.config.SMTPPort = 587 + + configureCalls := 0 + accountConfigCalls := 0 + var capturedConfig map[string]any + + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "get_all_accounts": + return rpcResult(req, []dcAccount{{ID: 7, Kind: "Configured", Addr: "bot@example.org"}}) + case "is_configured": + return rpcResult(req, true) + case "get_config": + key, _ := req.Params[1].(string) + current := map[string]*string{ + "addr": strPtr("bot@example.org"), + "mail_pw": strPtr("old-pw"), + "displayname": strPtr("Old Bot"), + } + return rpcResult(req, current[key]) + case "batch_set_config": + if cfg, ok := req.Params[1].(map[string]any); ok { + if _, ok := cfg["mail_pw"]; ok { + accountConfigCalls++ + capturedConfig = cfg + } + } + return rpcResult(req, nil) + case "configure": + configureCalls++ + return rpcResult(req, nil) + case "select_account", "start_io": + return rpcResult(req, nil) + default: + return rpcUnexpectedMethod(req) + } + }) + defer cleanup() + ch.rpc = rpc + + if err := ch.ensureAccount(context.Background()); err != nil { + t.Fatalf("ensureAccount: %v", err) + } + if configureCalls != 1 { + t.Fatalf("configure calls = %d, want 1", configureCalls) + } + if accountConfigCalls != 1 { + t.Fatalf("account batch_set_config calls = %d, want 1", accountConfigCalls) + } + if capturedConfig["mail_pw"] != "pw" { + t.Errorf("mail_pw = %v, want pw", capturedConfig["mail_pw"]) + } + if capturedConfig["mail_server"] != "imap.example.org" { + t.Errorf("mail_server = %v, want imap.example.org", capturedConfig["mail_server"]) + } + if capturedConfig["mail_port"] != "993" { + t.Errorf("mail_port = %v, want 993", capturedConfig["mail_port"]) + } + if capturedConfig["send_server"] != "smtp.example.org" { + t.Errorf("send_server = %v, want smtp.example.org", capturedConfig["send_server"]) + } + if capturedConfig["send_port"] != "587" { + t.Errorf("send_port = %v, want 587", capturedConfig["send_port"]) + } +} + +func TestEnsureAccountSkipsConfiguredAccountWhenSettingsMatch(t *testing.T) { + ch := newTestChannel(t) + ch.config.DisplayName = "Pico Bot" + ch.config.IMAPServer = "imap.example.org" + ch.config.IMAPPort = 993 + ch.config.SMTPServer = "smtp.example.org" + ch.config.SMTPPort = 587 + + configureCalls := 0 + accountConfigCalls := 0 + + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "get_all_accounts": + return rpcResult(req, []dcAccount{{ID: 7, Kind: "Configured", Addr: "bot@example.org"}}) + case "is_configured": + return rpcResult(req, true) + case "get_config": + key, _ := req.Params[1].(string) + current := map[string]*string{ + "addr": strPtr("bot@example.org"), + "mail_pw": strPtr("pw"), + "displayname": strPtr("Pico Bot"), + "mail_server": strPtr("imap.example.org"), + "mail_port": strPtr("993"), + "send_server": strPtr("smtp.example.org"), + "send_port": strPtr("587"), + } + return rpcResult(req, current[key]) + case "batch_set_config": + if cfg, ok := req.Params[1].(map[string]any); ok { + if _, ok := cfg["mail_pw"]; ok { + accountConfigCalls++ + } + } + return rpcResult(req, nil) + case "configure": + configureCalls++ + return rpcResult(req, nil) + case "select_account", "start_io": + return rpcResult(req, nil) + default: + return rpcUnexpectedMethod(req) + } + }) + defer cleanup() + ch.rpc = rpc + + if err := ch.ensureAccount(context.Background()); err != nil { + t.Fatalf("ensureAccount: %v", err) + } + if configureCalls != 0 { + t.Fatalf("configure calls = %d, want 0", configureCalls) + } + if accountConfigCalls != 0 { + t.Fatalf("account batch_set_config calls = %d, want 0", accountConfigCalls) + } +} + +func TestEnsureAccountCreatesBootstrapAccountAndStops(t *testing.T) { + ch := newTestChannel(t) + ch.config.Password = config.SecureString{} + ch.config.Email = "@mehl.cloud" + + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "add_account": + return rpcResult(req, int64(9)) + case "add_transport_from_qr": + if req.Params[0] != float64(9) { + t.Fatalf("account id = %v, want 9", req.Params[0]) + } + if req.Params[1] != "DCACCOUNT:https://mehl.cloud/new" { + t.Fatalf("qr = %v", req.Params[1]) + } + return rpcResult(req, nil) + case "get_config": + if req.Params[1] != "addr" { + t.Fatalf("get_config key = %v, want addr", req.Params[1]) + } + return rpcResult(req, "bot123@mehl.cloud") + default: + return rpcUnexpectedMethod(req) + } + }) + defer cleanup() + ch.rpc = rpc + + err := ch.ensureAccount(context.Background()) + if err == nil { + t.Fatal("expected created-account instruction error") + } + if !strings.Contains(err.Error(), "bot123@mehl.cloud") || !strings.Contains(err.Error(), "run PicoClaw again") { + t.Fatalf("error = %v, want generated email and rerun instruction", err) + } +} + +func TestEnsureAccountUsesConfiguredAccountWithoutPassword(t *testing.T) { + ch := newTestChannel(t) + ch.config.Password = config.SecureString{} + ch.config.DisplayName = "Local Bot" + avatar := filepath.Join(t.TempDir(), "avatar.png") + if err := os.WriteFile(avatar, []byte("png"), 0o644); err != nil { + t.Fatal(err) + } + ch.config.AvatarImage = avatar + + profileConfigCalls := 0 + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "get_all_accounts": + return rpcResult(req, []dcAccount{{ID: 7, Kind: "Configured", Addr: "bot@example.org"}}) + case "is_configured": + return rpcResult(req, true) + case "batch_set_config": + cfg, _ := req.Params[1].(map[string]any) + if _, ok := cfg["bot"]; ok { + return rpcResult(req, nil) + } + profileConfigCalls++ + if cfg["displayname"] != "Local Bot" { + t.Fatalf("displayname = %v, want Local Bot", cfg["displayname"]) + } + if cfg["selfavatar"] != avatar { + t.Fatalf("selfavatar = %v, want %s", cfg["selfavatar"], avatar) + } + return rpcResult(req, nil) + case "select_account", "start_io": + return rpcResult(req, nil) + default: + return rpcUnexpectedMethod(req) + } + }) + defer cleanup() + ch.rpc = rpc + + if err := ch.ensureAccount(context.Background()); err != nil { + t.Fatalf("ensureAccount: %v", err) + } + if profileConfigCalls != 1 { + t.Fatalf("profile config calls = %d, want 1", profileConfigCalls) + } + if ch.accountID != 7 { + t.Fatalf("accountID = %d, want 7", ch.accountID) + } +} + +func TestEnsureAccountSkipsMissingAvatarImage(t *testing.T) { + ch := newTestChannel(t) + ch.config.Password = config.SecureString{} + ch.config.AvatarImage = filepath.Join(t.TempDir(), "missing.png") + + profileConfigCalls := 0 + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "get_all_accounts": + return rpcResult(req, []dcAccount{{ID: 7, Kind: "Configured", Addr: "bot@example.org"}}) + case "is_configured": + return rpcResult(req, true) + case "batch_set_config": + cfg, _ := req.Params[1].(map[string]any) + if _, ok := cfg["bot"]; !ok { + profileConfigCalls++ + } + return rpcResult(req, nil) + case "select_account", "start_io": + return rpcResult(req, nil) + default: + return rpcUnexpectedMethod(req) + } + }) + defer cleanup() + ch.rpc = rpc + + if err := ch.ensureAccount(context.Background()); err != nil { + t.Fatalf("ensureAccount: %v", err) + } + if profileConfigCalls != 0 { + t.Fatalf("profile config calls = %d, want 0", profileConfigCalls) + } +} + +func TestEnsureAccountRequiresPasswordWhenAccountMissing(t *testing.T) { + ch := newTestChannel(t) + ch.config.Password = config.SecureString{} + + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "get_all_accounts": + return rpcResult(req, []dcAccount{}) + default: + return rpcUnexpectedMethod(req) + } + }) + defer cleanup() + ch.rpc = rpc + + err := ch.ensureAccount(context.Background()) + if err == nil { + t.Fatal("expected password-required error") + } + if !strings.Contains(err.Error(), "is not configured") { + t.Fatalf("error = %v, want not-configured error", err) + } +} + +func TestEnsureAccountClearsRemovedOptionalSettings(t *testing.T) { + ch := newTestChannel(t) + + var capturedConfig map[string]any + + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "get_all_accounts": + return rpcResult(req, []dcAccount{{ID: 7, Kind: "Configured", Addr: "bot@example.org"}}) + case "is_configured": + return rpcResult(req, true) + case "get_config": + key, _ := req.Params[1].(string) + current := map[string]*string{ + "addr": strPtr("bot@example.org"), + "mail_pw": strPtr("pw"), + "displayname": strPtr("Old Bot"), + "mail_server": strPtr("imap.example.org"), + "mail_port": strPtr("993"), + "send_server": strPtr("smtp.example.org"), + "send_port": strPtr("587"), + } + return rpcResult(req, current[key]) + case "batch_set_config": + if cfg, ok := req.Params[1].(map[string]any); ok { + if _, ok := cfg["mail_pw"]; ok { + capturedConfig = cfg + } + } + return rpcResult(req, nil) + case "configure", "select_account", "start_io": + return rpcResult(req, nil) + default: + return rpcUnexpectedMethod(req) + } + }) + defer cleanup() + ch.rpc = rpc + + if err := ch.ensureAccount(context.Background()); err != nil { + t.Fatalf("ensureAccount: %v", err) + } + if capturedConfig == nil { + t.Fatal("account batch_set_config was not called") + } + for _, key := range []string{"mail_server", "mail_port", "send_server", "send_port"} { + if value, ok := capturedConfig[key]; !ok || value != nil { + t.Errorf("%s = %v (present %v), want explicit null", key, value, ok) + } + } + if capturedConfig["addr"] != "bot@example.org" { + t.Errorf("addr = %v, want bot@example.org", capturedConfig["addr"]) + } + if capturedConfig["mail_pw"] != "pw" { + t.Errorf("mail_pw = %v, want pw", capturedConfig["mail_pw"]) + } +} + +// TestRPCClientRoundTrip drives the JSON-RPC client against an in-process mock +// server over pipes, verifying id correlation and error propagation. +func TestRPCClientRoundTrip(t *testing.T) { + reqR, reqW := io.Pipe() // client -> server + respR, respW := io.Pipe() // server -> client + + c := &rpcClient{ + stdin: reqW, + stdout: respR, + pending: make(map[uint64]chan rpcResponse), + } + go c.readLoop() + + // Mock server: echo method "ping" -> "pong", anything else -> error. + go func() { + scanner := bufio.NewScanner(reqR) + for scanner.Scan() { + var req rpcRequest + if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { + continue + } + var resp string + if req.Method == "ping" { + resp = `{"jsonrpc":"2.0","id":` + itoa(req.ID) + `,"result":"pong"}` + } else { + resp = `{"jsonrpc":"2.0","id":` + itoa(req.ID) + `,"error":{"code":-1,"message":"boom"}}` + } + _, _ = respW.Write([]byte(resp + "\n")) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + raw, err := c.call(ctx, "ping") + if err != nil { + t.Fatalf("ping call: %v", err) + } + var result string + if err := json.Unmarshal(raw, &result); err != nil || result != "pong" { + t.Fatalf("ping result = %q (err %v), want pong", result, err) + } + + if _, err := c.call(ctx, "explode"); err == nil { + t.Fatal("expected error from explode call") + } +} + +func itoa(n uint64) string { + b, _ := json.Marshal(n) + return string(b) +} + +// newTestChannel builds a DeltaChatChannel with a valid config (backed by a fake +// rpc-server binary) but without starting any IO, for unit-testing methods in +// isolation. +func newTestChannel(t *testing.T) *DeltaChatChannel { + return newTestChannelWithBus(t, bus.NewMessageBus(), nil) +} + +func newTestChannelWithBus(t *testing.T, msgBus *bus.MessageBus, configure func(*config.Channel)) *DeltaChatChannel { + t.Helper() + fakeServer := filepath.Join(t.TempDir(), "deltachat-rpc-server") + if err := os.WriteFile(fakeServer, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + bc := &config.Channel{Type: config.ChannelDeltaChat, Enabled: true} + if configure != nil { + configure(bc) + } + cfg := &config.DeltaChatSettings{ + Email: "bot@example.org", + Password: *config.NewSecureString("pw"), + RPCServerPath: fakeServer, + DataDir: t.TempDir(), + } + ch, err := NewDeltaChatChannel(bc, cfg, msgBus) + if err != nil { + t.Fatalf("new channel: %v", err) + } + return ch +} + +// newMockRPC wires an rpcClient to an in-process server that replies to every +// request with handler(req), so methods that call the rpc can be tested without +// a real deltachat-rpc-server. +func newMockRPC(t *testing.T, handler func(req rpcRequest) string) (*rpcClient, func()) { + t.Helper() + reqR, reqW := io.Pipe() + respR, respW := io.Pipe() + c := &rpcClient{ + stdin: reqW, + stdout: respR, + pending: make(map[uint64]chan rpcResponse), + } + go c.readLoop() + go func() { + scanner := bufio.NewScanner(reqR) + for scanner.Scan() { + var req rpcRequest + if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { + continue + } + _, _ = respW.Write([]byte(handler(req) + "\n")) + } + }() + return c, func() { _ = reqW.Close(); _ = respW.Close() } +} + +func rpcResult(req rpcRequest, result any) string { + raw, _ := json.Marshal(result) + return `{"jsonrpc":"2.0","id":` + itoa(req.ID) + `,"result":` + string(raw) + `}` +} + +func rpcUnexpectedMethod(req rpcRequest) string { + return `{"jsonrpc":"2.0","id":` + itoa(req.ID) + `,"error":{"code":-32601,"message":"unexpected method"}}` +} + +func strPtr(value string) *string { + return &value +} + +// TestMessageDataJSON pins the camelCase keys and omitempty behavior expected by +// Delta Chat's send_msg MessageData parameter. +func TestMessageDataJSON(t *testing.T) { + raw, err := json.Marshal(dcMessageData{File: "/tmp/x.png"}) + if err != nil { + t.Fatal(err) + } + if got := string(raw); got != `{"file":"/tmp/x.png"}` { + t.Errorf("json = %s, want only the file field", got) + } + + raw, _ = json.Marshal(dcMessageData{Text: "hi", File: "/f", Filename: "f.bin"}) + if got := string(raw); got != `{"text":"hi","file":"/f","filename":"f.bin"}` { + t.Errorf("json = %s, want text/file/filename in camelCase", got) + } +} + +// TestRegisterInboundFile checks that an inbound attachment is copied out of +// Delta Chat's account directory into the tool-readable media temp dir and +// registered with delete-on-cleanup, and that the absence of a store yields an +// empty ref for the annotation fallback. +func TestRegisterInboundFile(t *testing.T) { + ch := newTestChannel(t) + + tmp := filepath.Join(t.TempDir(), "doc.pdf") + if err := os.WriteFile(tmp, []byte("%PDF-1.4"), 0o644); err != nil { + t.Fatal(err) + } + msg := &dcMessage{File: tmp, FileName: "doc.pdf", FileMime: "application/pdf"} + + if ref := ch.registerInboundFile("scope", msg); ref != "" { + t.Errorf("ref without media store = %q, want empty", ref) + } + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + ref := ch.registerInboundFile("scope", msg) + if !strings.HasPrefix(ref, "media://") { + t.Fatalf("ref = %q, want a media:// ref", ref) + } + path, meta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("resolve: %v", err) + } + t.Cleanup(func() { _ = os.Remove(path) }) + + // The registered path must be a copy in the media temp dir (tool-readable), + // not the original blob path, and must have the same contents. + if path == tmp { + t.Errorf("path = %q, want a copy in the media temp dir, not the blob path", path) + } + if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(media.TempDir())) { + t.Errorf("path = %q, want it under media temp dir %q", path, media.TempDir()) + } + if data, rerr := os.ReadFile(path); rerr != nil || string(data) != "%PDF-1.4" { + t.Errorf("copied file contents = %q (err %v), want %q", string(data), rerr, "%PDF-1.4") + } + if meta.ContentType != "application/pdf" { + t.Errorf("content type = %q, want application/pdf", meta.ContentType) + } + if meta.CleanupPolicy != media.CleanupPolicyDeleteOnCleanup { + t.Errorf("cleanup policy = %q, want delete_on_cleanup", meta.CleanupPolicy) + } +} + +func TestSend_CurrentNumericChatIDAllowedWithoutRecipientResolution(t *testing.T) { + ch := newTestChannel(t) + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + if req.Method != "misc_send_msg" { + return rpcUnexpectedMethod(req) + } + if got, _ := req.Params[1].(float64); got != 42 { + t.Fatalf("chat id = %v, want 42", req.Params[1]) + } + return rpcResult(req, []any{int64(1001), map[string]any{}}) + }) + ch.accountID = 7 + ch.SetRunning(true) + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "42", + Content: "hello", + Context: bus.InboundContext{ChatID: "42", SenderID: "friend@example.org"}, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if len(ids) != 1 || ids[0] != "1001" { + t.Fatalf("ids = %v, want [1001]", ids) + } +} + +func TestSend_CrossChatNumericDeniedByDefault(t *testing.T) { + ch := newTestChannel(t) + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + t.Fatalf("unexpected rpc call: %s", req.Method) + return rpcUnexpectedMethod(req) + }) + ch.accountID = 7 + ch.SetRunning(true) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "99", + Content: "hello", + Context: bus.InboundContext{ChatID: "42", SenderID: "admin@example.org"}, + }) + if err == nil || !strings.Contains(err.Error(), "allow_crosspost") { + t.Fatalf("Send error = %v, want crosspost recipient gate", err) + } +} + +func TestSend_EmailRecipientDeniedByDefault(t *testing.T) { + ch := newTestChannel(t) + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + t.Fatalf("unexpected rpc call: %s", req.Method) + return rpcUnexpectedMethod(req) + }) + ch.accountID = 7 + ch.SetRunning(true) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "friend@example.org", + Content: "hello", + Context: bus.InboundContext{ChatID: "42", SenderID: "admin@example.org"}, + }) + if err == nil || !strings.Contains(err.Error(), "allow_crosspost") { + t.Fatalf("Send error = %v, want crosspost recipient gate", err) + } +} + +func TestSend_EmailRecipientRequiresAllowFrom(t *testing.T) { + ch := newTestChannelWithBus(t, bus.NewMessageBus(), func(bc *config.Channel) { + bc.AllowFrom = config.FlexibleStringSlice{"admin@example.org"} + }) + ch.config.AllowCrosspost = true + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + t.Fatalf("unexpected rpc call: %s", req.Method) + return rpcUnexpectedMethod(req) + }) + ch.accountID = 7 + ch.SetRunning(true) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "friend@example.org", + Content: "hello", + Context: bus.InboundContext{ChatID: "42", SenderID: "other@example.org"}, + }) + if err == nil || !strings.Contains(err.Error(), "allow_from") { + t.Fatalf("Send error = %v, want allow_from gate", err) + } +} + +func TestSend_EmailRecipientUsesSessionScopeSenderForAdmin(t *testing.T) { + ch := newTestChannelWithBus(t, bus.NewMessageBus(), func(bc *config.Channel) { + bc.AllowFrom = config.FlexibleStringSlice{"admin@example.org"} + }) + ch.config.AllowCrosspost = true + + var sentChatID float64 + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "lookup_contact_id_by_addr": + return rpcResult(req, int64(11)) + case "get_chat_id_by_contact_id": + return rpcResult(req, int64(99)) + case "misc_send_msg": + sentChatID, _ = req.Params[1].(float64) + return rpcResult(req, []any{int64(1233), map[string]any{}}) + default: + return rpcUnexpectedMethod(req) + } + }) + ch.accountID = 7 + ch.SetRunning(true) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "friend@example.org", + Content: "hello", + Context: bus.InboundContext{ChatID: "friend@example.org"}, + Scope: &bus.OutboundScope{ + Channel: config.ChannelDeltaChat, + Values: map[string]string{ + "chat": "direct:42", + "sender": "admin@example.org", + }, + }, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if sentChatID != 99 { + t.Fatalf("sent chat id = %v, want 99", sentChatID) + } +} + +func TestSend_EmailRecipientResolvesForAllowFromWildcard(t *testing.T) { + ch := newTestChannelWithBus(t, bus.NewMessageBus(), func(bc *config.Channel) { + bc.AllowFrom = config.FlexibleStringSlice{"*"} + }) + ch.config.AllowCrosspost = true + + var sentChatID float64 + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "lookup_contact_id_by_addr": + return rpcResult(req, int64(11)) + case "get_chat_id_by_contact_id": + return rpcResult(req, int64(99)) + case "misc_send_msg": + sentChatID, _ = req.Params[1].(float64) + return rpcResult(req, []any{int64(1236), map[string]any{}}) + default: + return rpcUnexpectedMethod(req) + } + }) + ch.accountID = 7 + ch.SetRunning(true) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "friend@example.org", + Content: "hello", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if sentChatID != 99 { + t.Fatalf("sent chat id = %v, want 99", sentChatID) + } +} + +func TestSend_CrossChatNumericUsesSessionScopeChatForGate(t *testing.T) { + ch := newTestChannel(t) + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + t.Fatalf("unexpected rpc call: %s", req.Method) + return rpcUnexpectedMethod(req) + }) + ch.accountID = 7 + ch.SetRunning(true) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "99", + Content: "hello", + Context: bus.InboundContext{ChatID: "99", SenderID: "admin@example.org"}, + Scope: &bus.OutboundScope{ + Channel: config.ChannelDeltaChat, + Values: map[string]string{"chat": "direct:42"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "allow_crosspost") { + t.Fatalf("Send error = %v, want crosspost recipient gate", err) + } +} + +func TestSend_EmailRecipientResolvesForAdmin(t *testing.T) { + ch := newTestChannelWithBus(t, bus.NewMessageBus(), func(bc *config.Channel) { + bc.AllowFrom = config.FlexibleStringSlice{"admin@example.org"} + }) + ch.config.AllowCrosspost = true + + var sentChatID float64 + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "lookup_contact_id_by_addr": + if req.Params[1] != "friend@example.org" { + t.Fatalf("lookup addr = %v, want friend@example.org", req.Params[1]) + } + return rpcResult(req, int64(11)) + case "get_chat_id_by_contact_id": + return rpcResult(req, nil) + case "create_chat_by_contact_id": + return rpcResult(req, int64(99)) + case "misc_send_msg": + sentChatID, _ = req.Params[1].(float64) + return rpcResult(req, []any{int64(1234), map[string]any{}}) + default: + return rpcUnexpectedMethod(req) + } + }) + ch.accountID = 7 + ch.SetRunning(true) + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "Friend ", + Content: "hello", + Context: bus.InboundContext{ChatID: "42", SenderID: "admin@example.org"}, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if sentChatID != 99 { + t.Fatalf("sent chat id = %v, want 99", sentChatID) + } + if len(ids) != 1 || ids[0] != "1234" { + t.Fatalf("ids = %v, want [1234]", ids) + } +} + +func TestSend_AliasRecipientResolvesForAdmin(t *testing.T) { + ch := newTestChannelWithBus(t, bus.NewMessageBus(), func(bc *config.Channel) { + bc.AllowFrom = config.FlexibleStringSlice{"admin@example.org"} + }) + ch.config.AllowCrosspost = true + + var sentChatID float64 + ch.rpc, _ = newMockRPC(t, func(req rpcRequest) string { + switch req.Method { + case "get_contacts": + if req.Params[2] != "Alice" { + t.Fatalf("contact query = %v, want Alice", req.Params[2]) + } + return rpcResult(req, []dcContact{{ID: 12, Address: "alice@example.org", DisplayName: "Alice"}}) + case "get_chat_id_by_contact_id": + return rpcResult(req, int64(88)) + case "misc_send_msg": + sentChatID, _ = req.Params[1].(float64) + return rpcResult(req, []any{int64(1235), map[string]any{}}) + default: + return rpcUnexpectedMethod(req) + } + }) + ch.accountID = 7 + ch.SetRunning(true) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "Alice", + Content: "hello", + Context: bus.InboundContext{ChatID: "42", SenderID: "admin@example.org"}, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if sentChatID != 88 { + t.Fatalf("sent chat id = %v, want 88", sentChatID) + } +} + +// TestSendMedia verifies SendMedia resolves a media ref to a local path and +// drives send_msg with the expected MessageData, returning the new message id. +func TestSendMedia(t *testing.T) { + ch := newTestChannel(t) + + tmp := filepath.Join(t.TempDir(), "photo.png") + if err := os.WriteFile(tmp, []byte("\x89PNGfake"), 0o644); err != nil { + t.Fatal(err) + } + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + ref, err := store.Store(tmp, media.MediaMeta{Filename: "photo.png"}, "scope") + if err != nil { + t.Fatal(err) + } + + captured := make(chan rpcRequest, 1) + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + captured <- req + return `{"jsonrpc":"2.0","id":` + itoa(req.ID) + `,"result":4242}` + }) + defer cleanup() + ch.rpc = rpc + ch.accountID = 7 + ch.SetRunning(true) + + ids, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "99", + Parts: []bus.MediaPart{{ + Type: "image", + Ref: ref, + Caption: "hello", + Filename: "photo.png", + }}, + }) + if err != nil { + t.Fatalf("SendMedia: %v", err) + } + if len(ids) != 1 || ids[0] != "4242" { + t.Fatalf("ids = %v, want [4242]", ids) + } + + select { + case req := <-captured: + if req.Method != "send_msg" { + t.Errorf("method = %q, want send_msg", req.Method) + } + if len(req.Params) != 3 { + t.Fatalf("params = %v, want [accountID, chatID, data]", req.Params) + } + if got, _ := req.Params[0].(float64); got != 7 { + t.Errorf("account id = %v, want 7", req.Params[0]) + } + if got, _ := req.Params[1].(float64); got != 99 { + t.Errorf("chat id = %v, want 99", req.Params[1]) + } + data, ok := req.Params[2].(map[string]any) + if !ok { + t.Fatalf("data param = %T, want object", req.Params[2]) + } + if data["file"] != tmp { + t.Errorf("file = %v, want %s", data["file"], tmp) + } + if data["text"] != "hello" { + t.Errorf("text = %v, want hello", data["text"]) + } + if data["filename"] != "photo.png" { + t.Errorf("filename = %v, want photo.png", data["filename"]) + } + if _, present := data["viewtype"]; present { + t.Errorf("viewtype should be omitted (Delta Chat infers it), got %v", data["viewtype"]) + } + case <-time.After(2 * time.Second): + t.Fatal("mock server never received the request") + } +} + +// TestSendMediaNoStore ensures SendMedia fails cleanly without a media store. +func TestSendMediaNoStore(t *testing.T) { + ch := newTestChannel(t) + ch.SetRunning(true) + if _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ChatID: "1"}); err == nil { + t.Error("expected error when no media store is configured") + } +} + +// TestSendMediaVoice verifies that a send_tts-sourced audio part is delivered +// with viewtype "Voice" so Delta Chat renders it as a voice bubble. +func TestSendMediaVoice(t *testing.T) { + ch := newTestChannel(t) + + tmp := filepath.Join(t.TempDir(), "tts-123.ogg") + if err := os.WriteFile(tmp, []byte("OggSfake"), 0o644); err != nil { + t.Fatal(err) + } + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + ref, err := store.Store(tmp, media.MediaMeta{ + Filename: "tts-123.ogg", + ContentType: "audio/ogg", + Source: "tool:send_tts", + }, "scope") + if err != nil { + t.Fatal(err) + } + + captured := make(chan rpcRequest, 1) + rpc, cleanup := newMockRPC(t, func(req rpcRequest) string { + captured <- req + return `{"jsonrpc":"2.0","id":` + itoa(req.ID) + `,"result":7}` + }) + defer cleanup() + ch.rpc = rpc + ch.accountID = 1 + ch.SetRunning(true) + + if _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "5", + Parts: []bus.MediaPart{{Type: "audio", Ref: ref, ContentType: "audio/ogg"}}, + }); err != nil { + t.Fatalf("SendMedia: %v", err) + } + + select { + case req := <-captured: + data, ok := req.Params[2].(map[string]any) + if !ok { + t.Fatalf("data param = %T, want object", req.Params[2]) + } + if data["viewtype"] != "Voice" { + t.Errorf("viewtype = %v, want Voice", data["viewtype"]) + } + case <-time.After(2 * time.Second): + t.Fatal("mock server never received the request") + } +} + +// TestDeltaChatViewtype pins the rule that only voice audio is forced to a view +// type; everything else is left to Delta Chat's own detection. +func TestDeltaChatViewtype(t *testing.T) { + tests := []struct { + name string + part bus.MediaPart + meta media.MediaMeta + want string + }{ + { + "tts audio", + bus.MediaPart{Type: "audio"}, + media.MediaMeta{Source: "tool:send_tts", ContentType: "audio/ogg"}, + "Voice", + }, + {"voice filename", bus.MediaPart{Type: "audio", Filename: "my-voice.mp3"}, media.MediaMeta{}, "Voice"}, + { + "plain audio", + bus.MediaPart{Type: "audio", Filename: "song.mp3"}, + media.MediaMeta{ContentType: "audio/mpeg"}, + "", + }, + {"image", bus.MediaPart{Type: "image", Filename: "photo.png"}, media.MediaMeta{ContentType: "image/png"}, ""}, + {"file", bus.MediaPart{Type: "file", Filename: "doc.pdf"}, media.MediaMeta{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := deltaChatViewtype(tt.part, tt.meta); got != tt.want { + t.Errorf("deltaChatViewtype() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestVoiceCapabilities checks that Delta Chat advertises ASR and TTS so the +// gateway's startup capability log is accurate. +func TestVoiceCapabilities(t *testing.T) { + ch := newTestChannel(t) + caps := ch.VoiceCapabilities() + if !caps.ASR || !caps.TTS { + t.Errorf("VoiceCapabilities() = %+v, want both ASR and TTS true", caps) + } +} diff --git a/pkg/channels/deltachat/handler.go b/pkg/channels/deltachat/handler.go new file mode 100644 index 000000000..724b73eea --- /dev/null +++ b/pkg/channels/deltachat/handler.go @@ -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 == '_' +} diff --git a/pkg/channels/deltachat/init.go b/pkg/channels/deltachat/init.go new file mode 100644 index 000000000..85fdee0c2 --- /dev/null +++ b/pkg/channels/deltachat/init.go @@ -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 + }, + ) +} diff --git a/pkg/channels/deltachat/rpc.go b/pkg/channels/deltachat/rpc.go new file mode 100644 index 000000000..a88355dbb --- /dev/null +++ b/pkg/channels/deltachat/rpc.go @@ -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 +} diff --git a/pkg/config/config.go b/pkg/config/config.go index aa643d0ac..df232f771 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -599,6 +599,31 @@ type MatrixSettings struct { 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 { 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"` diff --git a/pkg/config/config_channel.go b/pkg/config/config_channel.go index b71ec798d..232c37cdd 100644 --- a/pkg/config/config_channel.go +++ b/pkg/config/config_channel.go @@ -27,6 +27,7 @@ const ( ChannelDingTalk = "dingtalk" ChannelSlack = "slack" ChannelMatrix = "matrix" + ChannelDeltaChat = "deltachat" ChannelLINE = "line" ChannelOneBot = "onebot" ChannelQQ = "qq" @@ -669,6 +670,7 @@ var channelSettingsFactory = map[string]any{ ChannelDingTalk: (DingTalkSettings{}), ChannelSlack: (SlackSettings{}), ChannelMatrix: (MatrixSettings{}), + ChannelDeltaChat: (DeltaChatSettings{}), ChannelLINE: (LINESettings{}), ChannelOneBot: (OneBotSettings{}), ChannelQQ: (QQSettings{}), diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 94f769835..30e7077fb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -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) { channels := ChannelsConfig{ "pico1": &Channel{Enabled: true, Type: ChannelPico}, diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7d306c0f3..96ce5f0f4 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -549,6 +549,13 @@ func defaultChannels() ChannelsConfig { "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{ "group_trigger": map[string]any{"mention_only": true}, "settings": map[string]any{ diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index ec519e454..415929375 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "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/discord" _ "github.com/sipeed/picoclaw/pkg/channels/feishu"