picoclaw/pkg/channels/deltachat/deltachat.go

1331 lines
39 KiB
Go
Raw Normal View History

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