Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions internal/teamsctl/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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(tctl.SplitIDs(flags.Arg(0)), *name, *limit)
if err != nil {
return err
}
Expand All @@ -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 := tctl.SplitIDs(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})
Expand Down
184 changes: 17 additions & 167 deletions internal/teamsctl/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -169,164 +170,13 @@ 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
}
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) != "" {
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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, ","))
}
15 changes: 15 additions & 0 deletions pkg/teamsctl/conversations_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
34 changes: 34 additions & 0 deletions pkg/teamsctl/identity.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading