diff --git a/src/internal/m365/collection/groups/handlers.go b/src/internal/m365/collection/groups/handlers.go index f5a28fd28..bf3cb8f0f 100644 --- a/src/internal/m365/collection/groups/handlers.go +++ b/src/internal/m365/collection/groups/handlers.go @@ -31,3 +31,10 @@ type BackupHandler interface { teamID, channelID, messageID string, ) (serialization.Parsable, error) } + +type BackupMessagesHandler interface { + GetMessage(ctx context.Context, teamID, channelID, itemID string) (models.ChatMessageable, error) + NewMessagePager(teamID, channelID string) api.ChannelMessageDeltaEnumerator + GetChannel(ctx context.Context, teamID, channelID string) (models.Channelable, error) + GetReply(ctx context.Context, teamID, channelID, messageID string) (serialization.Parsable, error) +} diff --git a/src/pkg/services/m365/api/channels.go b/src/pkg/services/m365/api/channels.go index 778f64ec1..d48b59d3d 100644 --- a/src/pkg/services/m365/api/channels.go +++ b/src/pkg/services/m365/api/channels.go @@ -1 +1,194 @@ package api + +import ( + "context" + "fmt" + + "github.com/alcionai/clues" + "github.com/microsoft/kiota-abstractions-go/serialization" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/microsoftgraph/msgraph-sdk-go/teams" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/m365/graph" + "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/logger" +) + +// --------------------------------------------------------------------------- +// controller +// --------------------------------------------------------------------------- + +func (c Client) Channels() Channels { + return Channels{c} +} + +// Channels is an interface-compliant provider of the client. +type Channels struct { + Client +} + +// --------------------------------------------------------------------------- +// containers +// --------------------------------------------------------------------------- + +func (c Channels) GetChannel( + ctx context.Context, + teamID, containerID string, +) (models.Channelable, error) { + config := &teams.ItemChannelsChannelItemRequestBuilderGetRequestConfiguration{ + QueryParameters: &teams.ItemChannelsChannelItemRequestBuilderGetQueryParameters{ + Select: idAnd("displayName"), + }, + } + + resp, err := c.Stable. + Client(). + Teams(). + ByTeamId(teamID). + Channels(). + ByChannelId(containerID). + Get(ctx, config) + if err != nil { + return nil, graph.Stack(ctx, err) + } + + return resp, nil +} + +// GetChannelByName fetches a channel by name +func (c Channels) GetChannelByName( + ctx context.Context, + teamID, containerName string, +) (models.Channelable, error) { + ctx = clues.Add(ctx, "channel_name", containerName) + + filter := fmt.Sprintf("displayName eq '%s'", containerName) + options := &teams.ItemChannelsRequestBuilderGetRequestConfiguration{ + QueryParameters: &teams.ItemChannelsRequestBuilderGetQueryParameters{ + Filter: &filter, + }, + } + + resp, err := c.Stable. + Client(). + Teams(). + ByTeamId(teamID). + Channels(). + Get(ctx, options) + if err != nil { + return nil, graph.Stack(ctx, err).WithClues(ctx) + } + + gv := resp.GetValue() + if len(gv) == 0 { + return nil, clues.New("channel not found").WithClues(ctx) + } + + // We only allow the api to match one channel with the provided name. + // If we match multiples, we'll eagerly return the first one. + logger.Ctx(ctx).Debugw("channels matched the name search") + + // Sanity check ID and name + cal := gv[0] + + if err := CheckIDAndName(cal); err != nil { + return nil, clues.Stack(err).WithClues(ctx) + } + + return cal, nil +} + +// --------------------------------------------------------------------------- +// message +// --------------------------------------------------------------------------- + +// GetMessage retrieves a ChannelMessage item. +func (c Channels) GetMessage( + ctx context.Context, + teamID, channelID, itemID string, + errs *fault.Bus, +) (serialization.Parsable, *details.GroupsInfo, error) { + var size int64 + + message, err := c.Stable. + Client(). + Teams(). + ByTeamId(teamID). + Channels(). + ByChannelId(channelID). + Messages(). + ByChatMessageId(itemID). + Get(ctx, nil) + if err != nil { + return nil, nil, graph.Stack(ctx, err) + } + + return message, ChannelMessageInfo(message, size), nil +} + +// --------------------------------------------------------------------------- +// replies +// --------------------------------------------------------------------------- + +// GetReplies retrieves all replies to a Channel Message. +func (c Channels) GetReplies( + ctx context.Context, + teamID, channelID, messageID string, +) (serialization.Parsable, error) { + replies, err := c.Stable. + Client(). + Teams(). + ByTeamId(teamID). + Channels(). + ByChannelId(channelID). + Messages(). + ByChatMessageId(messageID). + Replies(). + Get(ctx, nil) + if err != nil { + return nil, graph.Stack(ctx, err) + } + + return replies, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func ChannelMessageInfo(msg models.ChatMessageable, size int64) *details.GroupsInfo { + created := ptr.Val(msg.GetCreatedDateTime()) + + return &details.GroupsInfo{ + ItemType: details.TeamsChannelMessage, + Size: size, + Created: created, + Modified: ptr.OrNow(msg.GetLastModifiedDateTime()), + } +} + +// --------------------------------------------------------------------------- +// helper funcs +// --------------------------------------------------------------------------- + +// CheckIDAndName is a validator that ensures the ID +// and name are populated and not zero valued. +func CheckIDAndName(c models.Channelable) error { + if c == nil { + return clues.New("nil container") + } + + id := ptr.Val(c.GetId()) + if len(id) == 0 { + return clues.New("container missing ID") + } + + dn := ptr.Val(c.GetDisplayName()) + if len(dn) == 0 { + return clues.New("container missing display name").With("container_id", id) + } + + return nil +} diff --git a/src/pkg/services/m365/api/channels_pager_test.go b/src/pkg/services/m365/api/channels_pager_test.go new file mode 100644 index 000000000..615e96ebe --- /dev/null +++ b/src/pkg/services/m365/api/channels_pager_test.go @@ -0,0 +1,67 @@ +package api_test + +import ( + "testing" + + "github.com/alcionai/clues" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/internal/tester/tconfig" +) + +type ChannelPagerIntgSuite struct { + tester.Suite + its intgTesterSetup +} + +func TestChannelPagerIntgSuite(t *testing.T) { + suite.Run(t, &ChannelPagerIntgSuite{ + Suite: tester.NewIntegrationSuite( + t, + [][]string{tconfig.M365AcctCredEnvs}), + }) +} + +func (suite *ChannelPagerIntgSuite) SetupSuite() { + suite.its = newIntegrationTesterSetup(suite.T()) +} + +// This will be added once 'pager' is implemented +// func (suite *ChannelPagerIntgSuite) TestChannels_GetPage() { +// t := suite.T() + +// ctx, flush := tester.NewContext(t) +// defer flush() + +// teamID := tconfig.M365TeamID(t) +// channelID := tconfig.M365ChannelID(t) +// pager := suite.its.ac.Channels().NewMessagePager(teamID, channelID, []string{}) +// a, err := pager.GetPage(ctx) +// assert.NoError(t, err, clues.ToCore(err)) +// assert.NotNil(t, a) +// } + +func (suite *ChannelPagerIntgSuite) TestChannels_Get() { + t := suite.T() + ctx, flush := tester.NewContext(t) + + defer flush() + + var ( + containerName = "General" + teamID = tconfig.M365TeamID(t) + chanClient = suite.its.ac.Channels() + ) + + // GET channel -should be found + channel, err := chanClient.GetChannelByName(ctx, teamID, containerName) + assert.NoError(t, err, clues.ToCore(err)) + assert.Equal(t, ptr.Val(channel.GetDisplayName()), containerName) + + // GET channel -should be found + _, err = chanClient.GetChannel(ctx, teamID, ptr.Val(channel.GetId())) + assert.NoError(t, err, clues.ToCore(err)) +}