From 5a9835ab477f80e78769ab6595e2f159d7ea800d Mon Sep 17 00:00:00 2001 From: Simon Frydensbjerg Sinding <5576291+TheSinding@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:43:41 +0200 Subject: [PATCH 1/2] feat: expose teamsctl as a public Go SDK Move the Teams client out of internal/ into pkg/ (teamsauth + teamsctl) so other Go projects can import it, and export the service API: ResolveConversationTarget, ResolveIndividualTargets, FindOneOnOneConversation, FindGroupConversation, Me/Identity, and the Message/SendOptions types. CLI and MCP wiring stay internal and consume the pkg packages. Backfill the message author for the signed-in user: Teams leaves imDisplayName empty on your own messages, so Messages now substitutes the account's display name when the sender MRI matches (directly, or derived from objectId), and exposes sender_id. --- internal/teamsctl/cli.go | 15 +- internal/teamsctl/mcp.go | 184 ++---------------- {internal => pkg}/teamsauth/autofill.go | 0 {internal => pkg}/teamsauth/browser.go | 0 {internal => pkg}/teamsauth/browser_test.go | 0 {internal => pkg}/teamsauth/command.go | 0 {internal => pkg}/teamsauth/models.go | 0 {internal => pkg}/teamsauth/oauth.go | 0 {internal => pkg}/teamsauth/oauth_test.go | 0 {internal => pkg}/teamsauth/tokens.go | 0 {internal => pkg}/teamsauth/tokens_test.go | 0 {internal => pkg}/teamsctl/conversations.go | 0 pkg/teamsctl/conversations_test.go | 15 ++ pkg/teamsctl/identity.go | 34 ++++ {internal => pkg}/teamsctl/messages.go | 33 +++- pkg/teamsctl/messages_test.go | 61 ++++++ {internal => pkg}/teamsctl/models.go | 1 + pkg/teamsctl/resolve.go | 174 +++++++++++++++++ .../teamsctl/resolve_test.go | 22 --- {internal => pkg}/teamsctl/send.go | 0 {internal => pkg}/teamsctl/send_test.go | 0 {internal => pkg}/teamsctl/service.go | 18 +- {internal => pkg}/teamsctl/service_test.go | 0 23 files changed, 358 insertions(+), 199 deletions(-) rename {internal => pkg}/teamsauth/autofill.go (100%) rename {internal => pkg}/teamsauth/browser.go (100%) rename {internal => pkg}/teamsauth/browser_test.go (100%) rename {internal => pkg}/teamsauth/command.go (100%) rename {internal => pkg}/teamsauth/models.go (100%) rename {internal => pkg}/teamsauth/oauth.go (100%) rename {internal => pkg}/teamsauth/oauth_test.go (100%) rename {internal => pkg}/teamsauth/tokens.go (100%) rename {internal => pkg}/teamsauth/tokens_test.go (100%) rename {internal => pkg}/teamsctl/conversations.go (100%) create mode 100644 pkg/teamsctl/conversations_test.go create mode 100644 pkg/teamsctl/identity.go rename {internal => pkg}/teamsctl/messages.go (53%) create mode 100644 pkg/teamsctl/messages_test.go rename {internal => pkg}/teamsctl/models.go (96%) create mode 100644 pkg/teamsctl/resolve.go rename internal/teamsctl/conversations_test.go => pkg/teamsctl/resolve_test.go (69%) rename {internal => pkg}/teamsctl/send.go (100%) rename {internal => pkg}/teamsctl/send_test.go (100%) rename {internal => pkg}/teamsctl/service.go (61%) rename {internal => pkg}/teamsctl/service_test.go (100%) diff --git a/internal/teamsctl/cli.go b/internal/teamsctl/cli.go index 7db71c8..fea8264 100644 --- a/internal/teamsctl/cli.go +++ b/internal/teamsctl/cli.go @@ -7,8 +7,9 @@ import ( "io" "strings" - "thesinding/teamsctl/internal/teamsauth" "thesinding/teamsctl/internal/version" + "thesinding/teamsctl/pkg/teamsauth" + tctl "thesinding/teamsctl/pkg/teamsctl" ) type stringFlags []string @@ -73,7 +74,7 @@ func runConversations(args []string, stdout io.Writer) error { if flags.NArg() != 0 { return fmt.Errorf("conversations takes no arguments") } - service, err := NewService() + service, err := tctl.NewService() if err != nil { return err } @@ -98,11 +99,11 @@ func runMessages(args []string, stdout io.Writer) error { if *limit < 0 { return fmt.Errorf("limit must be at least 0") } - service, err := NewService() + service, err := tctl.NewService() if err != nil { return err } - messages, err := service.Messages(splitIDs(flags.Arg(0)), *name, *limit) + messages, err := service.Messages(strings.Split(flags.Arg(0), ","), *name, *limit) if err != nil { return err } @@ -125,12 +126,12 @@ func runSend(args []string, stdin io.Reader, stdout io.Writer) error { if err != nil { return err } - service, err := NewService() + service, err := tctl.NewService() if err != nil { return err } - ids := splitIDs(flags.Arg(0)) - if err = service.Send(ids, content, SendOptions{Format: *format, Mentions: mentions}); err != nil { + ids := strings.Split(flags.Arg(0), ",") + if err = service.Send(ids, content, tctl.SendOptions{Format: *format, Mentions: mentions}); err != nil { return err } return writeJSON(stdout, map[string]interface{}{"sent": true, "conversation_ids": ids}) diff --git a/internal/teamsctl/mcp.go b/internal/teamsctl/mcp.go index 8cf0b3e..d683533 100644 --- a/internal/teamsctl/mcp.go +++ b/internal/teamsctl/mcp.go @@ -9,8 +9,9 @@ import ( "sync" "github.com/modelcontextprotocol/go-sdk/mcp" - "thesinding/teamsctl/internal/teamsauth" "thesinding/teamsctl/internal/version" + "thesinding/teamsctl/pkg/teamsauth" + tctl "thesinding/teamsctl/pkg/teamsctl" ) var checkMCPAuth = teamsauth.CheckTokens @@ -32,17 +33,17 @@ type messagesInput struct { } type sendMessageInput struct { - Recipient string `json:"recipient,omitempty" jsonschema:"Recipient phrase from the user, such as Mike, Mike and Charlie, ASM group chat, or ASM channel."` - ConversationID string `json:"conversation_id,omitempty" jsonschema:"Deprecated: use recipient. A Teams conversation ID remains accepted."` - Message string `json:"message" jsonschema:"Message content."` - Format string `json:"format,omitempty" jsonschema:"Message format: text or html. Use html for structured or formatted messages."` - Mentions []string `json:"mentions,omitempty" jsonschema:"People to mention. Each must match an @Name token in message."` - MentionEntities []MentionEntity `json:"mention_entities,omitempty" jsonschema:"Pre-resolved Teams mentions. Prefer mentions for automatic resolution."` + Recipient string `json:"recipient,omitempty" jsonschema:"Recipient phrase from the user, such as Mike, Mike and Charlie, ASM group chat, or ASM channel."` + ConversationID string `json:"conversation_id,omitempty" jsonschema:"Deprecated: use recipient. A Teams conversation ID remains accepted."` + Message string `json:"message" jsonschema:"Message content."` + Format string `json:"format,omitempty" jsonschema:"Message format: text or html. Use html for structured or formatted messages."` + Mentions []string `json:"mentions,omitempty" jsonschema:"People to mention. Each must match an @Name token in message."` + MentionEntities []tctl.MentionEntity `json:"mention_entities,omitempty" jsonschema:"Pre-resolved Teams mentions. Prefer mentions for automatic resolution."` } type mcpApplication struct { serviceMu sync.Mutex - service *Service + service *tctl.Service } func RunMCP(stdin io.Reader, stdout io.Writer) error { @@ -78,14 +79,14 @@ func newMCPServer() *mcp.Server { return server } -func (app *mcpApplication) serviceForTool() (*Service, error) { +func (app *mcpApplication) serviceForTool() (*tctl.Service, error) { if err := checkMCPAuth(); err != nil { return nil, err } app.serviceMu.Lock() defer app.serviceMu.Unlock() if app.service == nil { - service, err := NewService() + service, err := tctl.NewService() if err != nil { return nil, err } @@ -111,7 +112,7 @@ func (app *mcpApplication) latestMessage(_ context.Context, _ *mcp.CallToolReque if err != nil { return nil, nil, err } - conversation, err := service.findOneOnOneConversation(input.Query) + conversation, err := service.FindOneOnOneConversation(input.Query) if err != nil { return nil, nil, err } @@ -131,7 +132,7 @@ func (app *mcpApplication) messages(_ context.Context, _ *mcp.CallToolRequest, i if err != nil { return nil, nil, err } - target, err := service.resolveConversationTarget(firstNonEmpty(input.Recipient, input.ConversationID)) + target, err := service.ResolveConversationTarget(firstNonEmpty(input.Recipient, input.ConversationID)) if err != nil { return nil, nil, err } @@ -144,19 +145,19 @@ func (app *mcpApplication) sendMessage(_ context.Context, _ *mcp.CallToolRequest if err != nil { return nil, nil, err } - target, err := service.resolveConversationTarget(firstNonEmpty(input.Recipient, input.ConversationID)) + target, err := service.ResolveConversationTarget(firstNonEmpty(input.Recipient, input.ConversationID)) if err != nil { - var missingGroup *missingGroupChatError + var missingGroup *tctl.MissingGroupChatError if !errors.As(err, &missingGroup) { return nil, nil, err } - target, err = service.resolveIndividualTargets(missingGroup.Recipients) + target, err = service.ResolveIndividualTargets(missingGroup.Recipients) if err != nil { return nil, nil, err } target.FallbackToOneOnOne = true } - options := SendOptions{Format: input.Format, Mentions: input.Mentions, MentionEntities: input.MentionEntities} + options := tctl.SendOptions{Format: input.Format, Mentions: input.Mentions, MentionEntities: input.MentionEntities} if target.FallbackToOneOnOne { for _, ids := range target.IndividualIDs { if err := service.Send(ids, input.Message, options); err != nil { @@ -169,136 +170,6 @@ func (app *mcpApplication) sendMessage(_ context.Context, _ *mcp.CallToolRequest return nil, map[string]any{"sent": true, "sent_to": target.Recipients, "fallback_to_one_on_one": target.FallbackToOneOnOne}, nil } -type conversationTarget struct { - IDs []string - IndividualIDs [][]string - Name string - Recipients []string - FallbackToOneOnOne bool -} - -type missingGroupChatError struct{ Recipients []string } - -func (e *missingGroupChatError) Error() string { - return fmt.Sprintf("no group chat found for %s", strings.Join(e.Recipients, " and ")) -} - -func (s *Service) resolveConversationTarget(target string) (conversationTarget, error) { - target = strings.TrimSpace(target) - if target == "" { - return conversationTarget{}, fmt.Errorf("recipient is required") - } - if looksLikeConversationID(target) { - return conversationTarget{IDs: splitIDs(target), Recipients: []string{target}}, nil - } - if recipients := splitRecipientNames(target); len(recipients) > 1 { - conversation, err := s.findGroupConversation(recipients) - if err != nil { - return conversationTarget{}, err - } - if len(conversation.IDs) == 0 { - return conversationTarget{}, &missingGroupChatError{Recipients: recipients} - } - return conversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil - } - if query, kind := namedConversationQuery(target); kind != "" { - conversation, err := s.findNamedConversation(query, kind) - if err != nil { - return conversationTarget{}, err - } - return conversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil - } - conversation, err := s.findOneOnOneConversation(target) - if err != nil { - return conversationTarget{}, err - } - return conversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil -} - -func (s *Service) resolveIndividualTargets(recipients []string) (conversationTarget, error) { - individualIDs := make([][]string, 0, len(recipients)) - resolved := make([]string, 0, len(recipients)) - for _, recipient := range recipients { - conversation, err := s.findOneOnOneConversation(recipient) - if err != nil { - return conversationTarget{}, err - } - individualIDs = append(individualIDs, conversation.IDs) - resolved = append(resolved, conversation.Title) - } - return conversationTarget{IndividualIDs: individualIDs, Recipients: resolved}, nil -} - -func (s *Service) findOneOnOneConversation(query string) (Conversation, error) { - matches, err := s.FindConversations(query, "chat", 0) - if err != nil { - return Conversation{}, err - } - for _, conversation := range matches { - if conversation.OneOnOne { - return conversation, nil - } - } - return Conversation{}, fmt.Errorf("no one-to-one chat found matching %q", query) -} - -func (s *Service) findGroupConversation(recipients []string) (Conversation, error) { - conversations, err := s.Conversations() - if err != nil { - return Conversation{}, err - } - if conversation, ok := matchingGroupConversation(conversations, recipients); ok { - return conversation, nil - } - return Conversation{}, nil -} - -func (s *Service) findNamedConversation(query, kind string) (Conversation, error) { - conversations, err := s.FindConversations(query, kind, 0) - if err != nil { - return Conversation{}, err - } - for _, conversation := range conversations { - if kind != "chat" || !conversation.OneOnOne { - return conversation, nil - } - } - return Conversation{}, fmt.Errorf("no %s found matching %q", kind, query) -} - -func matchingGroupConversation(conversations []Conversation, recipients []string) (Conversation, bool) { - for _, conversation := range conversations { - if conversation.Kind != "chat" || conversation.OneOnOne { - continue - } - title := strings.ToLower(conversation.Title) - matched := true - for _, recipient := range recipients { - if !strings.Contains(title, strings.ToLower(recipient)) { - matched = false - break - } - } - if matched { - return conversation, true - } - } - return Conversation{}, false -} - -func looksLikeConversationID(target string) bool { - ids := splitIDs(target) - if len(ids) == 0 { - return false - } - for _, id := range ids { - if !strings.HasPrefix(id, "19:") && !strings.HasPrefix(id, "48:") { - return false - } - } - return true -} - func limitOrDefault(limit *int) int { if limit == nil { return 50 @@ -306,27 +177,6 @@ func limitOrDefault(limit *int) int { return *limit } -func splitRecipientNames(target string) []string { - parts := strings.Split(strings.TrimSpace(target), " and ") - if len(parts) < 2 { - return nil - } - return normalizeIDs(parts) -} - -func namedConversationQuery(target string) (string, string) { - lower := strings.ToLower(strings.TrimSpace(target)) - for _, suffix := range []struct { - value string - kind string - }{{" group chat", "chat"}, {" chat", "chat"}, {" channel", "channel"}} { - if strings.HasSuffix(lower, suffix.value) { - return strings.TrimSpace(target[:len(target)-len(suffix.value)]), suffix.kind - } - } - return "", "" -} - func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/internal/teamsauth/autofill.go b/pkg/teamsauth/autofill.go similarity index 100% rename from internal/teamsauth/autofill.go rename to pkg/teamsauth/autofill.go diff --git a/internal/teamsauth/browser.go b/pkg/teamsauth/browser.go similarity index 100% rename from internal/teamsauth/browser.go rename to pkg/teamsauth/browser.go diff --git a/internal/teamsauth/browser_test.go b/pkg/teamsauth/browser_test.go similarity index 100% rename from internal/teamsauth/browser_test.go rename to pkg/teamsauth/browser_test.go diff --git a/internal/teamsauth/command.go b/pkg/teamsauth/command.go similarity index 100% rename from internal/teamsauth/command.go rename to pkg/teamsauth/command.go diff --git a/internal/teamsauth/models.go b/pkg/teamsauth/models.go similarity index 100% rename from internal/teamsauth/models.go rename to pkg/teamsauth/models.go diff --git a/internal/teamsauth/oauth.go b/pkg/teamsauth/oauth.go similarity index 100% rename from internal/teamsauth/oauth.go rename to pkg/teamsauth/oauth.go diff --git a/internal/teamsauth/oauth_test.go b/pkg/teamsauth/oauth_test.go similarity index 100% rename from internal/teamsauth/oauth_test.go rename to pkg/teamsauth/oauth_test.go diff --git a/internal/teamsauth/tokens.go b/pkg/teamsauth/tokens.go similarity index 100% rename from internal/teamsauth/tokens.go rename to pkg/teamsauth/tokens.go diff --git a/internal/teamsauth/tokens_test.go b/pkg/teamsauth/tokens_test.go similarity index 100% rename from internal/teamsauth/tokens_test.go rename to pkg/teamsauth/tokens_test.go diff --git a/internal/teamsctl/conversations.go b/pkg/teamsctl/conversations.go similarity index 100% rename from internal/teamsctl/conversations.go rename to pkg/teamsctl/conversations.go diff --git a/pkg/teamsctl/conversations_test.go b/pkg/teamsctl/conversations_test.go new file mode 100644 index 0000000..9dd37f7 --- /dev/null +++ b/pkg/teamsctl/conversations_test.go @@ -0,0 +1,15 @@ +package teamsctl + +import "testing" + +func TestFilterConversationsPrefersOneOnOne(t *testing.T) { + records := []Conversation{ + {Kind: "chat", Title: "Mikkel Ljungberg, Rasmus Prip"}, + {Kind: "chat", Title: "Mikkel Ljungberg", OneOnOne: true}, + {Kind: "channel", Title: "Mikkel planning"}, + } + matches := filterConversations(records, "mikkel", "chat", 1) + if len(matches) != 1 || matches[0].Title != "Mikkel Ljungberg" { + t.Fatalf("filterConversations() = %#v", matches) + } +} diff --git a/pkg/teamsctl/identity.go b/pkg/teamsctl/identity.go new file mode 100644 index 0000000..dc9640b --- /dev/null +++ b/pkg/teamsctl/identity.go @@ -0,0 +1,34 @@ +package teamsctl + +import "fmt" + +// Identity describes the signed-in Teams account, for callers that need to +// tell the current user's own messages apart from a conversation partner's +// (e.g. when polling for a reply after Send). +type Identity struct { + DisplayName string + Email string + UserPrincipalName string + ObjectID string + Mri string +} + +// Me returns the signed-in account's identity, fetching and caching +// conversation state if it has not been loaded yet. +func (s *Service) Me() (Identity, error) { + if _, err := s.conversationRecords(false); err != nil { + return Identity{}, fmt.Errorf("get current user: %w", err) + } + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + if s.me == nil { + return Identity{}, fmt.Errorf("current user unavailable") + } + return Identity{ + DisplayName: s.me.DisplayName, + Email: s.me.Email, + UserPrincipalName: s.me.UserPrincipalName, + ObjectID: s.me.ObjectId, + Mri: s.me.Mri, + }, nil +} diff --git a/internal/teamsctl/messages.go b/pkg/teamsctl/messages.go similarity index 53% rename from internal/teamsctl/messages.go rename to pkg/teamsctl/messages.go index 1b667cf..6ca53c2 100644 --- a/internal/teamsctl/messages.go +++ b/pkg/teamsctl/messages.go @@ -3,9 +3,11 @@ package teamsctl import ( "fmt" "sort" + "strings" "time" "github.com/fossteams/teams-api/pkg/csa" + "github.com/fossteams/teams-api/pkg/models" ) func (s *Service) Messages(ids []string, displayName string, limit int) ([]Message, error) { @@ -28,12 +30,21 @@ func (s *Service) Messages(ids []string, displayName string, limit int) ([]Messa if limit > 0 && len(messages) > limit { messages = messages[len(messages)-limit:] } + return messageRecords(messages, s.currentUser()), nil +} + +func messageRecords(messages []csa.ChatMessage, me *models.User) []Message { records := make([]Message, 0, len(messages)) for _, message := range messages { + author := message.ImDisplayName + if strings.TrimSpace(author) == "" && isSelfSender(message.From, me) { + author = me.DisplayName + } records = append(records, Message{ ID: message.Id, ConversationID: message.ConversationId, - Author: message.ImDisplayName, + Author: author, + SenderID: message.From, Content: message.Content, ContentType: message.ContentType, MessageType: message.MessageType, @@ -41,5 +52,23 @@ func (s *Service) Messages(ids []string, displayName string, limit int) ([]Messa Mentions: message.Properties.Mentions, }) } - return records, nil + return records +} + +// isSelfSender reports whether from is the signed-in account's MRI. Teams +// leaves ImDisplayName empty on your own messages, so callers use this to +// backfill the author. The MRI is matched directly, or derived from ObjectId +// when the profile did not return one. +func isSelfSender(from string, me *models.User) bool { + if me == nil { + return false + } + from = strings.TrimSpace(from) + if from == "" { + return false + } + if me.Mri != "" && from == strings.TrimSpace(me.Mri) { + return true + } + return me.ObjectId != "" && from == "8:orgid:"+strings.TrimSpace(me.ObjectId) } diff --git a/pkg/teamsctl/messages_test.go b/pkg/teamsctl/messages_test.go new file mode 100644 index 0000000..85c742e --- /dev/null +++ b/pkg/teamsctl/messages_test.go @@ -0,0 +1,61 @@ +package teamsctl + +import ( + "testing" + + "github.com/fossteams/teams-api/pkg/csa" + "github.com/fossteams/teams-api/pkg/models" +) + +func TestIsSelfSender(t *testing.T) { + me := &models.User{Mri: "8:orgid:me", ObjectId: "me-id"} + if !isSelfSender("8:orgid:me", me) { + t.Fatal("direct MRI match should be self") + } + if isSelfSender("8:orgid:someone-else", me) { + t.Fatal("different MRI should not be self") + } + if isSelfSender("", me) { + t.Fatal("empty from should not be self") + } + if isSelfSender("8:orgid:me", nil) { + t.Fatal("nil me should not be self") + } +} + +func TestIsSelfSenderDerivesMRIFromObjectID(t *testing.T) { + me := &models.User{ObjectId: "me-id"} + if !isSelfSender("8:orgid:me-id", me) { + t.Fatal("ObjectId-derived MRI should be self") + } +} + +func TestMessageRecordsBackfillsSelfAuthor(t *testing.T) { + me := &models.User{DisplayName: "Simon Sinding", Mri: "8:orgid:me"} + messages := []csa.ChatMessage{ + {Id: "1", From: "8:orgid:me", ImDisplayName: ""}, + {Id: "2", From: "8:orgid:other", ImDisplayName: "Niklas Johansson"}, + {Id: "3", From: "8:orgid:other", ImDisplayName: ""}, + } + records := messageRecords(messages, me) + if records[0].Author != "Simon Sinding" { + t.Fatalf("self author not backfilled: %q", records[0].Author) + } + if records[1].Author != "Niklas Johansson" { + t.Fatalf("other author changed: %q", records[1].Author) + } + if records[2].Author != "" { + t.Fatalf("non-self empty author should stay empty: %q", records[2].Author) + } + if records[0].SenderID != "8:orgid:me" { + t.Fatalf("sender id not set: %q", records[0].SenderID) + } +} + +func TestMessageRecordsNilIdentityLeavesAuthorUntouched(t *testing.T) { + messages := []csa.ChatMessage{{Id: "1", From: "8:orgid:me", ImDisplayName: ""}} + records := messageRecords(messages, nil) + if records[0].Author != "" { + t.Fatalf("author should stay empty without identity: %q", records[0].Author) + } +} diff --git a/internal/teamsctl/models.go b/pkg/teamsctl/models.go similarity index 96% rename from internal/teamsctl/models.go rename to pkg/teamsctl/models.go index ec62a23..144da79 100644 --- a/internal/teamsctl/models.go +++ b/pkg/teamsctl/models.go @@ -17,6 +17,7 @@ type Message struct { ID string `json:"id"` ConversationID string `json:"conversation_id"` Author string `json:"author"` + SenderID string `json:"sender_id"` Content string `json:"content"` ContentType string `json:"content_type"` MessageType string `json:"message_type"` diff --git a/pkg/teamsctl/resolve.go b/pkg/teamsctl/resolve.go new file mode 100644 index 0000000..fa6d1e8 --- /dev/null +++ b/pkg/teamsctl/resolve.go @@ -0,0 +1,174 @@ +package teamsctl + +import ( + "fmt" + "strings" +) + +// ConversationTarget is a resolved destination for Send or Messages, produced +// by ResolveConversationTarget from a free-form recipient phrase. +type ConversationTarget struct { + IDs []string + IndividualIDs [][]string + Name string + Recipients []string + FallbackToOneOnOne bool +} + +// MissingGroupChatError indicates a multi-person recipient phrase (e.g. "Mike +// and Charlie") did not match an existing group chat. Callers may recover by +// calling ResolveIndividualTargets with Recipients and messaging each person +// individually. +type MissingGroupChatError struct{ Recipients []string } + +func (e *MissingGroupChatError) Error() string { + return fmt.Sprintf("no group chat found for %s", strings.Join(e.Recipients, " and ")) +} + +// ResolveConversationTarget turns a recipient phrase (a Teams conversation +// ID, a person's name, "Name and Name" for a group chat, "X group chat", or +// "X channel") into a ConversationTarget. If target names a group chat that +// does not exist, it returns a *MissingGroupChatError. +func (s *Service) ResolveConversationTarget(target string) (ConversationTarget, error) { + target = strings.TrimSpace(target) + if target == "" { + return ConversationTarget{}, fmt.Errorf("recipient is required") + } + if looksLikeConversationID(target) { + return ConversationTarget{IDs: splitIDs(target), Recipients: []string{target}}, nil + } + if recipients := splitRecipientNames(target); len(recipients) > 1 { + conversation, err := s.FindGroupConversation(recipients) + if err != nil { + return ConversationTarget{}, err + } + if len(conversation.IDs) == 0 { + return ConversationTarget{}, &MissingGroupChatError{Recipients: recipients} + } + return ConversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil + } + if query, kind := namedConversationQuery(target); kind != "" { + conversation, err := s.findNamedConversation(query, kind) + if err != nil { + return ConversationTarget{}, err + } + return ConversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil + } + conversation, err := s.FindOneOnOneConversation(target) + if err != nil { + return ConversationTarget{}, err + } + return ConversationTarget{IDs: conversation.IDs, Name: conversation.Title, Recipients: []string{conversation.Title}}, nil +} + +// ResolveIndividualTargets resolves each recipient to their own one-on-one +// conversation, for use as a fallback when a requested group chat does not +// exist. +func (s *Service) ResolveIndividualTargets(recipients []string) (ConversationTarget, error) { + individualIDs := make([][]string, 0, len(recipients)) + resolved := make([]string, 0, len(recipients)) + for _, recipient := range recipients { + conversation, err := s.FindOneOnOneConversation(recipient) + if err != nil { + return ConversationTarget{}, err + } + individualIDs = append(individualIDs, conversation.IDs) + resolved = append(resolved, conversation.Title) + } + return ConversationTarget{IndividualIDs: individualIDs, Recipients: resolved}, nil +} + +// FindOneOnOneConversation finds a one-to-one chat by person name or title. +func (s *Service) FindOneOnOneConversation(query string) (Conversation, error) { + matches, err := s.FindConversations(query, "chat", 0) + if err != nil { + return Conversation{}, err + } + for _, conversation := range matches { + if conversation.OneOnOne { + return conversation, nil + } + } + return Conversation{}, fmt.Errorf("no one-to-one chat found matching %q", query) +} + +// FindGroupConversation finds a group chat whose title contains every given +// recipient name. It returns a zero-value Conversation (no error) if none is +// found, so callers can fall back to individual one-on-one messages. +func (s *Service) FindGroupConversation(recipients []string) (Conversation, error) { + conversations, err := s.Conversations() + if err != nil { + return Conversation{}, err + } + if conversation, ok := matchingGroupConversation(conversations, recipients); ok { + return conversation, nil + } + return Conversation{}, nil +} + +func (s *Service) findNamedConversation(query, kind string) (Conversation, error) { + conversations, err := s.FindConversations(query, kind, 0) + if err != nil { + return Conversation{}, err + } + for _, conversation := range conversations { + if kind != "chat" || !conversation.OneOnOne { + return conversation, nil + } + } + return Conversation{}, fmt.Errorf("no %s found matching %q", kind, query) +} + +func matchingGroupConversation(conversations []Conversation, recipients []string) (Conversation, bool) { + for _, conversation := range conversations { + if conversation.Kind != "chat" || conversation.OneOnOne { + continue + } + title := strings.ToLower(conversation.Title) + matched := true + for _, recipient := range recipients { + if !strings.Contains(title, strings.ToLower(recipient)) { + matched = false + break + } + } + if matched { + return conversation, true + } + } + return Conversation{}, false +} + +func looksLikeConversationID(target string) bool { + ids := splitIDs(target) + if len(ids) == 0 { + return false + } + for _, id := range ids { + if !strings.HasPrefix(id, "19:") && !strings.HasPrefix(id, "48:") { + return false + } + } + return true +} + +func splitRecipientNames(target string) []string { + parts := strings.Split(strings.TrimSpace(target), " and ") + if len(parts) < 2 { + return nil + } + return normalizeIDs(parts) +} + +func namedConversationQuery(target string) (string, string) { + lower := strings.ToLower(strings.TrimSpace(target)) + for _, suffix := range []struct { + value string + kind string + }{{" group chat", "chat"}, {" chat", "chat"}, {" channel", "channel"}} { + if strings.HasSuffix(lower, suffix.value) { + return strings.TrimSpace(target[:len(target)-len(suffix.value)]), suffix.kind + } + } + return "", "" +} diff --git a/internal/teamsctl/conversations_test.go b/pkg/teamsctl/resolve_test.go similarity index 69% rename from internal/teamsctl/conversations_test.go rename to pkg/teamsctl/resolve_test.go index 042a0a6..18063c2 100644 --- a/internal/teamsctl/conversations_test.go +++ b/pkg/teamsctl/resolve_test.go @@ -2,18 +2,6 @@ package teamsctl import "testing" -func TestFilterConversationsPrefersOneOnOne(t *testing.T) { - records := []Conversation{ - {Kind: "chat", Title: "Mikkel Ljungberg, Rasmus Prip"}, - {Kind: "chat", Title: "Mikkel Ljungberg", OneOnOne: true}, - {Kind: "channel", Title: "Mikkel planning"}, - } - matches := filterConversations(records, "mikkel", "chat", 1) - if len(matches) != 1 || matches[0].Title != "Mikkel Ljungberg" { - t.Fatalf("filterConversations() = %#v", matches) - } -} - func TestConversationTargetTreatsNamesAndIDsDifferently(t *testing.T) { if looksLikeConversationID("Mikkel") { t.Fatal("name was treated as an ID") @@ -26,16 +14,6 @@ func TestConversationTargetTreatsNamesAndIDsDifferently(t *testing.T) { } } -func TestLimitOrDefault(t *testing.T) { - if got := limitOrDefault(nil); got != 50 { - t.Fatalf("limitOrDefault(nil) = %d", got) - } - all := 0 - if got := limitOrDefault(&all); got != 0 { - t.Fatalf("limitOrDefault(0) = %d", got) - } -} - func TestRecipientIntent(t *testing.T) { if got := splitRecipientNames("Mike and Charlie"); len(got) != 2 || got[0] != "Mike" || got[1] != "Charlie" { t.Fatalf("splitRecipientNames() = %#v", got) diff --git a/internal/teamsctl/send.go b/pkg/teamsctl/send.go similarity index 100% rename from internal/teamsctl/send.go rename to pkg/teamsctl/send.go diff --git a/internal/teamsctl/send_test.go b/pkg/teamsctl/send_test.go similarity index 100% rename from internal/teamsctl/send_test.go rename to pkg/teamsctl/send_test.go diff --git a/internal/teamsctl/service.go b/pkg/teamsctl/service.go similarity index 61% rename from internal/teamsctl/service.go rename to pkg/teamsctl/service.go index 8a1d515..1b9fc85 100644 --- a/internal/teamsctl/service.go +++ b/pkg/teamsctl/service.go @@ -8,7 +8,7 @@ import ( teamsapi "github.com/fossteams/teams-api" "github.com/fossteams/teams-api/pkg/csa" "github.com/fossteams/teams-api/pkg/models" - "thesinding/teamsctl/internal/teamsauth" + "thesinding/teamsctl/pkg/teamsauth" ) var newTeamsAPIClient = teamsapi.NewWithTokens @@ -33,3 +33,19 @@ func NewService() (*Service, error) { } return &Service{client: client}, nil } + +// currentUser returns the signed-in account, fetching it on first use and +// caching it alongside the conversation state. It returns nil when the +// identity cannot be fetched, so callers may treat it as best-effort. +func (s *Service) currentUser() *models.User { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + if s.me == nil { + me, err := s.client.GetMe() + if err != nil { + return nil + } + s.me = me + } + return s.me +} diff --git a/internal/teamsctl/service_test.go b/pkg/teamsctl/service_test.go similarity index 100% rename from internal/teamsctl/service_test.go rename to pkg/teamsctl/service_test.go From 006cda028da0728acb7540981cc06a745b98c9e0 Mon Sep 17 00:00:00 2001 From: Simon Frydensbjerg Sinding <5576291+TheSinding@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:05:36 +0200 Subject: [PATCH 2/2] fix: report normalized conversation IDs in CLI send Export SplitIDs from pkg/teamsctl and use it in the CLI so the send output reports the same normalized IDs (trimmed, deduped, non-empty) that Service.Send actually messages, instead of the raw comma split. Restores the pre-refactor normalization in messages/send. --- internal/teamsctl/cli.go | 4 ++-- pkg/teamsctl/conversations.go | 4 +++- pkg/teamsctl/resolve.go | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/teamsctl/cli.go b/internal/teamsctl/cli.go index fea8264..52f96c0 100644 --- a/internal/teamsctl/cli.go +++ b/internal/teamsctl/cli.go @@ -103,7 +103,7 @@ func runMessages(args []string, stdout io.Writer) error { if err != nil { return err } - messages, err := service.Messages(strings.Split(flags.Arg(0), ","), *name, *limit) + messages, err := service.Messages(tctl.SplitIDs(flags.Arg(0)), *name, *limit) if err != nil { return err } @@ -130,7 +130,7 @@ func runSend(args []string, stdin io.Reader, stdout io.Writer) error { if err != nil { return err } - ids := strings.Split(flags.Arg(0), ",") + ids := tctl.SplitIDs(flags.Arg(0)) if err = service.Send(ids, content, tctl.SendOptions{Format: *format, Mentions: mentions}); err != nil { return err } diff --git a/pkg/teamsctl/conversations.go b/pkg/teamsctl/conversations.go index 613dfc4..d08f637 100644 --- a/pkg/teamsctl/conversations.go +++ b/pkg/teamsctl/conversations.go @@ -296,6 +296,8 @@ func normalizeIDs(ids []string) []string { return out } -func splitIDs(value string) []string { +// SplitIDs splits a comma-separated conversation ID list into normalized IDs, +// trimming whitespace and dropping empty and duplicate entries. +func SplitIDs(value string) []string { return normalizeIDs(strings.Split(value, ",")) } diff --git a/pkg/teamsctl/resolve.go b/pkg/teamsctl/resolve.go index fa6d1e8..0ec4eed 100644 --- a/pkg/teamsctl/resolve.go +++ b/pkg/teamsctl/resolve.go @@ -35,7 +35,7 @@ func (s *Service) ResolveConversationTarget(target string) (ConversationTarget, return ConversationTarget{}, fmt.Errorf("recipient is required") } if looksLikeConversationID(target) { - return ConversationTarget{IDs: splitIDs(target), Recipients: []string{target}}, nil + return ConversationTarget{IDs: SplitIDs(target), Recipients: []string{target}}, nil } if recipients := splitRecipientNames(target); len(recipients) > 1 { conversation, err := s.FindGroupConversation(recipients) @@ -140,7 +140,7 @@ func matchingGroupConversation(conversations []Conversation, recipients []string } func looksLikeConversationID(target string) bool { - ids := splitIDs(target) + ids := SplitIDs(target) if len(ids) == 0 { return false }