diff --git a/README.md b/README.md
index 5cf9f61438..0e05f32992 100644
--- a/README.md
+++ b/README.md
@@ -308,7 +308,7 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
+Talk to your picoclaw through Telegram, Discord, WhatsApp, Signal, Matrix, QQ, DingTalk, LINE, or WeCom
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
@@ -317,6 +317,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
+| **Signal** | Easy (signal-cli daemon + phone) |
| **Matrix** | Medium (homeserver + bot access token) |
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
@@ -463,6 +464,57 @@ If `session_store_path` is empty, the session is stored in `<workspace>/wh
+
+Signal (via signal-cli)
+
+PicoClaw connects to Signal through [signal-cli](https://github.com/AsamK/signal-cli) running in JSON-RPC daemon mode. signal-cli handles Signal protocol registration and encryption; PicoClaw connects to its HTTP API.
+
+**1. Set up signal-cli**
+
+Run signal-cli as a daemon (Docker recommended):
+
+```bash
+docker run -d --name signal-cli \
+ -p 8080:8080 \
+ -v signal-data:/home/.local/share/signal-cli \
+ bbernhard/signal-cli-rest-api
+```
+
+Register or link a phone number following the [signal-cli docs](https://github.com/AsamK/signal-cli/wiki).
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "signal": {
+ "enabled": true,
+ "account": "+1234567890",
+ "signal_cli_url": "http://localhost:8080",
+ "allow_from": ["+1987654321"],
+ "group_trigger": {
+ "mention_only": true
+ }
+ }
+ }
+}
+```
+
+- `account`: The phone number registered with signal-cli
+- `signal_cli_url`: URL of the signal-cli REST API (default: `http://localhost:8080`)
+- `allow_from`: Phone numbers allowed to interact (empty = allow all)
+- `group_trigger`: Group chat trigger config — `mention_only` requires @mention, `prefixes` triggers on message prefixes (omit for respond-to-all)
+
+**3. Run**
+
+```bash
+picoclaw gateway
+```
+
+> Signal supports markdown-to-styled-text conversion (bold, italic, strikethrough, monospace) and typing indicators.
+
+
+
QQ
diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go
index 4f93b858a3..fe7838fd0f 100644
--- a/cmd/picoclaw/internal/gateway/helpers.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -23,6 +23,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
+ _ "github.com/sipeed/picoclaw/pkg/channels/signal"
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index cdd49538f9..a5d76f4e74 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -280,6 +280,10 @@ func (m *Manager) initChannels() error {
m.initChannel("irc", "IRC")
}
+ if m.config.Channels.Signal.Enabled && m.config.Channels.Signal.Account != "" {
+ m.initChannel("signal", "Signal")
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/signal/init.go b/pkg/channels/signal/init.go
new file mode 100644
index 0000000000..285143a82b
--- /dev/null
+++ b/pkg/channels/signal/init.go
@@ -0,0 +1,13 @@
+package signal
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("signal", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ return NewSignalChannel(cfg, b)
+ })
+}
diff --git a/pkg/channels/signal/signal.go b/pkg/channels/signal/signal.go
new file mode 100644
index 0000000000..0fb7549c1f
--- /dev/null
+++ b/pkg/channels/signal/signal.go
@@ -0,0 +1,915 @@
+package signal
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf16"
+
+ "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/utils"
+)
+
+const (
+ signalMaxMessageLength = 6000
+ signalSSEReconnectDelay = 5 * time.Second
+ signalRPCTimeout = 30 * time.Second
+ signalTypingInterval = 8 * time.Second
+ signalTypingTimeout = 5 * time.Minute
+ signalDefaultCLIURL = "http://localhost:8080"
+)
+
+// SignalChannel implements the Channel interface for Signal via signal-cli daemon.
+// It connects to signal-cli's HTTP API: SSE for receiving events, JSON-RPC for sending.
+//
+// Implements: channels.Channel, channels.TypingCapable, channels.ReactionCapable
+type SignalChannel struct {
+ *channels.BaseChannel
+ config config.SignalConfig
+ rpcClient *http.Client // JSON-RPC calls (30s timeout)
+ sseClient *http.Client // SSE streaming (no timeout)
+ ctx context.Context
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+}
+
+// Signal SSE event types
+
+type signalEvent struct {
+ Envelope signalEnvelope `json:"envelope"`
+ Account string `json:"account"`
+}
+
+type signalEnvelope struct {
+ Source string `json:"source"`
+ SourceNumber string `json:"sourceNumber"`
+ SourceUUID string `json:"sourceUuid"`
+ SourceName string `json:"sourceName"`
+ SourceDevice int `json:"sourceDevice"`
+ Timestamp int64 `json:"timestamp"`
+ DataMessage *signalDataMessage `json:"dataMessage"`
+}
+
+type signalDataMessage struct {
+ Timestamp int64 `json:"timestamp"`
+ Message string `json:"message"`
+ ExpiresInSeconds int `json:"expiresInSeconds"`
+ ViewOnce bool `json:"viewOnce"`
+ GroupInfo *signalGroupInfo `json:"groupInfo"`
+ Attachments []signalAttachment `json:"attachments"`
+ Mentions []signalMention `json:"mentions"`
+}
+
+type signalMention struct {
+ Start int `json:"start"`
+ Length int `json:"length"`
+ UUID string `json:"uuid"`
+ Number string `json:"number"`
+}
+
+type signalGroupInfo struct {
+ GroupID string `json:"groupId"`
+ Type string `json:"type"`
+}
+
+type signalAttachment struct {
+ ContentType string `json:"contentType"`
+ Filename string `json:"filename"`
+ ID string `json:"id"`
+ Size int64 `json:"size"`
+}
+
+// JSON-RPC types
+
+type jsonRPCRequest struct {
+ JSONRPC string `json:"jsonrpc"`
+ Method string `json:"method"`
+ ID int `json:"id"`
+ Params any `json:"params"`
+}
+
+type jsonRPCResponse struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID int `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Error *jsonRPCError `json:"error"`
+}
+
+type jsonRPCError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data any `json:"data"`
+}
+
+func NewSignalChannel(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ signalCfg := cfg.Channels.Signal
+
+ if signalCfg.SignalCLIURL == "" {
+ signalCfg.SignalCLIURL = signalDefaultCLIURL
+ }
+
+ opts := []channels.BaseChannelOption{
+ channels.WithMaxMessageLength(signalMaxMessageLength),
+ channels.WithGroupTrigger(signalCfg.GroupTrigger),
+ }
+ if signalCfg.ReasoningChannelID != "" {
+ opts = append(opts, channels.WithReasoningChannelID(signalCfg.ReasoningChannelID))
+ }
+
+ base := channels.NewBaseChannel("signal", signalCfg, b, signalCfg.AllowFrom, opts...)
+
+ return &SignalChannel{
+ BaseChannel: base,
+ config: signalCfg,
+ rpcClient: &http.Client{Timeout: signalRPCTimeout},
+ sseClient: &http.Client{Timeout: 0},
+ }, nil
+}
+
+func (c *SignalChannel) Start(ctx context.Context) error {
+ logger.InfoCF("signal", "Starting Signal channel", map[string]any{
+ "signal_cli_url": c.config.SignalCLIURL,
+ "account": c.config.Account,
+ })
+
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ c.wg.Add(1)
+ go func() {
+ defer c.wg.Done()
+ c.sseLoop()
+ }()
+
+ c.SetRunning(true)
+ logger.InfoC("signal", "Signal channel started")
+ return nil
+}
+
+func (c *SignalChannel) Stop(ctx context.Context) error {
+ logger.InfoC("signal", "Stopping Signal channel")
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ // Wait for goroutines with context deadline
+ done := make(chan struct{})
+ go func() {
+ c.wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-ctx.Done():
+ logger.WarnC("signal", fmt.Sprintf("Stop context canceled before goroutines finished: %v", ctx.Err()))
+ }
+
+ c.SetRunning(false)
+ return nil
+}
+
+func (c *SignalChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ if err := c.sendMessage(ctx, msg.ChatID, msg.Content); err != nil {
+ return fmt.Errorf("signal send: %w: %v", channels.ErrTemporary, err)
+ }
+
+ return nil
+}
+
+// StartTyping implements channels.TypingCapable.
+// It sends a typing indicator immediately and then repeats every 8 seconds
+// (signal-cli's typing indicator expires after ~10s) in a background goroutine.
+// The returned stop function is idempotent and cancels the goroutine.
+func (c *SignalChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ c.sendTyping(chatID)
+
+ typingCtx, cancel := context.WithCancel(ctx)
+ var once sync.Once
+ stop := func() {
+ once.Do(func() {
+ cancel()
+ })
+ }
+
+ go func() {
+ ticker := time.NewTicker(signalTypingInterval)
+ defer ticker.Stop()
+ timeout := time.After(signalTypingTimeout)
+ for {
+ select {
+ case <-typingCtx.Done():
+ return
+ case <-timeout:
+ return
+ case <-ticker.C:
+ c.sendTyping(chatID)
+ }
+ }
+ }()
+
+ return stop, nil
+}
+
+// ReactToMessage implements channels.ReactionCapable.
+// It sends a 👀 emoji reaction to the inbound message and returns an undo
+// function that removes the reaction. The Manager auto-calls this on inbound
+// and undoes it before sending the bot's response.
+func (c *SignalChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
+ // messageID is encoded as "timestamp:senderPhone" by handleEvent
+ ts, senderPhone, ok := parseMessageID(messageID)
+ if !ok {
+ return func() {}, nil // non-critical, skip silently
+ }
+
+ c.sendReaction(ctx, chatID, senderPhone, ts, "👀", false)
+
+ var once sync.Once
+ undo := func() {
+ once.Do(func() {
+ undoCtx, undoCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer undoCancel()
+ c.sendReaction(undoCtx, chatID, senderPhone, ts, "👀", true)
+ })
+ }
+
+ return undo, nil
+}
+
+// SSE event loop with automatic reconnection
+
+func (c *SignalChannel) sseLoop() {
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ default:
+ if err := c.connectSSE(); err != nil {
+ logger.ErrorCF("signal", "SSE connection error", map[string]any{
+ "error": err.Error(),
+ })
+ }
+
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-time.After(signalSSEReconnectDelay):
+ logger.InfoC("signal", "Reconnecting SSE...")
+ }
+ }
+ }
+}
+
+func (c *SignalChannel) connectSSE() error {
+ url := fmt.Sprintf("%s/api/v1/events", c.config.SignalCLIURL)
+
+ req, err := http.NewRequestWithContext(c.ctx, "GET", url, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create SSE request: %w", err)
+ }
+ req.Header.Set("Accept", "text/event-stream")
+
+ resp, err := c.sseClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("SSE connection failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return fmt.Errorf("SSE returned status %d: %s", resp.StatusCode, string(body))
+ }
+
+ logger.InfoC("signal", "SSE connected successfully")
+
+ scanner := bufio.NewScanner(resp.Body)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
+
+ for scanner.Scan() {
+ select {
+ case <-c.ctx.Done():
+ return nil
+ default:
+ }
+
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data:") {
+ continue
+ }
+
+ data := strings.TrimPrefix(line, "data:")
+ data = strings.TrimSpace(data)
+ if data == "" {
+ continue
+ }
+
+ var event signalEvent
+ if err := json.Unmarshal([]byte(data), &event); err != nil {
+ logger.DebugCF("signal", "Failed to parse SSE event", map[string]any{
+ "error": err.Error(),
+ "data": utils.Truncate(data, 100),
+ })
+ continue
+ }
+
+ c.handleEvent(event)
+ }
+
+ if err := scanner.Err(); err != nil {
+ return fmt.Errorf("SSE stream error: %w", err)
+ }
+
+ return fmt.Errorf("SSE stream ended")
+}
+
+// Event handling
+
+func (c *SignalChannel) handleEvent(event signalEvent) {
+ envelope := event.Envelope
+
+ if envelope.DataMessage == nil {
+ return
+ }
+
+ dm := envelope.DataMessage
+
+ senderPhone := envelope.SourceNumber
+ if senderPhone == "" {
+ senderPhone = envelope.Source
+ }
+ if senderPhone == "" {
+ return
+ }
+
+ // Build structured sender info for the new identity system
+ sender := bus.SenderInfo{
+ Platform: "signal",
+ PlatformID: senderPhone,
+ CanonicalID: identity.BuildCanonicalID("signal", senderPhone),
+ DisplayName: envelope.SourceName,
+ }
+
+ if !c.IsAllowedSender(sender) {
+ logger.DebugCF("signal", "Message rejected by allowlist", map[string]any{
+ "sender": senderPhone,
+ })
+ return
+ }
+
+ isGroup := dm.GroupInfo != nil
+ chatID := senderPhone
+ peerKind := "direct"
+ peerID := senderPhone
+
+ if isGroup {
+ chatID = dm.GroupInfo.GroupID
+ peerKind = "group"
+ peerID = dm.GroupInfo.GroupID
+ }
+
+ content := dm.Message
+
+ // In group chats, apply unified group trigger filtering
+ if isGroup {
+ isMentioned := c.isBotMentioned(dm.Mentions)
+ if isMentioned {
+ content = c.stripMention(content, dm.Mentions)
+ }
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
+ if !respond {
+ return
+ }
+ content = cleaned
+ }
+ mediaPaths := []string{}
+ localFiles := []string{}
+
+ defer func() {
+ for _, file := range localFiles {
+ if err := os.Remove(file); err != nil {
+ logger.DebugCF("signal", "Failed to cleanup temp file", map[string]any{
+ "file": file,
+ "error": err.Error(),
+ })
+ }
+ }
+ }()
+
+ for _, att := range dm.Attachments {
+ localPath := c.downloadAttachment(att)
+ if localPath == "" {
+ continue
+ }
+ localFiles = append(localFiles, localPath)
+ mediaPaths = append(mediaPaths, localPath)
+
+ if strings.HasPrefix(att.ContentType, "image/") {
+ content = appendContent(content, "[image: photo]")
+ } else if utils.IsAudioFile(att.Filename, att.ContentType) {
+ content = appendContent(content, "[voice message]")
+ } else {
+ name := att.Filename
+ if name == "" {
+ name = att.ContentType
+ }
+ content = appendContent(content, fmt.Sprintf("[file: %s]", name))
+ }
+ }
+
+ if content == "" && len(mediaPaths) == 0 {
+ return
+ }
+ if content == "" {
+ content = "[media only]"
+ }
+
+ peer := bus.Peer{Kind: peerKind, ID: peerID}
+
+ // Encode messageID as "timestamp:senderPhone" so ReactToMessage can extract both
+ messageID := fmt.Sprintf("%d:%s", dm.Timestamp, senderPhone)
+
+ metadata := map[string]string{
+ "timestamp": fmt.Sprintf("%d", dm.Timestamp),
+ "source_uuid": envelope.SourceUUID,
+ "source_name": envelope.SourceName,
+ "phone": senderPhone,
+ "is_group": fmt.Sprintf("%t", isGroup),
+ "peer_kind": peerKind,
+ "peer_id": peerID,
+ "message_id": messageID,
+ }
+ if isGroup {
+ metadata["group_id"] = dm.GroupInfo.GroupID
+ }
+
+ logger.DebugCF("signal", "Received message", map[string]any{
+ "sender": senderPhone,
+ "chat_id": chatID,
+ "is_group": isGroup,
+ "preview": utils.Truncate(content, 50),
+ })
+
+ c.HandleMessage(c.ctx, peer, messageID, senderPhone, chatID, content, mediaPaths, metadata, sender)
+}
+
+// isBotMentioned checks whether the bot was @mentioned in a group message
+// by looking for its account number or UUID in the structured mentions array.
+//
+// Note: signal-cli v0.13.24 has a bug (https://github.com/AsamK/signal-cli/issues/1940)
+// where the mentions array is empty due to binary ACI parsing issues. This will
+// work correctly once the fix (PR #1944) is released.
+func (c *SignalChannel) isBotMentioned(mentions []signalMention) bool {
+ for _, m := range mentions {
+ if m.Number == c.config.Account || m.UUID == c.config.Account {
+ return true
+ }
+ }
+ return false
+}
+
+// stripMention removes the bot's @mention from the message content using
+// the precise UTF-16 offsets from the structured mention data.
+// Signal represents mentions as U+FFFC (object replacement character) in the text.
+func (c *SignalChannel) stripMention(content string, mentions []signalMention) string {
+ for _, m := range mentions {
+ if m.Number != c.config.Account && m.UUID != c.config.Account {
+ continue
+ }
+ runes := []rune(content)
+ runeStart, runeLen := utf16PosToRunePos(runes, m.Start, m.Length)
+ if runeStart >= 0 && runeStart+runeLen <= len(runes) {
+ before := strings.TrimRight(string(runes[:runeStart]), " ")
+ after := strings.TrimLeft(string(runes[runeStart+runeLen:]), " ")
+ if before == "" {
+ return after
+ }
+ if after == "" {
+ return before
+ }
+ return before + " " + after
+ }
+ }
+ return content
+}
+
+// utf16PosToRunePos converts a UTF-16 code unit position and length to rune position and length.
+func utf16PosToRunePos(runes []rune, utf16Start, utf16Len int) (int, int) {
+ pos := 0
+ runeStart := -1
+ runeLen := 0
+ for i, r := range runes {
+ if pos == utf16Start {
+ runeStart = i
+ }
+ units := 1
+ if r >= 0x10000 {
+ units = 2 // surrogate pair
+ }
+ if runeStart >= 0 {
+ runeLen++
+ if pos+units >= utf16Start+utf16Len {
+ break
+ }
+ }
+ pos += units
+ }
+ return runeStart, runeLen
+}
+
+// Media handling
+
+func (c *SignalChannel) downloadAttachment(att signalAttachment) string {
+ if att.ID == "" {
+ return ""
+ }
+
+ url := fmt.Sprintf("%s/api/v1/attachments/%s", c.config.SignalCLIURL, att.ID)
+ filename := att.Filename
+ if filename == "" {
+ filename = "attachment" + extensionFromMIME(att.ContentType)
+ }
+
+ return utils.DownloadFile(url, filename, utils.DownloadOptions{
+ LoggerPrefix: "signal",
+ })
+}
+
+func extensionFromMIME(mime string) string {
+ switch {
+ case strings.HasPrefix(mime, "image/jpeg"):
+ return ".jpg"
+ case strings.HasPrefix(mime, "image/png"):
+ return ".png"
+ case strings.HasPrefix(mime, "image/gif"):
+ return ".gif"
+ case strings.HasPrefix(mime, "image/webp"):
+ return ".webp"
+ case strings.HasPrefix(mime, "audio/mpeg"), strings.HasPrefix(mime, "audio/mp3"):
+ return ".mp3"
+ case strings.HasPrefix(mime, "audio/ogg"):
+ return ".ogg"
+ case strings.HasPrefix(mime, "audio/mp4"), strings.HasPrefix(mime, "audio/aac"):
+ return ".m4a"
+ case strings.HasPrefix(mime, "video/mp4"):
+ return ".mp4"
+ default:
+ return ""
+ }
+}
+
+func appendContent(content, suffix string) string {
+ if content == "" {
+ return suffix
+ }
+ return content + "\n" + suffix
+}
+
+// Sending messages via JSON-RPC
+
+func (c *SignalChannel) sendMessage(ctx context.Context, chatID, content string) error {
+ plainText, textStyles := markdownToSignal(content)
+
+ params := map[string]any{
+ "account": c.config.Account,
+ "message": plainText,
+ }
+
+ if len(textStyles) > 0 {
+ params["textStyle"] = textStyles
+ }
+
+ if isGroupChat(chatID) {
+ params["groupId"] = chatID
+ } else {
+ params["recipient"] = []string{chatID}
+ }
+
+ _, err := c.rpcCall(ctx, "send", params)
+ return err
+}
+
+func (c *SignalChannel) sendReaction(
+ ctx context.Context,
+ chatID, targetAuthor string,
+ targetTimestamp int64,
+ emoji string,
+ remove bool,
+) {
+ params := map[string]any{
+ "account": c.config.Account,
+ "emoji": emoji,
+ "targetAuthor": targetAuthor,
+ "targetTimestamp": targetTimestamp,
+ "remove": remove,
+ }
+ if isGroupChat(chatID) {
+ params["groupId"] = chatID
+ } else {
+ params["recipient"] = []string{chatID}
+ }
+
+ if _, err := c.rpcCall(ctx, "sendReaction", params); err != nil {
+ logger.DebugCF("signal", "Failed to send reaction", map[string]any{
+ "error": err.Error(),
+ "remove": remove,
+ })
+ }
+}
+
+func (c *SignalChannel) rpcCall(ctx context.Context, method string, params any) (*jsonRPCResponse, error) {
+ req := jsonRPCRequest{
+ JSONRPC: "2.0",
+ Method: method,
+ ID: 1,
+ Params: params,
+ }
+
+ body, err := json.Marshal(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal RPC request: %w", err)
+ }
+
+ rpcURL := c.config.SignalCLIURL + "/api/v1/rpc"
+ httpReq, err := http.NewRequestWithContext(ctx, "POST", rpcURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create RPC request: %w", err)
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.rpcClient.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("RPC request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read RPC response: %w", err)
+ }
+
+ var rpcResp jsonRPCResponse
+ if err := json.Unmarshal(respBody, &rpcResp); err != nil {
+ return nil, fmt.Errorf("failed to parse RPC response: %w", err)
+ }
+
+ if rpcResp.Error != nil {
+ return nil, fmt.Errorf("RPC error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
+ }
+
+ return &rpcResp, nil
+}
+
+// Typing indicator
+
+func (c *SignalChannel) sendTyping(chatID string) {
+ params := map[string]any{
+ "account": c.config.Account,
+ }
+
+ if isGroupChat(chatID) {
+ params["groupId"] = chatID
+ } else {
+ params["recipient"] = []string{chatID}
+ }
+
+ ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
+ defer cancel()
+
+ if _, err := c.rpcCall(ctx, "sendTyping", params); err != nil {
+ logger.DebugCF("signal", "Failed to send typing indicator", map[string]any{
+ "error": err.Error(),
+ "chat_id": chatID,
+ })
+ }
+}
+
+// isGroupChat determines if a chatID is a Signal group (base64-encoded) or a phone number.
+// This is safe because chatID is always set by handleEvent: either the sender's E.164 phone
+// number (starts with "+") for DMs, or the base64-encoded GroupInfo.GroupID for groups.
+func isGroupChat(chatID string) bool {
+ return chatID != "" && !strings.HasPrefix(chatID, "+")
+}
+
+// parseMessageID extracts timestamp and sender phone from the encoded messageID
+// format "timestamp:senderPhone".
+func parseMessageID(messageID string) (timestamp int64, senderPhone string, ok bool) {
+ idx := strings.Index(messageID, ":")
+ if idx <= 0 || idx == len(messageID)-1 {
+ return 0, "", false
+ }
+ ts, err := strconv.ParseInt(messageID[:idx], 10, 64)
+ if err != nil {
+ return 0, "", false
+ }
+ return ts, messageID[idx+1:], true
+}
+
+// markdownToSignal converts markdown-formatted text to plain text with signal-cli
+// textStyle ranges. Returns the converted text and a slice of style strings in
+// "START:LENGTH:STYLE" format for signal-cli's textStyle parameter.
+// Handles: **bold**, *italic*, ~~strikethrough~~, `code`, ```code blocks```,
+// [links](url), heading stripping, list markers, blockquotes.
+func markdownToSignal(text string) (string, []string) {
+ if text == "" {
+ return text, nil
+ }
+
+ // Step 0: extract code blocks and inline code into placeholders.
+ // This prevents code content (e.g. *ptr inside code) from being
+ // processed as markdown in later steps.
+ var codeBlocks []string
+ var inlineCodes []string
+
+ reCodeBlock := regexp.MustCompile("(?s)```[\\w]*\\n?(.*?)```")
+ text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
+ inner := reCodeBlock.FindStringSubmatch(m)[1]
+ idx := len(codeBlocks)
+ codeBlocks = append(codeBlocks, inner)
+ return fmt.Sprintf("\x00CB%d\x00", idx)
+ })
+
+ reInlineCode := regexp.MustCompile("`([^`]+)`")
+ text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
+ inner := reInlineCode.FindStringSubmatch(m)[1]
+ idx := len(inlineCodes)
+ inlineCodes = append(inlineCodes, inner)
+ return fmt.Sprintf("\x00IC%d\x00", idx)
+ })
+
+ // Step 1: line-level markdown (headings, lists, blockquotes)
+ lines := strings.Split(text, "\n")
+ for i, line := range lines {
+ if strings.HasPrefix(line, "#") {
+ trimmed := strings.TrimLeft(line, "#")
+ lines[i] = strings.TrimLeft(trimmed, " ")
+ } else if strings.HasPrefix(line, "- ") {
+ lines[i] = "• " + line[2:]
+ } else if strings.HasPrefix(line, "* ") {
+ lines[i] = "• " + line[2:]
+ } else if strings.HasPrefix(line, "> ") {
+ lines[i] = line[2:]
+ }
+ }
+ text = strings.Join(lines, "\n")
+
+ // Step 1b: convert markdown links [text](url) → text (url)
+ reLink := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+ text = reLink.ReplaceAllString(text, "$1 ($2)")
+
+ // Step 2: inline styles → textStyle position ranges
+ type styleEntry struct {
+ start int
+ length int
+ style string
+ }
+
+ var styles []styleEntry
+ var result []rune
+ runes := []rune(text)
+ i := 0
+
+ utf16Pos := func() int {
+ return len(utf16.Encode(result))
+ }
+
+ // Check if current position is the start of a placeholder (\x00CB0\x00 or \x00IC0\x00)
+ matchPlaceholder := func(pos int) (kind string, idx int, end int, ok bool) {
+ if pos+4 >= len(runes) || runes[pos] != 0 {
+ return "", 0, 0, false
+ }
+ // Find the closing \x00
+ j := pos + 1
+ for j < len(runes) && runes[j] != 0 {
+ j++
+ }
+ if j >= len(runes) {
+ return "", 0, 0, false
+ }
+ tag := string(runes[pos+1 : j])
+ if strings.HasPrefix(tag, "CB") {
+ n := 0
+ if _, err := fmt.Sscanf(tag, "CB%d", &n); err == nil {
+ return "CB", n, j + 1, true
+ }
+ } else if strings.HasPrefix(tag, "IC") {
+ n := 0
+ if _, err := fmt.Sscanf(tag, "IC%d", &n); err == nil {
+ return "IC", n, j + 1, true
+ }
+ }
+ return "", 0, 0, false
+ }
+
+ for i < len(runes) {
+ // Code placeholders → restore content with MONOSPACE style
+ if kind, idx, end, ok := matchPlaceholder(i); ok {
+ var code string
+ if kind == "CB" && idx < len(codeBlocks) {
+ code = strings.TrimRight(codeBlocks[idx], "\n")
+ } else if kind == "IC" && idx < len(inlineCodes) {
+ code = inlineCodes[idx]
+ }
+ if code != "" {
+ codeRunes := []rune(code)
+ start := utf16Pos()
+ styles = append(styles, styleEntry{start, len(utf16.Encode(codeRunes)), "MONOSPACE"})
+ result = append(result, codeRunes...)
+ }
+ i = end
+ continue
+ }
+
+ // Strikethrough: ~~text~~
+ if i+1 < len(runes) && runes[i] == '~' && runes[i+1] == '~' {
+ if end := signalFindDouble(runes, i+2, '~'); end > i+2 {
+ inner := runes[i+2 : end]
+ start := utf16Pos()
+ styles = append(styles, styleEntry{start, len(utf16.Encode(inner)), "STRIKETHROUGH"})
+ result = append(result, inner...)
+ i = end + 2
+ continue
+ }
+ }
+
+ // Bold: **text**
+ if i+1 < len(runes) && runes[i] == '*' && runes[i+1] == '*' {
+ if end := signalFindDouble(runes, i+2, '*'); end > i+2 {
+ inner := runes[i+2 : end]
+ start := utf16Pos()
+ styles = append(styles, styleEntry{start, len(utf16.Encode(inner)), "BOLD"})
+ result = append(result, inner...)
+ i = end + 2
+ continue
+ }
+ }
+
+ // Italic: *text* (single *, not followed by another *)
+ if runes[i] == '*' && (i+1 < len(runes) && runes[i+1] != '*') {
+ if end := signalFindSingle(runes, i+1, '*'); end > i+1 {
+ inner := runes[i+1 : end]
+ start := utf16Pos()
+ styles = append(styles, styleEntry{start, len(utf16.Encode(inner)), "ITALIC"})
+ result = append(result, inner...)
+ i = end + 1
+ continue
+ }
+ }
+
+ result = append(result, runes[i])
+ i++
+ }
+
+ if len(styles) == 0 {
+ return string(result), nil
+ }
+
+ strs := make([]string, len(styles))
+ for idx, s := range styles {
+ strs[idx] = fmt.Sprintf("%d:%d:%s", s.start, s.length, s.style)
+ }
+ return string(result), strs
+}
+
+// signalFindDouble finds the next occurrence of two consecutive ch runes starting from pos.
+func signalFindDouble(runes []rune, start int, ch rune) int {
+ for i := start; i+1 < len(runes); i++ {
+ if runes[i] == ch && runes[i+1] == ch {
+ return i
+ }
+ }
+ return -1
+}
+
+// signalFindSingle finds the next occurrence of ch that is NOT followed by another ch.
+func signalFindSingle(runes []rune, start int, ch rune) int {
+ for i := start; i < len(runes); i++ {
+ if runes[i] == ch && (i+1 >= len(runes) || runes[i+1] != ch) {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/pkg/channels/signal/signal_test.go b/pkg/channels/signal/signal_test.go
new file mode 100644
index 0000000000..a0862a6944
--- /dev/null
+++ b/pkg/channels/signal/signal_test.go
@@ -0,0 +1,425 @@
+package signal
+
+import (
+ "encoding/json"
+ "reflect"
+ "testing"
+)
+
+func TestMarkdownToSignal(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantText string
+ wantStyles []string
+ }{
+ {
+ name: "plain text unchanged",
+ input: "Hello world",
+ wantText: "Hello world",
+ wantStyles: nil,
+ },
+ {
+ name: "empty string",
+ input: "",
+ wantText: "",
+ wantStyles: nil,
+ },
+ {
+ name: "bold",
+ input: "**hello** world",
+ wantText: "hello world",
+ wantStyles: []string{"0:5:BOLD"},
+ },
+ {
+ name: "multiple bold",
+ input: "**hello** and **world**",
+ wantText: "hello and world",
+ wantStyles: []string{"0:5:BOLD", "10:5:BOLD"},
+ },
+ {
+ name: "italic",
+ input: "It is *really* good",
+ wantText: "It is really good",
+ wantStyles: []string{"6:6:ITALIC"},
+ },
+ {
+ name: "bold and italic",
+ input: "**Alice** is *great*",
+ wantText: "Alice is great",
+ wantStyles: []string{"0:5:BOLD", "9:5:ITALIC"},
+ },
+ {
+ name: "strikethrough",
+ input: "~~not available~~ found it",
+ wantText: "not available found it",
+ wantStyles: []string{"0:13:STRIKETHROUGH"},
+ },
+ {
+ name: "unmatched bold markers left as-is",
+ input: "**unclosed bold",
+ wantText: "**unclosed bold",
+ wantStyles: nil,
+ },
+ {
+ name: "unmatched italic marker left as-is",
+ input: "*unclosed italic",
+ wantText: "*unclosed italic",
+ wantStyles: nil,
+ },
+ {
+ name: "heading stripped",
+ input: "## Tasks\nHere are some tasks",
+ wantText: "Tasks\nHere are some tasks",
+ wantStyles: nil,
+ },
+ {
+ name: "list markers converted (dash)",
+ input: "- Alice\n- Bob\n- Carol",
+ wantText: "• Alice\n• Bob\n• Carol",
+ wantStyles: nil,
+ },
+ {
+ name: "list markers converted (asterisk)",
+ input: "* Alice\n* Bob\n* Carol",
+ wantText: "• Alice\n• Bob\n• Carol",
+ wantStyles: nil,
+ },
+ {
+ name: "asterisk list with bold content",
+ input: "* **First item**: description one\n* **Second item**: description two",
+ wantText: "• First item: description one\n• Second item: description two",
+ wantStyles: []string{"2:10:BOLD", "32:11:BOLD"},
+ },
+ {
+ name: "blockquote stripped",
+ input: "> Some quote here",
+ wantText: "Some quote here",
+ wantStyles: nil,
+ },
+ {
+ name: "bold with non-ASCII characters",
+ input: "**Blåbær** er på bordet",
+ wantText: "Blåbær er på bordet",
+ wantStyles: []string{"0:6:BOLD"},
+ },
+ {
+ name: "mixed formatting and line-level",
+ input: "## Results\n- **Alice** is in group A\n- **Bob** is in group B",
+ wantText: "Results\n• Alice is in group A\n• Bob is in group B",
+ wantStyles: []string{"10:5:BOLD", "32:3:BOLD"},
+ },
+ {
+ name: "inline code",
+ input: "Run `kubectl get pods` to check",
+ wantText: "Run kubectl get pods to check",
+ wantStyles: []string{"4:16:MONOSPACE"},
+ },
+ {
+ name: "code block",
+ input: "Example:\n```bash\necho hello\n```\nDone",
+ wantText: "Example:\necho hello\nDone",
+ wantStyles: []string{"9:10:MONOSPACE"},
+ },
+ {
+ name: "code block with language tag",
+ input: "```python\nprint(\"hi\")\n```",
+ wantText: "print(\"hi\")",
+ wantStyles: []string{"0:11:MONOSPACE"},
+ },
+ {
+ name: "code preserves markdown inside",
+ input: "Use `**not bold**` in code",
+ wantText: "Use **not bold** in code",
+ wantStyles: []string{"4:12:MONOSPACE"},
+ },
+ {
+ name: "inline code and bold mixed",
+ input: "**Important**: use `cmd` here",
+ wantText: "Important: use cmd here",
+ wantStyles: []string{"0:9:BOLD", "15:3:MONOSPACE"},
+ },
+ {
+ name: "markdown link converted",
+ input: "See [Google](https://google.com) for more",
+ wantText: "See Google (https://google.com) for more",
+ wantStyles: nil,
+ },
+ {
+ name: "link with bold text",
+ input: "**Check** [docs](https://example.com)",
+ wantText: "Check docs (https://example.com)",
+ wantStyles: []string{"0:5:BOLD"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotText, gotStyles := markdownToSignal(tt.input)
+ if gotText != tt.wantText {
+ t.Errorf("text = %q, want %q", gotText, tt.wantText)
+ }
+ if !reflect.DeepEqual(gotStyles, tt.wantStyles) {
+ t.Errorf("styles = %v, want %v", gotStyles, tt.wantStyles)
+ }
+ })
+ }
+}
+
+func TestExtensionFromMIME(t *testing.T) {
+ tests := []struct {
+ mime string
+ want string
+ }{
+ {"image/jpeg", ".jpg"},
+ {"image/jpeg; charset=utf-8", ".jpg"},
+ {"image/png", ".png"},
+ {"image/gif", ".gif"},
+ {"image/webp", ".webp"},
+ {"audio/mpeg", ".mp3"},
+ {"audio/mp3", ".mp3"},
+ {"audio/ogg", ".ogg"},
+ {"audio/mp4", ".m4a"},
+ {"audio/aac", ".m4a"},
+ {"video/mp4", ".mp4"},
+ {"application/pdf", ""},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.mime, func(t *testing.T) {
+ if got := extensionFromMIME(tt.mime); got != tt.want {
+ t.Errorf("extensionFromMIME(%q) = %q, want %q", tt.mime, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSignalEventDeserialization(t *testing.T) {
+ tests := []struct {
+ name string
+ json string
+ wantSource string
+ wantName string
+ wantMsg string
+ wantGroup bool
+ }{
+ {
+ name: "direct message",
+ json: `{
+ "envelope": {
+ "source": "+4512345678",
+ "sourceNumber": "+4512345678",
+ "sourceUuid": "abc-def-123",
+ "sourceName": "John Doe",
+ "sourceDevice": 1,
+ "timestamp": 1700000000000,
+ "dataMessage": {
+ "timestamp": 1700000000000,
+ "message": "Hello bot",
+ "expiresInSeconds": 0,
+ "viewOnce": false
+ }
+ },
+ "account": "+4587654321"
+ }`,
+ wantSource: "+4512345678",
+ wantName: "John Doe",
+ wantMsg: "Hello bot",
+ wantGroup: false,
+ },
+ {
+ name: "group message",
+ json: `{
+ "envelope": {
+ "source": "+4512345678",
+ "sourceNumber": "+4512345678",
+ "sourceUuid": "abc-def-123",
+ "sourceName": "Jane",
+ "sourceDevice": 2,
+ "timestamp": 1700000000000,
+ "dataMessage": {
+ "timestamp": 1700000000000,
+ "message": "Hi group",
+ "groupInfo": {
+ "groupId": "R3JvdXBJZEhlcmU=",
+ "type": "DELIVER"
+ }
+ }
+ },
+ "account": "+4587654321"
+ }`,
+ wantSource: "+4512345678",
+ wantName: "Jane",
+ wantMsg: "Hi group",
+ wantGroup: true,
+ },
+ {
+ name: "message with attachment",
+ json: `{
+ "envelope": {
+ "source": "+4512345678",
+ "sourceNumber": "+4512345678",
+ "sourceUuid": "abc-def-123",
+ "sourceName": "John",
+ "sourceDevice": 1,
+ "timestamp": 1700000000000,
+ "dataMessage": {
+ "timestamp": 1700000000000,
+ "message": "",
+ "attachments": [{
+ "contentType": "image/jpeg",
+ "filename": "photo.jpg",
+ "id": "att-123",
+ "size": 54321
+ }]
+ }
+ },
+ "account": "+4587654321"
+ }`,
+ wantSource: "+4512345678",
+ wantName: "John",
+ wantMsg: "",
+ wantGroup: false,
+ },
+ {
+ name: "no data message (e.g. receipt)",
+ json: `{
+ "envelope": {
+ "source": "+4512345678",
+ "sourceNumber": "+4512345678",
+ "sourceUuid": "abc-def-123",
+ "sourceName": "John",
+ "sourceDevice": 1,
+ "timestamp": 1700000000000
+ },
+ "account": "+4587654321"
+ }`,
+ wantSource: "+4512345678",
+ wantName: "John",
+ wantMsg: "",
+ wantGroup: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var event signalEvent
+ if err := json.Unmarshal([]byte(tt.json), &event); err != nil {
+ t.Fatalf("failed to unmarshal: %v", err)
+ }
+
+ if event.Envelope.SourceNumber != tt.wantSource {
+ t.Errorf("sourceNumber = %q, want %q", event.Envelope.SourceNumber, tt.wantSource)
+ }
+ if event.Envelope.SourceName != tt.wantName {
+ t.Errorf("sourceName = %q, want %q", event.Envelope.SourceName, tt.wantName)
+ }
+
+ if event.Envelope.DataMessage != nil {
+ if event.Envelope.DataMessage.Message != tt.wantMsg {
+ t.Errorf("message = %q, want %q", event.Envelope.DataMessage.Message, tt.wantMsg)
+ }
+ gotGroup := event.Envelope.DataMessage.GroupInfo != nil
+ if gotGroup != tt.wantGroup {
+ t.Errorf("isGroup = %v, want %v", gotGroup, tt.wantGroup)
+ }
+ } else if tt.wantMsg != "" {
+ t.Errorf("dataMessage is nil, want message %q", tt.wantMsg)
+ }
+ })
+ }
+}
+
+func TestIsGroupChat(t *testing.T) {
+ tests := []struct {
+ name string
+ chatID string
+ want bool
+ }{
+ {
+ name: "phone number is not group",
+ chatID: "+4571376774",
+ want: false,
+ },
+ {
+ name: "base64 group ID is group",
+ chatID: "abc123def456==",
+ want: true,
+ },
+ {
+ name: "empty string is not group",
+ chatID: "",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isGroupChat(tt.chatID); got != tt.want {
+ t.Errorf("isGroupChat(%q) = %v, want %v", tt.chatID, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseMessageID(t *testing.T) {
+ tests := []struct {
+ name string
+ messageID string
+ wantTS int64
+ wantPhone string
+ wantOK bool
+ }{
+ {
+ name: "valid direct message ID",
+ messageID: "1700000000000:+4512345678",
+ wantTS: 1700000000000,
+ wantPhone: "+4512345678",
+ wantOK: true,
+ },
+ {
+ name: "empty string",
+ messageID: "",
+ wantTS: 0,
+ wantPhone: "",
+ wantOK: false,
+ },
+ {
+ name: "no colon",
+ messageID: "1700000000000",
+ wantTS: 0,
+ wantPhone: "",
+ wantOK: false,
+ },
+ {
+ name: "invalid timestamp",
+ messageID: "notanumber:+4512345678",
+ wantTS: 0,
+ wantPhone: "",
+ wantOK: false,
+ },
+ {
+ name: "colon at end",
+ messageID: "1700000000000:",
+ wantTS: 0,
+ wantPhone: "",
+ wantOK: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ts, phone, ok := parseMessageID(tt.messageID)
+ if ok != tt.wantOK {
+ t.Errorf("ok = %v, want %v", ok, tt.wantOK)
+ }
+ if ts != tt.wantTS {
+ t.Errorf("timestamp = %d, want %d", ts, tt.wantTS)
+ }
+ if phone != tt.wantPhone {
+ t.Errorf("phone = %q, want %q", phone, tt.wantPhone)
+ }
+ })
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index deff1eb0f5..63ed70d5fd 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -233,6 +233,7 @@ type ChannelsConfig struct {
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"`
IRC IRCConfig `json:"irc"`
+ Signal SignalConfig `json:"signal"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
@@ -449,6 +450,15 @@ type IRCConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
}
+type SignalConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SIGNAL_ENABLED"`
+ Account string `json:"account" env:"PICOCLAW_CHANNELS_SIGNAL_ACCOUNT"`
+ SignalCLIURL string `json:"signal_cli_url" env:"PICOCLAW_CHANNELS_SIGNAL_CLI_URL"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SIGNAL_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SIGNAL_REASONING_CHANNEL_ID"`
+}
+
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5