picoclaw/feat_add_imsg_channel_feat_add_imsg_channel.patch
2026-04-03 11:04:26 +08:00

1087 lines
31 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Subject: [PATCH] feat:add imsg channel
feat:add imsg channel
---
Index: config/config.example.json
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/config/config.example.json b/config/config.example.json
--- a/config/config.example.json (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/config/config.example.json (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -105,6 +105,11 @@
},
"reasoning_channel_id": ""
},
+ "imsg": {
+ "enabled": false,
+ "allow_from": [],
+ "iMessageCLIPath": "imsg"
+ },
"qq": {
"enabled": false,
"app_id": "YOUR_QQ_APP_ID",
Index: pkg/channels/imsg/imsg.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pkg/channels/imsg/imsg.go b/pkg/channels/imsg/imsg.go
new file mode 100644
--- /dev/null (revision 9f4fadd446eb36835d4e3c23e652b86c98b601a4)
+++ b/pkg/channels/imsg/imsg.go (revision 9f4fadd446eb36835d4e3c23e652b86c98b601a4)
@@ -0,0 +1,824 @@
+package imsg
+
+import (
+ "bufio"
+ "context"
+ "crypto/sha1"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os/exec"
+ "reflect"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "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"
+)
+
+type IMsgChannel struct {
+ *channels.BaseChannel
+ config config.IMsgConfig
+ runCtx context.Context
+ runCancel context.CancelFunc
+ rpcCmd *exec.Cmd
+ rpcStdin io.WriteCloser
+ wg sync.WaitGroup
+ mu sync.Mutex
+ fatalMu sync.Mutex
+ fatalLine string
+ lastLine string
+ recvMode string
+ seenMu sync.Mutex
+ seenIDs map[string]struct{}
+ seenEvent map[string]time.Time
+ reqSeq atomic.Uint64
+ writeMu sync.Mutex
+ pendingMu sync.Mutex
+ pending map[string]chan rpcResponse
+}
+
+func NewIMsgChannel(cfg config.IMsgConfig, messageBus *bus.MessageBus) (*IMsgChannel, error) {
+ if strings.TrimSpace(cfg.IMessageCLIPath) == "" {
+ cfg.IMessageCLIPath = "imsg"
+ }
+
+ base := channels.NewBaseChannel("imsg", cfg, messageBus, cfg.AllowFrom)
+ return &IMsgChannel{
+ BaseChannel: base,
+ config: cfg,
+ seenIDs: make(map[string]struct{}),
+ seenEvent: make(map[string]time.Time),
+ pending: make(map[string]chan rpcResponse),
+ }, nil
+}
+
+type rpcResponse struct {
+ Result any
+ Error *rpcError
+}
+
+type rpcError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+func (c *IMsgChannel) Start(ctx context.Context) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.IsRunning() {
+ return nil
+ }
+ c.fatalMu.Lock()
+ c.fatalLine = ""
+ c.lastLine = ""
+ c.fatalMu.Unlock()
+ c.runCtx, c.runCancel = context.WithCancel(ctx)
+ if err := c.startRPCLocked(); err != nil {
+ c.runCancel()
+ c.runCtx = nil
+ c.runCancel = nil
+ return err
+ }
+ c.SetRunning(true)
+ logger.InfoC("imsg", "iMessage channel started")
+ return nil
+}
+
+func (c *IMsgChannel) Stop(ctx context.Context) error {
+ c.mu.Lock()
+ if !c.IsRunning() && c.rpcCmd == nil {
+ c.mu.Unlock()
+ return nil
+ }
+ c.SetRunning(false)
+ if c.runCancel != nil {
+ c.runCancel()
+ }
+ cmd := c.rpcCmd
+ stdin := c.rpcStdin
+ c.rpcCmd = nil
+ c.rpcStdin = nil
+ c.mu.Unlock()
+
+ if stdin != nil {
+ _ = stdin.Close()
+ }
+ if cmd != nil && cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ c.wg.Wait()
+
+ c.mu.Lock()
+ c.runCtx = nil
+ c.runCancel = nil
+ c.mu.Unlock()
+
+ logger.InfoC("imsg", "iMessage channel stopped")
+ return nil
+}
+
+func (c *IMsgChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
+ if !c.IsRunning() {
+ return nil, channels.ErrNotRunning
+ }
+ paramsList := []map[string]any{
+ {"chat_id": msg.ChatID, "text": msg.Content},
+ {"chatId": msg.ChatID, "text": msg.Content},
+ {"chat_id": msg.ChatID, "content": msg.Content},
+ }
+ methods := []string{"send", "message.send"}
+ var lastErr error
+ for _, method := range methods {
+ for _, params := range paramsList {
+ logger.InfoCF("imsg", "imsg rpc send request", map[string]any{
+ "method": method,
+ "params": params,
+ })
+ if _, err := c.rpcCall(ctx, method, params, 8*time.Second); err == nil {
+ return nil, nil
+ } else {
+ lastErr = err
+ }
+ }
+ }
+ return nil, fmt.Errorf("imsg rpc send failed: %w", lastErr)
+}
+
+// SendMedia implements channels.MediaSender via imsg RPC.
+func (c *IMsgChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
+ if !c.IsRunning() {
+ return nil, channels.ErrNotRunning
+ }
+ store := c.GetMediaStore()
+ if store == nil {
+ return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ }
+
+ for _, part := range msg.Parts {
+ localPath, err := store.Resolve(part.Ref)
+ if err != nil {
+ return nil, fmt.Errorf("resolve media ref %s: %w", part.Ref, err)
+ }
+ paramsList := []map[string]any{
+ {"chat_id": msg.ChatID, "text": part.Caption, "file": localPath},
+ {"chatId": msg.ChatID, "text": part.Caption, "file": localPath},
+ {"chat_id": msg.ChatID, "content": part.Caption, "file": localPath},
+ }
+ methods := []string{"send", "message.send"}
+ var lastErr error
+ ok := false
+ for _, method := range methods {
+ for _, params := range paramsList {
+ logger.InfoCF("imsg", "imsg rpc send media request", map[string]any{
+ "method": method,
+ "params": params,
+ })
+ if _, err := c.rpcCall(ctx, method, params, 12*time.Second); err == nil {
+ ok = true
+ break
+ } else {
+ lastErr = err
+ }
+ }
+ if ok {
+ break
+ }
+ }
+ if !ok {
+ return nil, fmt.Errorf("imsg rpc send media failed: %w", lastErr)
+ }
+ }
+ return nil, nil
+}
+
+func (c *IMsgChannel) startRPCLocked() error {
+ cliPath := strings.TrimSpace(c.config.IMessageCLIPath)
+ if cliPath == "" {
+ cliPath = "imsg"
+ }
+
+ commands := []struct {
+ mode string
+ args []string
+ }{
+ {mode: "rpc", args: []string{"rpc", "--json"}},
+ {mode: "rpc", args: []string{"rpc"}},
+ }
+ var lastErr error
+ for _, candidate := range commands {
+ if err := c.startReceiveProcessLocked(cliPath, candidate.mode, candidate.args); err != nil {
+ lastErr = err
+ continue
+ }
+ if err := c.subscribeRPC(c.runCtx); err != nil {
+ c.cleanupReceiveProcessLocked()
+ lastErr = err
+ continue
+ }
+ return nil
+ }
+ if lastErr != nil {
+ return lastErr
+ }
+ return fmt.Errorf("imsg startup failed: no receive command available")
+}
+
+func (c *IMsgChannel) cleanupReceiveProcessLocked() {
+ stdin := c.rpcStdin
+ cmd := c.rpcCmd
+ c.rpcStdin = nil
+ c.rpcCmd = nil
+ if stdin != nil {
+ _ = stdin.Close()
+ }
+ if cmd != nil && cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ c.wg.Wait()
+}
+
+func (c *IMsgChannel) startReceiveProcessLocked(cliPath, mode string, args []string) error {
+ cmd := exec.CommandContext(c.runCtx, cliPath, args...)
+ logger.InfoCF("imsg", "imsg receive command", map[string]any{
+ "mode": mode,
+ "cmd": strings.Join(cmd.Args, " "),
+ })
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("imsg %s stdout pipe: %w", mode, err)
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return fmt.Errorf("imsg %s stderr pipe: %w", mode, err)
+ }
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return fmt.Errorf("imsg %s stdin pipe: %w", mode, err)
+ }
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("start imsg %s: %w", mode, err)
+ }
+ c.rpcCmd = cmd
+ c.rpcStdin = stdin
+ c.recvMode = mode
+
+ exited := make(chan error, 1)
+ c.wg.Add(3)
+ go c.consumeRPCStdout(stdout)
+ go c.consumeRPCStderr(stderr)
+ go c.waitRPCExit(cmd, exited)
+
+ // Startup probe: if RPC exits immediately, fail Start with a clear reason.
+ select {
+ case err := <-exited:
+ c.rpcCmd = nil
+ c.rpcStdin = nil
+ c.wg.Wait()
+ if err == nil {
+ if fatal := c.getFatalLine(); fatal != "" {
+ return fmt.Errorf(
+ "imsg %s exited during startup (cmd=%q, fatal=%s)",
+ mode,
+ strings.Join(cmd.Args, " "),
+ fatal,
+ )
+ }
+ if last := c.getLastLine(); last != "" {
+ return fmt.Errorf(
+ "imsg %s exited during startup (cmd=%q, last output: %s)",
+ mode,
+ strings.Join(cmd.Args, " "),
+ last,
+ )
+ }
+ return fmt.Errorf(
+ "imsg %s exited during startup (cmd=%q, no output captured)",
+ mode,
+ strings.Join(cmd.Args, " "),
+ )
+ }
+ if fatal := c.getFatalLine(); fatal != "" {
+ return fmt.Errorf(
+ "imsg %s startup failed: %v (cmd=%q, fatal=%s)",
+ mode,
+ err,
+ strings.Join(cmd.Args, " "),
+ fatal,
+ )
+ }
+ if last := c.getLastLine(); last != "" {
+ return fmt.Errorf(
+ "imsg %s startup failed: %v (cmd=%q, last output: %s)",
+ mode,
+ err,
+ strings.Join(cmd.Args, " "),
+ last,
+ )
+ }
+ return fmt.Errorf(
+ "imsg %s startup failed: %w (cmd=%q, no output captured)",
+ mode,
+ err,
+ strings.Join(cmd.Args, " "),
+ )
+ case <-time.After(1200 * time.Millisecond):
+ }
+ logger.InfoCF("imsg", "imsg receive process started", map[string]any{
+ "mode": mode,
+ "cmd": strings.Join(cmd.Args, " "),
+ })
+ // NOTE: History polling fallback is intentionally disabled.
+ // Use pure JSON-RPC watch.subscribe for inbound messages.
+ return nil
+}
+
+func (c *IMsgChannel) consumeRPCStdout(r io.Reader) {
+ defer c.wg.Done()
+ scanner := bufio.NewScanner(r)
+ scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
+ for scanner.Scan() {
+ c.handleRPCLine(scanner.Text())
+ }
+ if err := scanner.Err(); err != nil && c.IsRunning() {
+ logger.WarnCF("imsg", "imsg rpc stdout scanner error", map[string]any{"error": err.Error()})
+ }
+}
+
+func (c *IMsgChannel) consumeRPCStderr(r io.Reader) {
+ defer c.wg.Done()
+ scanner := bufio.NewScanner(r)
+ scanner.Buffer(make([]byte, 0, 8*1024), 512*1024)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ c.setLastLine(line)
+ if isFatalRPCLine(line) {
+ c.setFatalLine(line)
+ }
+ logger.DebugCF("imsg", "imsg rpc stderr", map[string]any{"line": line})
+ }
+}
+
+func (c *IMsgChannel) waitRPCExit(cmd *exec.Cmd, exited chan<- error) {
+ defer c.wg.Done()
+ err := cmd.Wait()
+ select {
+ case exited <- err:
+ default:
+ }
+ if err != nil && c.IsRunning() {
+ fields := map[string]any{"error": err.Error()}
+ if fatal := c.getFatalLine(); fatal != "" {
+ fields["hint"] = fatal
+ } else if last := c.getLastLine(); last != "" {
+ fields["last_output"] = last
+ }
+ logger.ErrorCF("imsg", "imsg rpc exited unexpectedly", fields)
+ }
+}
+
+func (c *IMsgChannel) handleRPCLine(line string) {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ return
+ }
+ c.setLastLine(line)
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(line), &raw); err != nil {
+ if isFatalRPCLine(line) {
+ c.setFatalLine(line)
+ logger.ErrorCF("imsg", "imsg rpc fatal output", map[string]any{"line": line})
+ return
+ }
+ logger.DebugCF("imsg", "skip non-json rpc line", map[string]any{"line": line})
+ return
+ }
+ // Handle JSON-RPC responses first.
+ if id, ok := raw["id"]; ok && (raw["result"] != nil || raw["error"] != nil) {
+ c.resolvePending(id, raw)
+ return
+ }
+
+ method := strings.ToLower(anyToString(raw["method"]))
+ if method != "" {
+ // imsg legacy notification: method = "message"
+ if strings.Contains(method, "message") || strings.Contains(method, "watch") {
+ params := firstMap(raw["params"], raw["result"], raw["payload"], raw["message"], raw)
+ c.handlePayload(params, line)
+ return
+ }
+ // Non-message RPC notifications (e.g. ping/ack) should not enter agent loop.
+ logger.DebugCF("imsg", "skip non-message rpc notification", map[string]any{"method": method})
+ return
+ }
+
+ // Ignore JSON lines without method to avoid parsing RPC response-like payloads
+ // as inbound chat messages, which can create self-reply loops.
+ logger.DebugCF("imsg", "skip rpc json without method", map[string]any{"line": line})
+}
+
+func (c *IMsgChannel) handlePayload(raw map[string]any, rawLine string) {
+ // Ignore messages sent by ourselves to avoid self-trigger loops.
+ if deepBool(raw, "is_from_me", "from_me", "is_me", "isFromMe", "fromMe", "outgoing") {
+ return
+ }
+ content := deepCoalesceString(raw, "content", "text", "message", "body")
+ if strings.TrimSpace(content) == "" {
+ return
+ }
+
+ kind := strings.ToLower(deepCoalesceString(raw, "type", "event", "method", "name"))
+ if strings.Contains(kind, "outgoing") || strings.Contains(kind, "sent") {
+ return
+ }
+
+ senderID := deepCoalesceString(raw, "from", "sender", "sender_id", "handle", "from_id", "participant")
+ chatID := deepCoalesceString(
+ raw,
+ "chat_id",
+ "chat",
+ "conversation",
+ "thread",
+ "peer",
+ "to",
+ "chat_identifier",
+ "conversation_id",
+ )
+ if senderID == "" && chatID == "" {
+ logger.DebugCF("imsg", "imsg rpc json parsed but missing sender/chat", map[string]any{
+ "kind": kind,
+ "line": rawLine,
+ })
+ return
+ }
+ if senderID == "" {
+ senderID = chatID
+ }
+ if chatID == "" {
+ chatID = senderID
+ }
+ messageID := deepCoalesceString(raw, "id", "message_id", "guid", "rowid")
+ if messageID != "" && !c.markSeen(messageID) {
+ return
+ }
+ if messageID == "" {
+ fingerprint := eventFingerprint(chatID, senderID, content, deepCoalesceString(raw, "timestamp", "date", "time"))
+ if !c.markSeenEvent(fingerprint) {
+ return
+ }
+ }
+
+ peerKind := "direct"
+ if deepBool(raw, "is_group", "group", "isGroup") {
+ peerKind = "group"
+ }
+ peer := bus.Peer{Kind: peerKind, ID: chatID}
+ sender := bus.SenderInfo{
+ Platform: "imsg",
+ PlatformID: senderID,
+ CanonicalID: identity.BuildCanonicalID("imsg", senderID),
+ }
+ if !c.isAllowedIMsg(sender, senderID, chatID) {
+ logger.DebugCF("imsg", "imsg message blocked by allow_from", map[string]any{
+ "sender_id": senderID,
+ "chat_id": chatID,
+ })
+ c.sendNotAllowedNotice(chatID)
+ return
+ }
+ metadata := map[string]string{"platform": "imsg"}
+ if kind != "" {
+ metadata["rpc_event"] = kind
+ }
+ metadata["recv_mode"] = c.recvMode
+ if rawLine != "" {
+ metadata["raw_line"] = rawLine
+ }
+ logger.InfoCF("imsg", "imsg inbound message received", map[string]any{
+ "mode": c.recvMode,
+ "event": kind,
+ "sender_id": senderID,
+ "chat_id": chatID,
+ })
+ c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, nil, metadata, sender)
+}
+
+func (c *IMsgChannel) isAllowedIMsg(sender bus.SenderInfo, senderID, chatID string) bool {
+ // 1) Canonical sender matching (preferred).
+ if c.IsAllowedSender(sender) {
+ return true
+ }
+ // 2) Legacy sender matching.
+ if senderID != "" && c.IsAllowed(senderID) {
+ return true
+ }
+ return false
+}
+
+func (c *IMsgChannel) sendNotAllowedNotice(chatID string) {
+ if strings.TrimSpace(chatID) == "" || !c.IsRunning() {
+ return
+ }
+ _, err := c.Send(c.runCtx, bus.OutboundMessage{
+ Channel: "imsg",
+ ChatID: chatID,
+ Content: "not allowed",
+ })
+ if err != nil {
+ logger.DebugCF("imsg", "failed to send not-allowed notice", map[string]any{
+ "chat_id": chatID,
+ "error": err.Error(),
+ })
+ }
+}
+
+func deepCoalesceString(v any, keys ...string) string {
+ for _, k := range keys {
+ if s := deepLookupString(v, k); s != "" {
+ return s
+ }
+ }
+ return ""
+}
+
+func deepLookupString(v any, key string) string {
+ switch vv := v.(type) {
+ case map[string]any:
+ if val, ok := vv[key]; ok {
+ if s := anyToString(val); s != "" {
+ return s
+ }
+ }
+ for _, child := range vv {
+ if s := deepLookupString(child, key); s != "" {
+ return s
+ }
+ }
+ case []any:
+ for _, child := range vv {
+ if s := deepLookupString(child, key); s != "" {
+ return s
+ }
+ }
+ }
+ return ""
+}
+
+func anyToString(v any) string {
+ switch vv := v.(type) {
+ case string:
+ if strings.TrimSpace(vv) != "" {
+ return vv
+ }
+ case json.Number:
+ return vv.String()
+ case float64:
+ return fmt.Sprintf("%.0f", vv)
+ case int, int32, int64, uint, uint32, uint64:
+ return fmt.Sprintf("%v", vv)
+ }
+ return ""
+}
+
+func deepBool(v any, keys ...string) bool {
+ for _, k := range keys {
+ if b, ok := deepLookupBool(v, k); ok {
+ return b
+ }
+ }
+ return false
+}
+
+func deepLookupBool(v any, key string) (bool, bool) {
+ switch vv := v.(type) {
+ case map[string]any:
+ if val, ok := vv[key]; ok {
+ if b, okb := val.(bool); okb {
+ return b, true
+ }
+ // Handle 0/1 style flags.
+ rv := reflect.ValueOf(val)
+ switch rv.Kind() {
+ case reflect.Int, reflect.Int32, reflect.Int64:
+ return rv.Int() != 0, true
+ case reflect.Uint, reflect.Uint32, reflect.Uint64:
+ return rv.Uint() != 0, true
+ case reflect.Float32, reflect.Float64:
+ return rv.Float() != 0, true
+ }
+ }
+ for _, child := range vv {
+ if b, ok := deepLookupBool(child, key); ok {
+ return b, true
+ }
+ }
+ case []any:
+ for _, child := range vv {
+ if b, ok := deepLookupBool(child, key); ok {
+ return b, true
+ }
+ }
+ }
+ return false, false
+}
+
+func (c *IMsgChannel) subscribeRPC(ctx context.Context) error {
+ methods := []string{"watch.subscribe", "subscribe"}
+ var lastErr error
+ for _, m := range methods {
+ logger.InfoCF("imsg", "imsg rpc subscribe request", map[string]any{"method": m})
+ if _, err := c.rpcCall(ctx, m, map[string]any{}, 6*time.Second); err == nil {
+ return nil
+ } else {
+ lastErr = err
+ }
+ }
+ return fmt.Errorf("rpc subscribe failed: %w", lastErr)
+}
+
+func (c *IMsgChannel) rpcCall(ctx context.Context, method string, params any, timeout time.Duration) (any, error) {
+ id := fmt.Sprintf("%d", c.reqSeq.Add(1))
+ req := map[string]any{
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": method,
+ "params": params,
+ }
+ data, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+
+ respCh := make(chan rpcResponse, 1)
+ c.pendingMu.Lock()
+ c.pending[id] = respCh
+ c.pendingMu.Unlock()
+ defer func() {
+ c.pendingMu.Lock()
+ delete(c.pending, id)
+ c.pendingMu.Unlock()
+ }()
+
+ c.writeMu.Lock()
+ stdin := c.rpcStdin
+ if stdin == nil {
+ c.writeMu.Unlock()
+ return nil, fmt.Errorf("rpc stdin unavailable")
+ }
+ _, wErr := io.WriteString(stdin, string(data)+"\n")
+ c.writeMu.Unlock()
+ if wErr != nil {
+ return nil, fmt.Errorf("rpc write failed: %w", wErr)
+ }
+
+ waitCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ select {
+ case <-waitCtx.Done():
+ return nil, waitCtx.Err()
+ case resp := <-respCh:
+ if resp.Error != nil {
+ return nil, fmt.Errorf("rpc error %d: %s", resp.Error.Code, resp.Error.Message)
+ }
+ return resp.Result, nil
+ }
+}
+
+func (c *IMsgChannel) resolvePending(id any, raw map[string]any) {
+ key := anyToString(id)
+ if key == "" {
+ return
+ }
+ c.pendingMu.Lock()
+ ch, ok := c.pending[key]
+ c.pendingMu.Unlock()
+ if !ok {
+ return
+ }
+ resp := rpcResponse{Result: raw["result"]}
+ if errObj, ok := raw["error"].(map[string]any); ok {
+ resp.Error = &rpcError{
+ Code: int(toFloat(errObj["code"])),
+ Message: anyToString(errObj["message"]),
+ }
+ }
+ select {
+ case ch <- resp:
+ default:
+ }
+}
+
+func firstMap(candidates ...any) map[string]any {
+ for _, v := range candidates {
+ switch vv := v.(type) {
+ case map[string]any:
+ if msg, ok := vv["message"].(map[string]any); ok {
+ return msg
+ }
+ if data, ok := vv["data"].(map[string]any); ok {
+ return data
+ }
+ if payload, ok := vv["payload"].(map[string]any); ok {
+ return payload
+ }
+ return vv
+ case []any:
+ for _, item := range vv {
+ if mm, ok := item.(map[string]any); ok {
+ return mm
+ }
+ }
+ }
+ }
+ return map[string]any{}
+}
+
+func toFloat(v any) float64 {
+ switch vv := v.(type) {
+ case float64:
+ return vv
+ case int:
+ return float64(vv)
+ case int64:
+ return float64(vv)
+ case json.Number:
+ f, _ := vv.Float64()
+ return f
+ }
+ return 0
+}
+
+func isFatalRPCLine(line string) bool {
+ l := strings.ToLower(line)
+ return strings.Contains(l, "permissiondenied") ||
+ strings.Contains(l, "authorization denied") ||
+ (strings.Contains(l, "chat.db") && strings.Contains(l, "denied"))
+}
+
+func (c *IMsgChannel) setFatalLine(line string) {
+ c.fatalMu.Lock()
+ defer c.fatalMu.Unlock()
+ if c.fatalLine == "" {
+ c.fatalLine = line
+ }
+}
+
+func (c *IMsgChannel) getFatalLine() string {
+ c.fatalMu.Lock()
+ defer c.fatalMu.Unlock()
+ return c.fatalLine
+}
+
+func (c *IMsgChannel) setLastLine(line string) {
+ c.fatalMu.Lock()
+ defer c.fatalMu.Unlock()
+ c.lastLine = line
+}
+
+func (c *IMsgChannel) getLastLine() string {
+ c.fatalMu.Lock()
+ defer c.fatalMu.Unlock()
+ return c.lastLine
+}
+
+func (c *IMsgChannel) markSeen(id string) bool {
+ c.seenMu.Lock()
+ defer c.seenMu.Unlock()
+ if _, ok := c.seenIDs[id]; ok {
+ return false
+ }
+ c.seenIDs[id] = struct{}{}
+ return true
+}
+
+func (c *IMsgChannel) markSeenEvent(fp string) bool {
+ c.seenMu.Lock()
+ defer c.seenMu.Unlock()
+ now := time.Now()
+ const ttl = 2 * time.Minute
+ if t, ok := c.seenEvent[fp]; ok && now.Sub(t) <= ttl {
+ return false
+ }
+ c.seenEvent[fp] = now
+ // Opportunistic cleanup to keep map bounded.
+ for k, t := range c.seenEvent {
+ if now.Sub(t) > ttl {
+ delete(c.seenEvent, k)
+ }
+ }
+ return true
+}
+
+func eventFingerprint(chatID, senderID, content, ts string) string {
+ sum := sha1.Sum([]byte(chatID + "|" + senderID + "|" + content + "|" + ts))
+ return fmt.Sprintf("%x", sum[:])
+}
Index: pkg/channels/imsg/init.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pkg/channels/imsg/init.go b/pkg/channels/imsg/init.go
new file mode 100644
--- /dev/null (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
+++ b/pkg/channels/imsg/init.go (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -0,0 +1,16 @@
+package imsg
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("imsg", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ if !cfg.Channels.IMsg.Enabled {
+ return nil, nil
+ }
+ return NewIMsgChannel(cfg.Channels.IMsg, b)
+ })
+}
Index: pkg/channels/manager.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
--- a/pkg/channels/manager.go (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/pkg/channels/manager.go (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -374,6 +374,10 @@
m.initChannel("discord", "Discord")
}
+ if channels.IMsg.Enabled {
+ m.initChannel("imsg", "iMessage")
+ }
+
if channels.MaixCam.Enabled {
m.initChannel("maixcam", "MaixCam")
}
Index: pkg/config/config.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pkg/config/config.go b/pkg/config/config.go
--- a/pkg/config/config.go (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/pkg/config/config.go (revision 9f4fadd446eb36835d4e3c23e652b86c98b601a4)
@@ -281,6 +281,7 @@
Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"`
Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"`
Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"`
+ IMsg IMsgConfig `json:"imsg" yaml:"imsg,omitempty"`
MaixCam MaixCamConfig `json:"maixcam" yaml:"-"`
QQ QQConfig `json:"qq" yaml:"qq,omitempty"`
DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"`
@@ -383,6 +384,12 @@
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
}
+type IMsgConfig struct {
+ Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_IMSG_ENABLED"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_IMSG_ALLOW_FROM"`
+ IMessageCLIPath string `json:"iMessageCLIPath" yaml:"-" env:"PICOCLAW_CHANNELS_IMSG_IMESSAGECLIPATH"`
+}
+
type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Index: pkg/config/defaults.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
--- a/pkg/config/defaults.go (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/pkg/config/defaults.go (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -68,6 +68,11 @@
AllowFrom: FlexibleStringSlice{},
MentionOnly: false,
},
+ IMsg: IMsgConfig{
+ Enabled: false,
+ AllowFrom: FlexibleStringSlice{},
+ IMessageCLIPath: "imsg",
+ },
MaixCam: MaixCamConfig{
Enabled: false,
Host: "0.0.0.0",
Index: pkg/gateway/gateway.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
--- a/pkg/gateway/gateway.go (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/pkg/gateway/gateway.go (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -18,6 +18,7 @@
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
+ _ "github.com/sipeed/picoclaw/pkg/channels/imsg"
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
Index: web/backend/api/channels.go
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go
--- a/web/backend/api/channels.go (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/web/backend/api/channels.go (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -15,6 +15,7 @@
{Name: "weixin", ConfigKey: "weixin"},
{Name: "telegram", ConfigKey: "telegram"},
{Name: "discord", ConfigKey: "discord"},
+ {Name: "imsg", ConfigKey: "imsg"},
{Name: "slack", ConfigKey: "slack"},
{Name: "feishu", ConfigKey: "feishu"},
{Name: "dingtalk", ConfigKey: "dingtalk"},
Index: web/frontend/src/components/channels/channel-config-page.tsx
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx
--- a/web/frontend/src/components/channels/channel-config-page.tsx (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/web/frontend/src/components/channels/channel-config-page.tsx (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -225,6 +225,7 @@
const CHANNELS_WITHOUT_DOCS = new Set([
"pico",
+ "imsg",
"wecom",
"matrix",
"irc",
Index: web/frontend/src/components/channels/channel-forms/generic-form.tsx
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx
--- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -151,6 +151,7 @@
channels: t("channels.form.desc.channels"),
request_caps: t("channels.form.desc.requestCaps"),
max_base64_file_size_mib: t("channels.form.desc.maxBase64FileSizeMiB"),
+ iMessageCLIPath: t("channels.form.desc.iMessageCLIPath"),
}
return (
descriptions[key] ??
Index: web/frontend/src/hooks/use-sidebar-channels.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts
--- a/web/frontend/src/hooks/use-sidebar-channels.ts (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/web/frontend/src/hooks/use-sidebar-channels.ts (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -34,6 +34,7 @@
"wecom",
"dingtalk",
"qq",
+ "imsg",
"onebot",
"matrix",
"pico",
@@ -83,6 +84,7 @@
onebot: IconRobot,
pico: IconBrandChrome,
irc: IconMessages,
+ imsg: IconMessages,
}
function asRecord(value: unknown): Record<string, unknown> {
Index: web/frontend/src/i18n/locales/en.json
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
--- a/web/frontend/src/i18n/locales/en.json (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/web/frontend/src/i18n/locales/en.json (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -249,6 +249,7 @@
"name": {
"telegram": "Telegram",
"discord": "Discord",
+ "imsg": "iMessage",
"slack": "Slack",
"feishu": "Feishu",
"dingtalk": "DingTalk",
@@ -385,6 +386,7 @@
"realName": "Displayed real name.",
"channels": "IRC channels to join.",
"requestCaps": "IRC capability list requested on connect.",
+ "iMessageCLIPath": "Path to iMessage CLI executable (default: imsg).",
"maxBase64FileSizeMiB": "Maximum size in MiB for converting local files to base64 before upload. 0 means unlimited. Applies only to local files, not URL uploads.",
"genericField": "Used to configure {{field}}."
}
Index: web/frontend/src/i18n/locales/zh.json
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
--- a/web/frontend/src/i18n/locales/zh.json (revision e4893d27d769a8441d2d2ac491670158f696d327)
+++ b/web/frontend/src/i18n/locales/zh.json (revision 1b841470cad5f1a1bd520abbac6ce2e7a7b28802)
@@ -249,6 +249,7 @@
"name": {
"telegram": "Telegram",
"discord": "Discord",
+ "imsg": "iMessage",
"slack": "Slack",
"feishu": "飞书",
"dingtalk": "钉钉",
@@ -385,6 +386,7 @@
"realName": "显示名称。",
"channels": "要加入的 IRC 频道列表。",
"requestCaps": "连接时请求的 IRC 扩展能力列表。",
+ "iMessageCLIPath": "iMessage CLI 可执行文件路径默认imsg。",
"maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB0 表示不限制,仅影响本地文件,不影响 URL 直传。",
"genericField": "用于配置{{field}}。"
}