diff --git a/README.md b/README.md index b8c5651..458ae92 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,84 @@ anytype space join anytype space leave ``` +### Chat Operations + +Chat support enables programmatic interaction with Anytype's messaging feature. This unlocks powerful automation possibilities: build chat bots, create notification integrations, archive conversations, or bridge Anytype chats with other platformsβ€”all through the command line. + +#### Getting Started + +First, discover chat objects in your space: + +```bash +# Get your space ID +anytype space list + +# Find all chat objects in the space +anytype chat find +``` + +This displays a table of chat objects with their Chat IDs, names, and Object IDs. Use the Chat ID for subsequent commands. + +#### Sending Messages + +```bash +# Send a simple message +anytype chat send "Hello from the CLI!" + +# Reply to a specific message +anytype chat send "Thanks for the info" --reply-to +``` + +#### Reading Messages + +```bash +# List recent messages (default: 20) +anytype chat list + +# Get more messages +anytype chat list -n 50 + +# Show newest first +anytype chat list --reverse + +# Pagination: get messages before/after a specific point +anytype chat list --before +anytype chat list --after +``` + +#### Managing Messages + +```bash +# Edit a message you sent +anytype chat edit "Updated text" + +# Delete a message +anytype chat delete + +# Add or remove a reaction +anytype chat react "πŸ‘" + +# Mark messages as read +anytype chat read +``` + +#### Example: Simple Chat Bot + +```bash +#!/bin/bash +CHAT_ID="your-chat-id" + +# Monitor and respond (basic polling example) +while true; do + # Get latest messages + anytype chat list $CHAT_ID -n 5 --reverse + + # Your bot logic here... + + sleep 10 +done +``` + ## Development ### Project Structure diff --git a/cmd/chat/chat.go b/cmd/chat/chat.go new file mode 100644 index 0000000..58d7523 --- /dev/null +++ b/cmd/chat/chat.go @@ -0,0 +1,31 @@ +package chat + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/cmd/chat/delete" + "github.com/anyproto/anytype-cli/cmd/chat/edit" + "github.com/anyproto/anytype-cli/cmd/chat/find" + "github.com/anyproto/anytype-cli/cmd/chat/list" + "github.com/anyproto/anytype-cli/cmd/chat/react" + "github.com/anyproto/anytype-cli/cmd/chat/read" + "github.com/anyproto/anytype-cli/cmd/chat/send" +) + +func NewChatCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "chat", + Short: "Chat operations", + Long: "Send, receive, and manage chat messages in Anytype spaces", + } + + cmd.AddCommand(find.NewFindCmd()) + cmd.AddCommand(send.NewSendCmd()) + cmd.AddCommand(list.NewListCmd()) + cmd.AddCommand(edit.NewEditCmd()) + cmd.AddCommand(delete.NewDeleteCmd()) + cmd.AddCommand(react.NewReactCmd()) + cmd.AddCommand(read.NewReadCmd()) + + return cmd +} diff --git a/cmd/chat/delete/delete.go b/cmd/chat/delete/delete.go new file mode 100644 index 0000000..fa59b9d --- /dev/null +++ b/cmd/chat/delete/delete.go @@ -0,0 +1,31 @@ +package delete + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a message", + Long: "Delete a message from a chat", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + chatId := args[0] + messageId := args[1] + + err := core.DeleteChatMessage(chatId, messageId) + if err != nil { + return output.Error("Failed to delete message: %w", err) + } + + output.Info("Message deleted successfully") + return nil + }, + } + + return cmd +} diff --git a/cmd/chat/edit/edit.go b/cmd/chat/edit/edit.go new file mode 100644 index 0000000..e5a4e54 --- /dev/null +++ b/cmd/chat/edit/edit.go @@ -0,0 +1,32 @@ +package edit + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewEditCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "edit ", + Short: "Edit a message", + Long: "Edit the content of an existing chat message", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + chatId := args[0] + messageId := args[1] + newText := args[2] + + err := core.EditChatMessage(chatId, messageId, newText) + if err != nil { + return output.Error("Failed to edit message: %w", err) + } + + output.Info("Message edited successfully") + return nil + }, + } + + return cmd +} diff --git a/cmd/chat/find/find.go b/cmd/chat/find/find.go new file mode 100644 index 0000000..6c08c9c --- /dev/null +++ b/cmd/chat/find/find.go @@ -0,0 +1,49 @@ +package find + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewFindCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "find ", + Short: "Find chat objects in a space", + Long: "Search for objects with chat functionality in a space and display their chat IDs", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + spaceId := args[0] + + chats, err := core.FindChats(spaceId) + if err != nil { + return output.Error("Failed to find chats: %w", err) + } + + if len(chats) == 0 { + output.Info("No chat objects found in this space") + return nil + } + + output.Info("%-40s %-20s %s", "CHAT ID", "NAME", "OBJECT ID") + output.Info("%-40s %-20s %s", "───────", "────", "─────────") + + for _, chat := range chats { + name := chat.Name + if len(name) > 18 { + name = name[:15] + "..." + } + chatId := chat.ChatID + if chatId == "" { + chatId = "(no chatId set)" + } + output.Info("%-40s %-20s %s", chatId, name, chat.ObjectID) + } + + return nil + }, + } + + return cmd +} diff --git a/cmd/chat/list/list.go b/cmd/chat/list/list.go new file mode 100644 index 0000000..b79a74c --- /dev/null +++ b/cmd/chat/list/list.go @@ -0,0 +1,81 @@ +package list + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewListCmd() *cobra.Command { + var ( + limit int32 + before string + after string + reverse bool + ) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List messages in a chat", + Long: "Retrieve and display messages from an Anytype chat object", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + chatId := args[0] + + messages, err := core.GetChatMessages(chatId, limit, before, after) + if err != nil { + return output.Error("Failed to get messages: %w", err) + } + + if len(messages) == 0 { + output.Info("No messages found") + return nil + } + + // Print in chronological order (oldest first) unless reversed + start, end, step := 0, len(messages), 1 + if reverse { + start, end, step = len(messages)-1, -1, -1 + } + + for i := start; i != end; i += step { + msg := messages[i] + timestamp := msg.CreatedAt.Format("2006-01-02 15:04:05") + readMark := " " + if !msg.Read { + readMark = "●" + } + + output.Info("%s [%s] %s:", readMark, timestamp, msg.Creator) + output.Info(" %s", msg.Text) + + if len(msg.Reactions) > 0 { + reactionStr := " Reactions:" + for emoji, users := range msg.Reactions { + reactionStr += fmt.Sprintf(" %s(%d)", emoji, len(users)) + } + output.Info(reactionStr) + } + + if msg.ReplyTo != "" { + output.Info(" ↳ Reply to: %s", msg.ReplyTo) + } + + output.Info(" ID: %s", msg.ID) + output.Info("") + } + + return nil + }, + } + + cmd.Flags().Int32VarP(&limit, "limit", "n", 20, "Maximum number of messages to retrieve") + cmd.Flags().StringVar(&before, "before", "", "Get messages before this order ID") + cmd.Flags().StringVar(&after, "after", "", "Get messages after this order ID") + cmd.Flags().BoolVarP(&reverse, "reverse", "r", false, "Show newest messages first") + + return cmd +} diff --git a/cmd/chat/react/react.go b/cmd/chat/react/react.go new file mode 100644 index 0000000..bb9f17f --- /dev/null +++ b/cmd/chat/react/react.go @@ -0,0 +1,36 @@ +package react + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewReactCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "react ", + Short: "React to a message", + Long: "Add or remove a reaction (emoji) from a message. Running twice toggles the reaction off.", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + chatId := args[0] + messageId := args[1] + emoji := args[2] + + added, err := core.ToggleChatReaction(chatId, messageId, emoji) + if err != nil { + return output.Error("Failed to toggle reaction: %w", err) + } + + if added { + output.Info("Reaction %s added", emoji) + } else { + output.Info("Reaction %s removed", emoji) + } + return nil + }, + } + + return cmd +} diff --git a/cmd/chat/read/read.go b/cmd/chat/read/read.go new file mode 100644 index 0000000..2fe7c30 --- /dev/null +++ b/cmd/chat/read/read.go @@ -0,0 +1,38 @@ +package read + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewReadCmd() *cobra.Command { + var ( + afterOrderId string + beforeOrderId string + ) + + cmd := &cobra.Command{ + Use: "read ", + Short: "Mark chat messages as read", + Long: "Mark messages in a chat as read within an optional range", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + chatId := args[0] + + err := core.MarkChatMessagesRead(chatId, afterOrderId, beforeOrderId) + if err != nil { + return output.Error("Failed to mark messages as read: %w", err) + } + + output.Info("Messages marked as read") + return nil + }, + } + + cmd.Flags().StringVar(&afterOrderId, "after", "", "Mark messages after this order ID") + cmd.Flags().StringVar(&beforeOrderId, "before", "", "Mark messages before this order ID") + + return cmd +} diff --git a/cmd/chat/send/send.go b/cmd/chat/send/send.go new file mode 100644 index 0000000..bd748e5 --- /dev/null +++ b/cmd/chat/send/send.go @@ -0,0 +1,36 @@ +package send + +import ( + "github.com/spf13/cobra" + + "github.com/anyproto/anytype-cli/core" + "github.com/anyproto/anytype-cli/core/output" +) + +func NewSendCmd() *cobra.Command { + var replyTo string + + cmd := &cobra.Command{ + Use: "send ", + Short: "Send a message to a chat", + Long: "Send a text message to an Anytype chat object", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + chatId := args[0] + message := args[1] + + msgId, err := core.SendChatMessage(chatId, message, replyTo) + if err != nil { + return output.Error("Failed to send message: %w", err) + } + + output.Info("Message sent successfully") + output.Info("Message ID: %s", msgId) + return nil + }, + } + + cmd.Flags().StringVar(&replyTo, "reply-to", "", "Message ID to reply to") + + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index 2a62751..dfa70a4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/anyproto/anytype-cli/cmd/auth" + "github.com/anyproto/anytype-cli/cmd/chat" "github.com/anyproto/anytype-cli/cmd/config" "github.com/anyproto/anytype-cli/cmd/serve" "github.com/anyproto/anytype-cli/cmd/service" @@ -50,6 +51,7 @@ func init() { rootCmd.AddCommand( auth.NewAuthCmd(), + chat.NewChatCmd(), config.NewConfigCmd(), serve.NewServeCmd(), service.NewServiceCmd(), diff --git a/core/chat.go b/core/chat.go new file mode 100644 index 0000000..e8d9828 --- /dev/null +++ b/core/chat.go @@ -0,0 +1,225 @@ +package core + +import ( + "context" + "fmt" + "time" + + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pb/service" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" +) + +// ChatMessage represents a chat message for display +type ChatMessage struct { + ID string + OrderID string + Creator string + Text string + CreatedAt time.Time + Reactions map[string][]string // emoji -> user IDs + ReplyTo string + Read bool +} + +// ChatInfo represents a chat object found in a space +type ChatInfo struct { + ChatID string + Name string + ObjectID string +} + +// parseChatMessage converts a protobuf ChatMessage to our ChatMessage type +func parseChatMessage(m *model.ChatMessage) ChatMessage { + msg := ChatMessage{ + ID: m.Id, + OrderID: m.OrderId, + Creator: m.Creator, + CreatedAt: time.Unix(m.CreatedAt, 0), + ReplyTo: m.ReplyToMessageId, + Read: m.Read, + } + if m.Message != nil { + msg.Text = m.Message.Text + } + if m.Reactions != nil { + msg.Reactions = make(map[string][]string) + for emoji, identList := range m.Reactions.Reactions { + msg.Reactions[emoji] = identList.Ids + } + } + return msg +} + +// FindChats searches for chat objects in a space +func FindChats(spaceId string) ([]ChatInfo, error) { + var chats []ChatInfo + err := GRPCCall(func(ctx context.Context, client service.ClientCommandsClient) error { + req := &pb.RpcObjectSearchRequest{ + SpaceId: spaceId, + Keys: []string{"id", "name", "chatId", "hasChat", "layout"}, + Limit: 100, + } + resp, err := client.ObjectSearch(ctx, req) + if err != nil { + return fmt.Errorf("failed to search objects: %w", err) + } + if resp.Error != nil && resp.Error.Code != pb.RpcObjectSearchResponseError_NULL { + return fmt.Errorf("search error: %s", resp.Error.Description) + } + + for _, record := range resp.Records { + chatId := pbtypes.GetString(record, "chatId") + hasChat := pbtypes.GetBool(record, "hasChat") + + if chatId != "" || hasChat { + chats = append(chats, ChatInfo{ + ChatID: chatId, + Name: pbtypes.GetString(record, "name"), + ObjectID: pbtypes.GetString(record, "id"), + }) + } + } + return nil + }) + return chats, err +} + +// SendChatMessage sends a message to a chat object +func SendChatMessage(chatObjectId string, text string, replyToMsgId string) (string, error) { + var msgId string + err := GRPCCall(func(ctx context.Context, client service.ClientCommandsClient) error { + msg := &model.ChatMessage{ + Message: &model.ChatMessageMessageContent{ + Text: text, + }, + } + if replyToMsgId != "" { + msg.ReplyToMessageId = replyToMsgId + } + + req := &pb.RpcChatAddMessageRequest{ + ChatObjectId: chatObjectId, + Message: msg, + } + resp, err := client.ChatAddMessage(ctx, req) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + if resp.Error != nil && resp.Error.Code != pb.RpcChatAddMessageResponseError_NULL { + return fmt.Errorf("send message error: %s", resp.Error.Description) + } + msgId = resp.MessageId + return nil + }) + return msgId, err +} + +// GetChatMessages retrieves messages from a chat object +func GetChatMessages(chatObjectId string, limit int32, beforeOrderId string, afterOrderId string) ([]ChatMessage, error) { + var messages []ChatMessage + err := GRPCCall(func(ctx context.Context, client service.ClientCommandsClient) error { + req := &pb.RpcChatGetMessagesRequest{ + ChatObjectId: chatObjectId, + Limit: limit, + BeforeOrderId: beforeOrderId, + AfterOrderId: afterOrderId, + } + resp, err := client.ChatGetMessages(ctx, req) + if err != nil { + return fmt.Errorf("failed to get messages: %w", err) + } + if resp.Error != nil && resp.Error.Code != pb.RpcChatGetMessagesResponseError_NULL { + return fmt.Errorf("get messages error: %s", resp.Error.Description) + } + + for _, m := range resp.Messages { + messages = append(messages, parseChatMessage(m)) + } + return nil + }) + return messages, err +} + +// EditChatMessage edits an existing message +func EditChatMessage(chatObjectId string, messageId string, newText string) error { + return GRPCCall(func(ctx context.Context, client service.ClientCommandsClient) error { + req := &pb.RpcChatEditMessageContentRequest{ + ChatObjectId: chatObjectId, + MessageId: messageId, + EditedMessage: &model.ChatMessage{ + Message: &model.ChatMessageMessageContent{ + Text: newText, + }, + }, + } + resp, err := client.ChatEditMessageContent(ctx, req) + if err != nil { + return fmt.Errorf("failed to edit message: %w", err) + } + if resp.Error != nil && resp.Error.Code != pb.RpcChatEditMessageContentResponseError_NULL { + return fmt.Errorf("edit message error: %s", resp.Error.Description) + } + return nil + }) +} + +// DeleteChatMessage deletes a message from a chat +func DeleteChatMessage(chatObjectId string, messageId string) error { + return GRPCCall(func(ctx context.Context, client service.ClientCommandsClient) error { + req := &pb.RpcChatDeleteMessageRequest{ + ChatObjectId: chatObjectId, + MessageId: messageId, + } + resp, err := client.ChatDeleteMessage(ctx, req) + if err != nil { + return fmt.Errorf("failed to delete message: %w", err) + } + if resp.Error != nil && resp.Error.Code != pb.RpcChatDeleteMessageResponseError_NULL { + return fmt.Errorf("delete message error: %s", resp.Error.Description) + } + return nil + }) +} + +// ToggleChatReaction adds or removes a reaction from a message +func ToggleChatReaction(chatObjectId string, messageId string, emoji string) (bool, error) { + var added bool + err := GRPCCall(func(ctx context.Context, client service.ClientCommandsClient) error { + req := &pb.RpcChatToggleMessageReactionRequest{ + ChatObjectId: chatObjectId, + MessageId: messageId, + Emoji: emoji, + } + resp, err := client.ChatToggleMessageReaction(ctx, req) + if err != nil { + return fmt.Errorf("failed to toggle reaction: %w", err) + } + if resp.Error != nil && resp.Error.Code != pb.RpcChatToggleMessageReactionResponseError_NULL { + return fmt.Errorf("toggle reaction error: %s", resp.Error.Description) + } + added = resp.Added + return nil + }) + return added, err +} + +// MarkChatMessagesRead marks messages as read up to a certain point +func MarkChatMessagesRead(chatObjectId string, afterOrderId string, beforeOrderId string) error { + return GRPCCall(func(ctx context.Context, client service.ClientCommandsClient) error { + req := &pb.RpcChatReadMessagesRequest{ + ChatObjectId: chatObjectId, + AfterOrderId: afterOrderId, + BeforeOrderId: beforeOrderId, + } + resp, err := client.ChatReadMessages(ctx, req) + if err != nil { + return fmt.Errorf("failed to mark messages read: %w", err) + } + if resp.Error != nil && resp.Error.Code != pb.RpcChatReadMessagesResponseError_NULL { + return fmt.Errorf("mark read error: %s", resp.Error.Description) + } + return nil + }) +} diff --git a/core/chat_test.go b/core/chat_test.go new file mode 100644 index 0000000..8a9570b --- /dev/null +++ b/core/chat_test.go @@ -0,0 +1,132 @@ +package core + +import ( + "testing" + "time" + + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/stretchr/testify/assert" +) + +func TestParseChatMessage(t *testing.T) { + tests := []struct { + name string + input *model.ChatMessage + expected ChatMessage + }{ + { + name: "basic message", + input: &model.ChatMessage{ + Id: "msg-123", + OrderId: "order-456", + Creator: "user-789", + CreatedAt: 1704067200, // 2024-01-01 00:00:00 UTC + Message: &model.ChatMessageMessageContent{ + Text: "Hello, world!", + }, + Read: true, + }, + expected: ChatMessage{ + ID: "msg-123", + OrderID: "order-456", + Creator: "user-789", + Text: "Hello, world!", + CreatedAt: time.Unix(1704067200, 0), + Read: true, + Reactions: nil, + }, + }, + { + name: "message with reply", + input: &model.ChatMessage{ + Id: "msg-456", + OrderId: "order-789", + Creator: "user-abc", + CreatedAt: 1704153600, + ReplyToMessageId: "msg-123", + Message: &model.ChatMessageMessageContent{ + Text: "This is a reply", + }, + }, + expected: ChatMessage{ + ID: "msg-456", + OrderID: "order-789", + Creator: "user-abc", + Text: "This is a reply", + CreatedAt: time.Unix(1704153600, 0), + ReplyTo: "msg-123", + Reactions: nil, + }, + }, + { + name: "message with reactions", + input: &model.ChatMessage{ + Id: "msg-789", + OrderId: "order-abc", + Creator: "user-def", + CreatedAt: 1704240000, + Message: &model.ChatMessageMessageContent{ + Text: "React to me!", + }, + Reactions: &model.ChatMessageReactions{ + Reactions: map[string]*model.ChatMessageReactionsIdentityList{ + "πŸ‘": {Ids: []string{"user-1", "user-2"}}, + "❀️": {Ids: []string{"user-3"}}, + }, + }, + }, + expected: ChatMessage{ + ID: "msg-789", + OrderID: "order-abc", + Creator: "user-def", + Text: "React to me!", + CreatedAt: time.Unix(1704240000, 0), + Reactions: map[string][]string{ + "πŸ‘": {"user-1", "user-2"}, + "❀️": {"user-3"}, + }, + }, + }, + { + name: "message with nil content", + input: &model.ChatMessage{ + Id: "msg-nil", + OrderId: "order-nil", + Creator: "user-nil", + CreatedAt: 1704326400, + Message: nil, + }, + expected: ChatMessage{ + ID: "msg-nil", + OrderID: "order-nil", + Creator: "user-nil", + Text: "", + CreatedAt: time.Unix(1704326400, 0), + Reactions: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseChatMessage(tt.input) + + assert.Equal(t, tt.expected.ID, result.ID) + assert.Equal(t, tt.expected.OrderID, result.OrderID) + assert.Equal(t, tt.expected.Creator, result.Creator) + assert.Equal(t, tt.expected.Text, result.Text) + assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt) + assert.Equal(t, tt.expected.ReplyTo, result.ReplyTo) + assert.Equal(t, tt.expected.Read, result.Read) + + if tt.expected.Reactions == nil { + assert.Nil(t, result.Reactions) + } else { + assert.Equal(t, len(tt.expected.Reactions), len(result.Reactions)) + for emoji, users := range tt.expected.Reactions { + assert.ElementsMatch(t, users, result.Reactions[emoji]) + } + } + }) + } +} diff --git a/core/client.go b/core/client.go index b8814aa..7f0ffab 100644 --- a/core/client.go +++ b/core/client.go @@ -18,7 +18,7 @@ import ( "github.com/anyproto/anytype-cli/core/config" ) -const defaultTimeout = 5 * time.Second +const defaultTimeout = 30 * time.Second var ( clientInstance service.ClientCommandsClient